Repository: eclipse/leshan
Pull request #958 (2021-01-08 17:28:04)

Add Read-Composite operation
This aims to implement #957.

~(This is work in progress, not in reviewable state or testable state)~

sbernard31: I finished to write a first "working" version. This was mainly to detect all the impact on current code base.
This should be enough to play with it but this is clearly not in a clean state.

Now I need to polish this (implement some missing details, clean the code, add more validation, add javadocs, some renames ...)
ReadComposite using "/" is not implemented now and will be probably not in this PR.

The API looks like this : 
```java
// read device model number
ReadCompositeResponse response = server.send(registration,
        new ReadCompositeRequest(ContentFormat.CBOR, ContentFormat.CBOR, "/3/0/0", "/1"));

if (response.isSuccess()) {
    List<LwM2mNode> content = response.getContent();
    content.get(0); // response for "/3/0/0"
    content.get(1); // response for "/1"
    ...
}
```
sbernard31: @madhushreegc @eliasweingaertner I think this is in in a reviewable and testable state.
(If you want to look at the code I advice to check commit by commit this will be more understandable)

My next step for another PR will be to look at the WriteComposite to check if the ReadComposite design matches correctly with it.



sbernard31: Unfortunately, I need to update the code by mainly switching all the `List<LwM2mPath>` + `List<LwM2mNode>` by `Map<LwM2mPath,  LwM2mNode>`.

This was mainly because to decode nodes from WriteComposite. In this case the list of `List<LwM2mPath>` is unknown and so the result must return Node+Path, I'm not sure I'm so clear ... (see first commit of  #959 for more details)

This impacts the `ReadCompositeResponse` API and so now it looks like : 
```java
// read device model number
ReadCompositeResponse response = server.send(registration,
        new ReadCompositeRequest(ContentFormat.CBOR, ContentFormat.CBOR, "/3/0/0", "/1"));

if (response.isSuccess()) {
    LwM2mResourceInstance resource = (LwM2mResourceInstance) response.getContent("/3/0/0");
    LwM2mObject object = (LwM2mObject) response.getContent("/1");
    ...
}
```

Patch:
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/LeshanClient.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/LeshanClient.java
index e97aeca4..d52fcc6b 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/LeshanClient.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/LeshanClient.java
@@ -42,6 +42,8 @@ import org.eclipse.leshan.client.observer.LwM2mClientObserverAdapter;
 import org.eclipse.leshan.client.observer.LwM2mClientObserverDispatcher;
 import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
 import org.eclipse.leshan.client.resource.LwM2mObjectTree;
+import org.eclipse.leshan.client.resource.LwM2mRootEnabler;
+import org.eclipse.leshan.client.resource.RootEnabler;
 import org.eclipse.leshan.client.resource.listener.ObjectListener;
 import org.eclipse.leshan.client.resource.listener.ObjectsListenerAdapter;
 import org.eclipse.leshan.client.servers.ServerIdentity;
@@ -67,6 +69,9 @@ public class LeshanClient implements LwM2mClient {
 
     private LwM2mObjectTree objectTree;
     private final BootstrapHandler bootstrapHandler;
+    private final LwM2mRootEnabler rootEnabler;
+    private final LwM2mNodeEncoder encoder;
+    private final LwM2mNodeDecoder decoder;
     private final RegistrationEngine engine;
     private final LwM2mClientObserverDispatcher observers;
 
@@ -101,6 +106,9 @@ public class LeshanClient implements LwM2mClient {
         Validate.notNull(coapConfig);
 
         objectTree = createObjectTree(objectEnablers);
+        rootEnabler = createRootEnabler(objectTree);
+        this.decoder = decoder;
+        this.encoder = encoder;
         observers = createClientObserverDispatcher();
         bootstrapHandler = createBoostrapHandler(objectTree);
         endpointsManager = createEndpointsManager(localAddress, coapConfig, dtlsConfigBuilder, trustStore,
@@ -118,6 +126,10 @@ public class LeshanClient implements LwM2mClient {
         coapApi = new CoapAPI();
     }
 
+    protected LwM2mRootEnabler createRootEnabler(LwM2mObjectTree tree) {
+        return new RootEnabler(tree);
+    }
+
     protected LwM2mObjectTree createObjectTree(List<? extends LwM2mObjectEnabler> objectEnablers) {
         return new LwM2mObjectTree(this, objectEnablers);
     }
@@ -143,7 +155,8 @@ public class LeshanClient implements LwM2mClient {
             @Override
             protected Resource createRoot() {
                 // Use to handle Delete on "/"
-                return new org.eclipse.leshan.client.californium.RootResource(engine, bootstrapHandler, this);
+                return new org.eclipse.leshan.client.californium.RootResource(engine, bootstrapHandler, this,
+                        rootEnabler, encoder, decoder);
             }
         };
 
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 e1007b42..b439ec8c 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
@@ -29,12 +29,19 @@ import org.eclipse.californium.core.network.Endpoint;
 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.servers.ServerIdentity;
 import org.eclipse.leshan.core.Link;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.codec.LwM2mNodeDecoder;
+import org.eclipse.leshan.core.node.codec.LwM2mNodeEncoder;
 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.ReadCompositeRequest;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
 import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
+import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.util.StringUtils;
 
 /**
@@ -44,13 +51,19 @@ public class RootResource extends LwM2mClientCoapResource {
 
     protected CoapServer coapServer;
     protected BootstrapHandler bootstrapHandler;
+    protected LwM2mRootEnabler rootEnabler;
+    protected LwM2mNodeEncoder encoder;
+    protected LwM2mNodeDecoder decoder;
 
-    public RootResource(RegistrationEngine registrationEngine, BootstrapHandler bootstrapHandler,
-            CoapServer coapServer) {
+    public RootResource(RegistrationEngine registrationEngine, BootstrapHandler bootstrapHandler, CoapServer coapServer,
+            LwM2mRootEnabler rootEnabler, LwM2mNodeEncoder encoder, LwM2mNodeDecoder decoder) {
         super("", registrationEngine);
         this.bootstrapHandler = bootstrapHandler;
         setVisible(false);
         this.coapServer = coapServer;
+        this.rootEnabler = rootEnabler;
+        this.encoder = encoder;
+        this.decoder = decoder;
     }
 
     @Override
@@ -74,6 +87,48 @@ public class RootResource extends LwM2mClientCoapResource {
         return;
     }
 
+    @Override
+    public void handleFETCH(CoapExchange exchange) {
+        ServerIdentity identity = getServerOrRejectRequest(exchange);
+        if (identity == null)
+            return;
+
+        // Manage Read Composite request
+        Request coapRequest = exchange.advanced().getRequest();
+
+        // Handle content format for the response
+        ContentFormat responseContentFormat = ContentFormat.SENML_CBOR; // use CBOR as default
+        if (exchange.getRequestOptions().hasAccept()) {
+            // If an request ask for a specific content format, use it (if we support it)
+            responseContentFormat = ContentFormat.fromCode(exchange.getRequestOptions().getAccept());
+            if (!encoder.isSupported(responseContentFormat)) {
+                exchange.respond(ResponseCode.NOT_ACCEPTABLE);
+                return;
+            }
+        }
+
+        // Decode Path to read
+        if (!exchange.getRequestOptions().hasContentFormat()) {
+            exchange.respond(ResponseCode.BAD_REQUEST);
+            return;
+        }
+        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());
+        } 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;
+    }
+
     @Override
     public void handleDELETE(CoapExchange exchange) {
         String URI = exchange.getRequestOptions().getUriPathString();
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
new file mode 100644
index 00000000..c1c2b616
--- /dev/null
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
@@ -0,0 +1,31 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.client.resource;
+
+import org.eclipse.leshan.client.servers.ServerIdentity;
+import org.eclipse.leshan.core.model.LwM2mModel;
+import org.eclipse.leshan.core.request.ReadCompositeRequest;
+import org.eclipse.leshan.core.response.ReadCompositeResponse;
+
+/**
+ * Enable request on root path.
+ */
+public interface LwM2mRootEnabler {
+
+    ReadCompositeResponse read(ServerIdentity identity, ReadCompositeRequest request);
+
+    LwM2mModel getModel();
+}
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
new file mode 100644
index 00000000..c461ef15
--- /dev/null
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
@@ -0,0 +1,123 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.client.resource;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.leshan.client.servers.ServerIdentity;
+import org.eclipse.leshan.core.model.LwM2mModel;
+import org.eclipse.leshan.core.model.ObjectModel;
+import org.eclipse.leshan.core.model.ResourceModel;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.ReadCompositeRequest;
+import org.eclipse.leshan.core.request.ReadRequest;
+import org.eclipse.leshan.core.response.ReadCompositeResponse;
+import org.eclipse.leshan.core.response.ReadResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A default implementation of {@link LwM2mRootEnabler}.
+ */
+public class RootEnabler implements LwM2mRootEnabler {
+
+    private static final Logger LOG = LoggerFactory.getLogger(RootEnabler.class);
+
+    private final LwM2mObjectTree tree;
+    private final LwM2mModel model;
+
+    public RootEnabler(final LwM2mObjectTree tree) {
+        this.tree = tree;
+        this.model = new LwM2mModel() {
+
+            @Override
+            public ResourceModel getResourceModel(int objectId, int resourceId) {
+                ObjectModel objectModel = this.getObjectModel(objectId);
+                if (objectModel != null)
+                    return objectModel.resources.get(resourceId);
+                return null;
+            }
+
+            @Override
+            public Collection<ObjectModel> getObjectModels() {
+                // TODO implements this ?
+                throw new UnsupportedOperationException("Not implemented");
+            }
+
+            @Override
+            public ObjectModel getObjectModel(int objectId) {
+                LwM2mObjectEnabler objectEnabler = tree.getObjectEnabler(objectId);
+                if (objectEnabler != null)
+                    return objectEnabler.getObjectModel();
+                return null;
+            }
+        };
+    }
+
+    @Override
+    public ReadCompositeResponse read(ServerIdentity identity, ReadCompositeRequest request) {
+        List<LwM2mPath> paths = request.getPaths();
+        if (paths.size() == 1 && paths.get(0).isRoot()) {
+            // TODO implement read for "/" use case.
+            return ReadCompositeResponse.internalServerError("Not implemented yet");
+        }
+
+        // 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.read(identity,
+                        new ReadRequest(request.getResponseContentFormat(), path, request.getCoapRequest()));
+                if (response.isSuccess()) {
+                    node = response.getContent();
+                    isEmpty = false;
+                } else {
+                    LOG.debug("Server {} try to read node {} in a Read-Composite Request {} but it failed for {} {}",
+                            identity, path, paths, response.getCode(), response.getErrorMessage());
+                }
+            } else {
+                LOG.debug(
+                        "Server {} try to read node {} in a Read-Composite Request {} but it failed because Object {} is not supported",
+                        identity, path, paths, objectId);
+            }
+            // LWM2M specification says that "Read-Composite operation is treated as
+            // non-atomic and handled as best effort by the client. That is, if any of the requested resources do not
+            // have a valid value to return, they will not be included in the response".
+            // So If we are not able to read a node (error or not supported we just ignore it) and add NULL to the list.
+            content.put(path, node);
+        }
+        if (isEmpty) {
+            return ReadCompositeResponse.notFound();
+        } else {
+            return ReadCompositeResponse.success(content);
+        }
+    }
+
+    @Override
+    public LwM2mModel getModel() {
+        return model;
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeDecoder.java
index cacc4b9e..43af3eea 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeDecoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeDecoder.java
@@ -34,6 +34,7 @@ import org.eclipse.leshan.core.node.codec.cbor.LwM2mNodeCborDecoder;
 import org.eclipse.leshan.core.node.codec.json.LwM2mNodeJsonDecoder;
 import org.eclipse.leshan.core.node.codec.opaque.LwM2mNodeOpaqueDecoder;
 import org.eclipse.leshan.core.node.codec.senml.LwM2mNodeSenMLDecoder;
+import org.eclipse.leshan.core.node.codec.senml.LwM2mPathSenMLDecoder;
 import org.eclipse.leshan.core.node.codec.text.LwM2mNodeTextDecoder;
 import org.eclipse.leshan.core.node.codec.tlv.LwM2mNodeTlvDecoder;
 import org.eclipse.leshan.core.request.ContentFormat;
@@ -44,7 +45,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * A default {@link LwM2mNodeDecoder} which support LWM2M {@link ContentFormat} :
+ * A default {@link LwM2mNodeDecoder}.
+ * <p>
+ * For {@link LwM2mNode} decoding, those formats are supported :
  * <ul>
  * <li>{@link ContentFormat#TEXT}</li>
  * <li>{@link ContentFormat#OPAQUE}</li>
@@ -54,12 +57,18 @@ import org.slf4j.LoggerFactory;
  * <li>{@link ContentFormat#TLV}</li>
  * <li>{@link ContentFormat#JSON} (old one from LwM2m v1.0)</li>
  * </ul>
+ * <p>
+ * For {@link LwM2mPath} decoding, those formats are supported :
+ * <ul>
+ * <li>{@link ContentFormat#SENML_JSON}</li>
+ * <li>{@link ContentFormat#SENML_CBOR}</li>
+ * </ul>
  */
 public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
 
     private static final Logger LOG = LoggerFactory.getLogger(DefaultLwM2mNodeDecoder.class);
 
-    public static Map<ContentFormat, NodeDecoder> getDefaultDecoders(boolean supportDeprecatedContentFormat) {
+    public static Map<ContentFormat, NodeDecoder> getDefaultNodeDecoders(boolean supportDeprecatedContentFormat) {
         Map<ContentFormat, NodeDecoder> decoders = new HashMap<>();
         decoders.put(ContentFormat.TEXT, new LwM2mNodeTextDecoder());
         decoders.put(ContentFormat.OPAQUE, new LwM2mNodeOpaqueDecoder());
@@ -81,7 +90,17 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
         return decoders;
     }
 
-    protected final Map<ContentFormat, NodeDecoder> decoders;
+    public static Map<ContentFormat, PathDecoder> getDefaultPathDecoder() {
+        Map<ContentFormat, PathDecoder> decoders = new HashMap<>();
+        decoders.put(ContentFormat.SENML_JSON, new LwM2mPathSenMLDecoder(new SenMLJsonJacksonEncoderDecoder(true)));
+        decoders.put(ContentFormat.SENML_CBOR,
+                new LwM2mPathSenMLDecoder(new SenMLCborUpokecenterEncoderDecoder(false, true)));
+
+        return decoders;
+    }
+
+    protected final Map<ContentFormat, NodeDecoder> nodeDecoders;
+    protected final Map<ContentFormat, PathDecoder> pathDecoders;
 
     /**
      * Create {@link DefaultLwM2mNodeDecoder} without support of old TLV and JSON code.
@@ -99,11 +118,17 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
      * @param supportDeprecatedContentFormat True to accept to decode old code.
      */
     public DefaultLwM2mNodeDecoder(boolean supportDeprecatedContentFormat) {
-        this(getDefaultDecoders(supportDeprecatedContentFormat));
+        this(getDefaultNodeDecoders(supportDeprecatedContentFormat));
     }
 
-    public DefaultLwM2mNodeDecoder(Map<ContentFormat, NodeDecoder> decoders) {
-        this.decoders = decoders;
+    public DefaultLwM2mNodeDecoder(Map<ContentFormat, NodeDecoder> nodeDecoders) {
+        this(nodeDecoders, getDefaultPathDecoder());
+    }
+
+    public DefaultLwM2mNodeDecoder(Map<ContentFormat, NodeDecoder> nodeDecoders,
+            Map<ContentFormat, PathDecoder> pathDecoders) {
+        this.nodeDecoders = nodeDecoders;
+        this.pathDecoders = pathDecoders;
     }
 
     @Override
@@ -123,13 +148,36 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
             throw new CodecException("Content format is mandatory. [%s]", path);
         }
 
-        NodeDecoder decoder = decoders.get(format);
+        NodeDecoder decoder = nodeDecoders.get(format);
         if (decoder == null) {
             throw new CodecException("Content format %s is not supported [%s]", format, path);
         }
         return decoder.decode(content, path, model, nodeClass);
     }
 
+    @Override
+    public Map<LwM2mPath, LwM2mNode> decodeNodes(byte[] content, ContentFormat format, List<LwM2mPath> paths,
+            LwM2mModel model) throws CodecException {
+        LOG.debug("Decoding value for path {} and format {}: {}", paths, format, content);
+        Validate.notNull(paths);
+
+        if (format == null) {
+            throw new CodecException("Content format is mandatory. [%s]", paths);
+        }
+
+        NodeDecoder decoder = nodeDecoders.get(format);
+        if (decoder == null) {
+            throw new CodecException("Content format %s is not supported [%s]", format, paths);
+        }
+
+        if (decoder instanceof MultiNodeDecoder) {
+            return ((MultiNodeDecoder) decoder).decodeNodes(content, paths, model);
+        } else {
+            throw new CodecException("Decoder does not support multi node decoding for this content format %s [%s] ",
+                    format, paths);
+        }
+    }
+
     @Override
     public List<TimestampedLwM2mNode> decodeTimestampedData(byte[] content, ContentFormat format, LwM2mPath path,
             LwM2mModel model) throws CodecException {
@@ -140,7 +188,7 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
             throw new CodecException("Content format is mandatory. [%s]", path);
         }
 
-        NodeDecoder decoder = decoders.get(format);
+        NodeDecoder decoder = nodeDecoders.get(format);
         if (decoder == null) {
             throw new CodecException("Content format %s is not supported [%s]", format, path);
         }
@@ -153,6 +201,22 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
         }
     }
 
+    @Override
+    public List<LwM2mPath> decodePaths(byte[] content, ContentFormat format) throws CodecException {
+        LOG.debug("Decoding paths encoded with {}: {}", format, content);
+        Validate.notNull(content);
+
+        if (format == null) {
+            throw new CodecException("Content format is mandatory.");
+        }
+
+        PathDecoder decoder = pathDecoders.get(format);
+        if (decoder == null) {
+            throw new CodecException("Content format %s is not supported for ath decoding", format);
+        }
+        return decoder.decode(content);
+    }
+
     private static List<TimestampedLwM2mNode> toTimestampedNodes(LwM2mNode node) {
         if (node == null)
             return Collections.emptyList();
@@ -177,6 +241,6 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
 
     @Override
     public boolean isSupported(ContentFormat format) {
-        return decoders.get(format) != null;
+        return nodeDecoders.get(format) != null;
     }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeEncoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeEncoder.java
index 2f1e1c8b..46f6131f 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeEncoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/DefaultLwM2mNodeEncoder.java
@@ -19,6 +19,7 @@ package org.eclipse.leshan.core.node.codec;
 import java.util.HashMap;
 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;
@@ -28,6 +29,7 @@ import org.eclipse.leshan.core.node.codec.cbor.LwM2mNodeCborEncoder;
 import org.eclipse.leshan.core.node.codec.json.LwM2mNodeJsonEncoder;
 import org.eclipse.leshan.core.node.codec.opaque.LwM2mNodeOpaqueEncoder;
 import org.eclipse.leshan.core.node.codec.senml.LwM2mNodeSenMLEncoder;
+import org.eclipse.leshan.core.node.codec.senml.LwM2mPathSenMLEncoder;
 import org.eclipse.leshan.core.node.codec.text.LwM2mNodeTextEncoder;
 import org.eclipse.leshan.core.node.codec.tlv.LwM2mNodeTlvEncoder;
 import org.eclipse.leshan.core.request.ContentFormat;
@@ -38,7 +40,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * A default {@link LwM2mNodeEncoder} which support LWM2M {@link ContentFormat} :
+ * A default {@link LwM2mNodeEncoder}.
+ * <p>
+ * For {@link LwM2mNode} encoding, those formats are supported :
  * <ul>
  * <li>{@link ContentFormat#TEXT}</li>
  * <li>{@link ContentFormat#OPAQUE}</li>
@@ -48,12 +52,18 @@ import org.slf4j.LoggerFactory;
  * <li>{@link ContentFormat#TLV}</li>
  * <li>{@link ContentFormat#JSON} (old one from LwM2m v1.0)</li>
  * </ul>
+ * <p>
+ * For {@link LwM2mPath} encoding, those formats are supported :
+ * <ul>
+ * <li>{@link ContentFormat#SENML_JSON}</li>
+ * <li>{@link ContentFormat#SENML_CBOR}</li>
+ * </ul>
  */
 public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
 
     private static final Logger LOG = LoggerFactory.getLogger(DefaultLwM2mNodeEncoder.class);
 
-    public static Map<ContentFormat, NodeEncoder> getDefaultEncoders(boolean supportDeprecatedContentFormat) {
+    public static Map<ContentFormat, NodeEncoder> getDefaultNodeEncoders(boolean supportDeprecatedContentFormat) {
         Map<ContentFormat, NodeEncoder> encoders = new HashMap<>();
         encoders.put(ContentFormat.TEXT, new LwM2mNodeTextEncoder());
         encoders.put(ContentFormat.OPAQUE, new LwM2mNodeOpaqueEncoder());
@@ -75,8 +85,18 @@ public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
         return encoders;
     }
 
+    public static Map<ContentFormat, PathEncoder> getDefaultPathEncoder() {
+        Map<ContentFormat, PathEncoder> encoders = new HashMap<>();
+        encoders.put(ContentFormat.SENML_JSON, new LwM2mPathSenMLEncoder(new SenMLJsonJacksonEncoderDecoder(true)));
+        encoders.put(ContentFormat.SENML_CBOR,
+                new LwM2mPathSenMLEncoder(new SenMLCborUpokecenterEncoderDecoder(false, true)));
+
+        return encoders;
+    }
+
     protected final LwM2mValueConverter converter;
-    protected final Map<ContentFormat, NodeEncoder> encoders;
+    protected final Map<ContentFormat, NodeEncoder> nodeEncoders;
+    protected final Map<ContentFormat, PathEncoder> pathEncoders;
 
     /**
      * Create {@link DefaultLwM2mNodeEncoder} without support of old TLV and JSON code.
@@ -102,11 +122,13 @@ public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
     }
 
     public DefaultLwM2mNodeEncoder(LwM2mValueConverter converter, boolean supportDeprecatedContentFormat) {
-        this(getDefaultEncoders(supportDeprecatedContentFormat), converter);
+        this(getDefaultNodeEncoders(supportDeprecatedContentFormat), getDefaultPathEncoder(), converter);
     }
 
-    public DefaultLwM2mNodeEncoder(Map<ContentFormat, NodeEncoder> encoders, LwM2mValueConverter converter) {
-        this.encoders = encoders;
+    public DefaultLwM2mNodeEncoder(Map<ContentFormat, NodeEncoder> nodeEncoders,
+            Map<ContentFormat, PathEncoder> pathEncoders, LwM2mValueConverter converter) {
+        this.nodeEncoders = nodeEncoders;
+        this.pathEncoders = pathEncoders;
         this.converter = converter;
     }
 
@@ -118,7 +140,7 @@ public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
             throw new CodecException("Content format is mandatory. [%s]", path);
         }
 
-        NodeEncoder encoder = encoders.get(format);
+        NodeEncoder encoder = nodeEncoders.get(format);
         if (encoder == null) {
             throw new CodecException("Content format %s is not supported [%s]", format, path);
         }
@@ -128,6 +150,32 @@ public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
         return encoded;
     }
 
+    @Override
+    public byte[] encodeNodes(Map<LwM2mPath, LwM2mNode> nodes, ContentFormat format, LwM2mModel model)
+            throws CodecException {
+        // Validate arguments
+        Validate.notEmpty(nodes);
+        Set<LwM2mPath> paths = nodes.keySet();
+
+        // Search encoder
+        if (format == null) {
+            throw new CodecException("Content format is mandatory. [%s]", paths);
+        }
+        NodeEncoder encoder = nodeEncoders.get(format);
+        if (encoder == null) {
+            throw new CodecException("Content format %s is not supported [%s]", format, paths);
+        }
+        if (!(encoder instanceof MultiNodeEncoder)) {
+            throw new CodecException("Cannot encode several nodes with format %s. [%s]", format, paths);
+        }
+
+        // Encode nodes
+        LOG.trace("Encoding nodes {} for path {} and format {}", nodes, paths, format);
+        byte[] encoded = ((MultiNodeEncoder) encoder).encodeNodes(nodes, model, converter);
+        LOG.trace("Encoded nodes {}: {}", nodes, encoded);
+        return encoded;
+    }
+
     @Override
     public byte[] encodeTimestampedData(List<TimestampedLwM2mNode> timestampedNodes, ContentFormat format,
             LwM2mPath path, LwM2mModel model) throws CodecException {
@@ -136,7 +184,7 @@ public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
             throw new CodecException("Content format is mandatory. [%s]", path);
         }
 
-        NodeEncoder encoder = encoders.get(format);
+        NodeEncoder encoder = nodeEncoders.get(format);
         if (encoder == null) {
             throw new CodecException("Content format %s is not supported [%s]", format, path);
         }
@@ -151,8 +199,26 @@ public class DefaultLwM2mNodeEncoder implements LwM2mNodeEncoder {
 
     }
 
+    @Override
+    public byte[] encodePaths(List<LwM2mPath> paths, ContentFormat format) throws CodecException {
+        Validate.notEmpty(paths);
+
+        if (format == null) {
+            throw new CodecException("Content format is mandatory. [%s]", paths);
+        }
+
+        PathEncoder encoder = pathEncoders.get(format);
+        if (encoder == null) {
+            throw new CodecException("Content format %s is not supported for paths encoding[%s]", format, paths);
+        }
+        LOG.trace("Encoding paths {} with format {}", paths, format);
+        byte[] encoded = encoder.encode(paths);
+        LOG.trace("Encoded path {}: {}", paths, encoded);
+        return encoded;
+    }
+
     @Override
     public boolean isSupported(ContentFormat format) {
-        return encoders.get(format) != null;
+        return nodeEncoders.get(format) != null;
     }
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoder.java
index 085b8d18..cf980e79 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoder.java
@@ -16,6 +16,7 @@
 package org.eclipse.leshan.core.node.codec;
 
 import java.util.List;
+import java.util.Map;
 
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.node.LwM2mNode;
@@ -24,7 +25,12 @@ import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.request.ContentFormat;
 
 /**
- * A Decoder of {@link LwM2mNode} which should support several {@link ContentFormat} and time-stamped values.
+ * A Decoder which should support several {@link ContentFormat} and is able to decode :
+ * <ul>
+ * <li>a {@link LwM2mNode}</li>
+ * <li>a time-stamped {@link LwM2mNode} (e.g. for historical representations)</li>
+ * <li>a list of {@link LwM2mNode} (e.g. for composite operation)</li>
+ * </ul>
  */
 public interface LwM2mNodeDecoder {
 
@@ -38,7 +44,7 @@ public interface LwM2mNodeDecoder {
      * @param path the path of the node to build
      * @param model the collection of supported object models
      * @return the resulting node
-     * @throws CodecException if there payload is malformed.
+     * @throws CodecException if content is malformed.
      */
     LwM2mNode decode(byte[] content, ContentFormat format, LwM2mPath path, LwM2mModel model) throws CodecException;
 
@@ -51,11 +57,27 @@ public interface LwM2mNodeDecoder {
      * @param model the collection of supported object models
      * @param nodeClass the class of the {@link LwM2mNode} to decode
      * @return the resulting node
-     * @throws CodecException if there payload is malformed.
+     * @throws CodecException if content is malformed.
      */
     <T extends LwM2mNode> T decode(byte[] content, ContentFormat format, LwM2mPath path, LwM2mModel model,
             Class<T> nodeClass) throws CodecException;
 
+    /**
+     * Deserializes a binary content into a list of {@link LwM2mNode} of the expected type.
+     * <p>
+     * Expected type is guess from list of {@link LwM2mPath}.
+     *
+     * @param content the content
+     * @param format the content format
+     * @param paths the list of path of node to build.
+     * @param model the collection of supported object models
+     * @return the Map of {@link LwM2mPath} to decoded {@link LwM2mNode}. value can be <code>null</code> if no data was
+     *         available for a given path
+     * @throws CodecException if content is malformed.
+     */
+    Map<LwM2mPath, LwM2mNode> decodeNodes(byte[] content, ContentFormat format, List<LwM2mPath> paths, LwM2mModel model)
+            throws CodecException;
+
     /**
      * Deserializes a binary content into a list of time-stamped {@link LwM2mNode} ordering by time-stamp.
      *
@@ -64,11 +86,22 @@ public interface LwM2mNodeDecoder {
      * @param path the path of the node to build
      * @param model the collection of supported object models
      * @return the resulting list of time-stamped {@link LwM2mNode} ordering by time-stamp
-     * @exception CodecException if there payload is malformed.
+     * @exception CodecException if content is malformed.
      */
     List<TimestampedLwM2mNode> decodeTimestampedData(byte[] content, ContentFormat format, LwM2mPath path,
             LwM2mModel model) throws CodecException;
 
+    /**
+     * Deserializes a binary content into a list of {@link LwM2mPath}.
+     * 
+     * @param content the content to decode
+     * @param format the format used to encode the content
+     * @return a list of {@link LwM2mPath}
+     * 
+     * @throws CodecException if content is malformed.
+     */
+    List<LwM2mPath> decodePaths(byte[] content, ContentFormat format) throws CodecException;
+
     /**
      * return true is the given {@link ContentFormat} is supported
      */
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoder.java
index 8aff71d7..30ea3aa3 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoder.java
@@ -16,6 +16,7 @@
 package org.eclipse.leshan.core.node.codec;
 
 import java.util.List;
+import java.util.Map;
 
 import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.node.LwM2mNode;
@@ -24,7 +25,12 @@ import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.request.ContentFormat;
 
 /**
- * An Encoder of {@link LwM2mNode} which should support several {@link ContentFormat} and time-stamped values.
+ * A LWM2M Encoder which supports several {@link ContentFormat} and is able to encode :
+ * <ul>
+ * <li>a {@link LwM2mNode}</li>
+ * <li>a time-stamped {@link LwM2mNode} (e.g. for historical representations)</li>
+ * <li>a list of {@link LwM2mNode} (e.g. for composite operation)</li>
+ * </ul>
  */
 public interface LwM2mNodeEncoder {
 
@@ -36,10 +42,22 @@ public interface LwM2mNodeEncoder {
      * @param path the path of the node to serialize
      * @param model the collection of supported object models
      * @return the encoded node as a byte array
-     * @throws CodecException if there payload is malformed.
+     * @throws CodecException if encoding failed.
      */
     byte[] encode(LwM2mNode node, ContentFormat format, LwM2mPath path, LwM2mModel model) throws CodecException;
 
+    /**
+     * Serializes a list of {@link LwM2mNode} using the given content format.
+     *
+     * @param nodes the Map from {@link LwM2mPath} to {@link LwM2mNode} to serialize. value can be <code>null</code> if
+     *        no data was available for a given path
+     * @param format the content format
+     * @param model the collection of supported object models
+     * @return the encoded nodes as a byte array
+     * @throws CodecException if encoding failed.
+     */
+    byte[] encodeNodes(Map<LwM2mPath, LwM2mNode> nodes, ContentFormat format, LwM2mModel model) throws CodecException;
+
     /**
      * Serializes a list of time-stamped {@link LwM2mNode} with the given content format.
      *
@@ -48,11 +66,22 @@ public interface LwM2mNodeEncoder {
      * @param path the path of the node to serialize
      * @param model the collection of supported object models
      * @return the encoded node as a byte array
-     * @throws CodecException if there payload is malformed.
+     * @throws CodecException if encoding failed.
      */
     byte[] encodeTimestampedData(List<TimestampedLwM2mNode> timestampedNodes, ContentFormat format, LwM2mPath path,
             LwM2mModel model) throws CodecException;
 
+    /**
+     * Serializes a list of {@link LwM2mPath} with the given content format.
+     * 
+     * @param paths The list of {@link LwM2mPath} to encode
+     * @param format the {@link ContentFormat} used to encode
+     * @return the encoded path as byte array
+     * 
+     * @throws CodecException if encoding failed.
+     */
+    byte[] encodePaths(List<LwM2mPath> paths, ContentFormat format) throws CodecException;
+
     /**
      * return true is the given {@link ContentFormat} is supported
      */
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/MultiNodeDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/MultiNodeDecoder.java
new file mode 100644
index 00000000..18643e89
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/MultiNodeDecoder.java
@@ -0,0 +1,47 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec;
+
+import java.util.List;
+import java.util.Map;
+
+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.request.ContentFormat;
+
+/**
+ * A decoder for a list of {@link LwM2mNode} which supports only one {@link ContentFormat}.
+ * 
+ * @see DefaultLwM2mNodeDecoder
+ */
+public interface MultiNodeDecoder {
+
+    /**
+     * Deserializes a binary content into a list of {@link LwM2mNode} of the expected type.
+     * <p>
+     * Expected type is guess from list of {@link LwM2mPath}.
+     *
+     * @param content the content
+     * @param paths the list of path of node to build.
+     * @param model the collection of supported object models
+     * @return the Map of {@link LwM2mPath} to decoded {@link LwM2mNode}. value can be <code>null</code> if no data was
+     *         available for a given path
+     * @throws CodecException if there payload is malformed.
+     */
+    Map<LwM2mPath, LwM2mNode> decodeNodes(byte[] content, List<LwM2mPath> paths, LwM2mModel model)
+            throws CodecException;
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/MultiNodeEncoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/MultiNodeEncoder.java
new file mode 100644
index 00000000..ac060f75
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/MultiNodeEncoder.java
@@ -0,0 +1,43 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec;
+
+import java.util.Map;
+
+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.request.ContentFormat;
+
+/**
+ * An encoder for a list of {@link LwM2mNode} which support only one {@link ContentFormat}.
+ * 
+ * @see DefaultLwM2mNodeEncoder
+ */
+public interface MultiNodeEncoder {
+
+    /**
+     * Serializes a list of {@link LwM2mNode} using the given content format.
+     *
+     * @param nodes the Map from {@link LwM2mPath} to {@link LwM2mNode} to serialize. value can be <code>null</code> if
+     *        no data was available for a given path
+     * @param model the collection of supported object models
+     * @return the encoded nodes as a byte array
+     * @throws CodecException if there payload is malformed.
+     */
+    byte[] encodeNodes(Map<LwM2mPath, LwM2mNode> nodes, LwM2mModel model, LwM2mValueConverter converter)
+            throws CodecException;
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/PathDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/PathDecoder.java
new file mode 100644
index 00000000..ef0281f7
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/PathDecoder.java
@@ -0,0 +1,34 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec;
+
+import java.util.List;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+
+/**
+ * a {@link LwM2mPath} decoder.
+ * 
+ * @see DefaultLwM2mNodeDecoder
+ */
+public interface PathDecoder {
+
+    /**
+     * @param content The content to decode
+     * @return The list of {@link LwM2mPath}
+     */
+    List<LwM2mPath> decode(byte[] content);
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/PathEncoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/PathEncoder.java
new file mode 100644
index 00000000..bdeeb4a8
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/PathEncoder.java
@@ -0,0 +1,34 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec;
+
+import java.util.List;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+
+/**
+ * A {@link LwM2mPath} encoder.
+ * 
+ * @see DefaultLwM2mNodeEncoder
+ */
+public interface PathEncoder {
+
+    /**
+     * @param paths The list of {@link LwM2mPath} to encode
+     * @return the encoded path as a byte array.
+     */
+    byte[] encode(List<LwM2mPath> paths);
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLDecoder.java
index 3e9aff85..f0bc445f 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLDecoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLDecoder.java
@@ -39,6 +39,8 @@ import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.node.ObjectLink;
 import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.node.codec.CodecException;
+import org.eclipse.leshan.core.node.codec.DefaultLwM2mNodeDecoder;
+import org.eclipse.leshan.core.node.codec.MultiNodeDecoder;
 import org.eclipse.leshan.core.node.codec.TimestampedNodeDecoder;
 import org.eclipse.leshan.core.util.Base64;
 import org.eclipse.leshan.core.util.datatype.NumberUtil;
@@ -50,7 +52,7 @@ import org.eclipse.leshan.senml.SenMLRecord;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
+public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder, MultiNodeDecoder {
 
     private static final Logger LOG = LoggerFactory.getLogger(LwM2mNodeSenMLDecoder.class);
 
@@ -65,125 +67,222 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
     public <T extends LwM2mNode> T decode(byte[] content, LwM2mPath path, LwM2mModel model, Class<T> nodeClass)
             throws CodecException {
         try {
+            // Decode SenML pack
             SenMLPack pack = decoder.fromSenML(content);
-            List<TimestampedLwM2mNode> timestampedNodes = parseSenMLPack(pack, path, model, nodeClass);
-            if (timestampedNodes.size() == 0) {
-                return null;
-            } else if (timestampedNodes.size() == 1 && timestampedNodes.get(0).getTimestamp() == null) {
-                return (T) timestampedNodes.get(0).getNode();
-            } else {
-                throw new CodecException("Unable to decode node[path:%s] : value should not be timestamped", path);
+            List<SenMLRecord> records = pack.getRecords();
+
+            // Resolve records
+            LwM2mSenMLResolver resolver = new LwM2mSenMLResolver();
+            Collection<LwM2mResolvedSenMLRecord> resolvedRecords = new ArrayList<>(records.size());
+            for (SenMLRecord record : records) {
+                LwM2mResolvedSenMLRecord resolvedRecord = resolver.resolve(record);
+                // Validate SenML resolved name
+                if (!resolvedRecord.getPath().isResourceInstance() && !resolvedRecord.getPath().isResource()) {
+                    throw new CodecException(
+                            "Invalid path [%s] for resource, it should be a resource or a resource instance path",
+                            resolvedRecord.getName());
+                }
+                if (!resolvedRecord.getPath().startWith(path)) {
+                    throw new CodecException("Invalid path [%s] for resource, it should start by %s",
+                            resolvedRecord.getPath(), path);
+                }
+                if (resolvedRecord.getTimeStamp() != null) {
+                    throw new CodecException("Unable to decode node[path:%s] : value should not be timestamped", path);
+                }
+                resolvedRecords.add(resolvedRecord);
             }
+
+            // Parse records and create node
+            return (T) parseRecords(resolvedRecords, path, model, nodeClass);
         } catch (SenMLException e) {
             String jsonStrValue = content != null ? new String(content) : "";
             throw new CodecException(e, "Unable to decode node[path:%s] : %s", path, jsonStrValue, e);
         }
     }
 
+    @Override
+    public Map<LwM2mPath, LwM2mNode> decodeNodes(byte[] content, List<LwM2mPath> paths, LwM2mModel model)
+            throws CodecException {
+        try {
+            // Decode SenML pack
+            SenMLPack pack = decoder.fromSenML(content);
+
+            // Resolve records & Group it by time-stamp
+            Map<LwM2mPath, Collection<LwM2mResolvedSenMLRecord>> recordsByPath = groupByPath(pack.getRecords(), paths);
+
+            // Fill nodes collections
+            Map<LwM2mPath, LwM2mNode> nodes = new HashMap<>();
+            for (LwM2mPath path : paths) {
+                Collection<LwM2mResolvedSenMLRecord> records = recordsByPath.get(path);
+                if (records.isEmpty()) {
+                    // Node can be null as the LWM2M specification says that "Read-Composite operation is
+                    // treated as non-atomic and handled as best effort by the client. That is, if any of the
+                    // requested
+                    // resources do not have a valid value to return, they will not be included in the response".
+                    // Meaning that a given path could have no corresponding value.
+                    nodes.put(path, null);
+                } else {
+                    LwM2mNode node = parseRecords(recordsByPath.get(path), path, model,
+                            DefaultLwM2mNodeDecoder.nodeClassFromPath(path));
+                    nodes.put(path, node);
+                }
+            }
+            return nodes;
+        } catch (SenMLException e) {
+            String jsonStrValue = content != null ? new String(content) : "";
+            throw new CodecException(e, "Unable to decode nodes[path:%s] : %s", paths, jsonStrValue, e);
+        }
+    }
+
     @Override
     public List<TimestampedLwM2mNode> decodeTimestampedData(byte[] content, LwM2mPath path, LwM2mModel model,
             Class<? extends LwM2mNode> nodeClass) throws CodecException {
         try {
+            // Decode SenML pack
             SenMLPack pack = decoder.fromSenML(content);
-            return parseSenMLPack(pack, path, model, nodeClass);
+
+            // Resolve records & Group it by time-stamp
+            Map<Long, Collection<LwM2mResolvedSenMLRecord>> recordsByTimestamp = groupRecordByTimestamp(
+                    pack.getRecords(), path);
+
+            // Fill time-stamped nodes collection
+            List<TimestampedLwM2mNode> timestampedNodes = new ArrayList<>();
+            for (Entry<Long, Collection<LwM2mResolvedSenMLRecord>> entryByTimestamp : recordsByTimestamp.entrySet()) {
+                LwM2mNode node = parseRecords(entryByTimestamp.getValue(), path, model, nodeClass);
+                // add time-stamped node
+                timestampedNodes.add(new TimestampedLwM2mNode(entryByTimestamp.getKey(), node));
+            }
+            return timestampedNodes;
         } catch (SenMLException e) {
             String jsonStrValue = content != null ? new String(content) : "";
             throw new CodecException(e, "Unable to decode node[path:%s] : %s", path, jsonStrValue, e);
         }
     }
 
-    private List<TimestampedLwM2mNode> parseSenMLPack(SenMLPack pack, LwM2mPath path, LwM2mModel model,
+    /**
+     * Parse records for a given LWM2M path.
+     */
+    private LwM2mNode parseRecords(Collection<LwM2mResolvedSenMLRecord> records, LwM2mPath path, LwM2mModel model,
             Class<? extends LwM2mNode> nodeClass) throws CodecException {
 
-        LOG.trace("Parsing SenML JSON object for path {}: {}", path, pack);
+        LOG.trace("Parsing SenML records for path {}: {}", path, records);
 
-        // Group Records by time-stamp
-        Map<Long, Collection<ResolvedSenMLRecord>> recordsByTimestamp = groupRecordByTimestamp(pack.getRecords(), path);
+        // Group records by instance
+        Map<Integer, Collection<LwM2mResolvedSenMLRecord>> recordsByInstanceId = groupRecordsByInstanceId(records);
 
-        // fill time-stamped nodes collection
-        List<TimestampedLwM2mNode> timestampedNodes = new ArrayList<>();
-        for (Entry<Long, Collection<ResolvedSenMLRecord>> entryByTimestamp : recordsByTimestamp.entrySet()) {
+        // Create lwm2m node
+        LwM2mNode node = null;
+        if (nodeClass == LwM2mObject.class) {
+            Collection<LwM2mObjectInstance> instances = new ArrayList<>();
+            for (Entry<Integer, Collection<LwM2mResolvedSenMLRecord>> entryByInstanceId : recordsByInstanceId
+                    .entrySet()) {
+                Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(entryByInstanceId.getValue(), path,
+                        model);
 
-            // Group records by instance
-            Map<Integer, Collection<ResolvedSenMLRecord>> recordsByInstanceId = groupRecordsByInstanceId(
-                    entryByTimestamp.getValue());
+                instances.add(new LwM2mObjectInstance(entryByInstanceId.getKey(), resourcesMap.values()));
+            }
 
-            // Create lwm2m node
-            LwM2mNode node = null;
-            if (nodeClass == LwM2mObject.class) {
-                Collection<LwM2mObjectInstance> instances = new ArrayList<>();
-                for (Entry<Integer, Collection<ResolvedSenMLRecord>> entryByInstanceId : recordsByInstanceId
-                        .entrySet()) {
-                    Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(entryByInstanceId.getValue(), path,
-                            model);
+            node = new LwM2mObject(path.getObjectId(), instances);
+        } else if (nodeClass == LwM2mObjectInstance.class) {
+            // validate we have resources for only 1 instance
+            if (recordsByInstanceId.size() != 1)
+                throw new CodecException("One instance expected in the payload [path:%s]", path);
+
+            // Extract resources
+            Entry<Integer, Collection<LwM2mResolvedSenMLRecord>> instanceEntry = recordsByInstanceId.entrySet()
+                    .iterator().next();
+            Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(instanceEntry.getValue(), path, model);
+
+            // Create instance
+            node = new LwM2mObjectInstance(instanceEntry.getKey(), resourcesMap.values());
+        } else if (nodeClass == LwM2mResource.class) {
+            // validate we have resources for only 1 instance
+            if (recordsByInstanceId.size() > 1)
+                throw new CodecException("Only one instance expected in the payload [path:%s]", path);
+
+            // Extract resources
+            Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(
+                    recordsByInstanceId.values().iterator().next(), path, model);
+
+            // validate there is only 1 resource
+            if (resourcesMap.size() != 1)
+                throw new CodecException("One resource should be present in the payload [path:%s]", path);
+
+            node = resourcesMap.values().iterator().next();
+        } else if (nodeClass == LwM2mResourceInstance.class) {
+            // validate we have resources for only 1 instance
+            if (recordsByInstanceId.size() > 1)
+                throw new CodecException("Only one instance expected in the payload [path:%s]", path);
+
+            // Extract resources
+            Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(
+                    recordsByInstanceId.values().iterator().next(), path, model);
+
+            // validate there is only 1 resource
+            if (resourcesMap.size() != 1)
+                throw new CodecException("One resource should be present in the payload [path:%s]", path);
+
+            LwM2mResource resource = resourcesMap.values().iterator().next();
+            if (!resource.isMultiInstances()) {
+                throw new CodecException("Resource should be multi Instances resource [path:%s]", path);
+            }
 
-                    instances.add(new LwM2mObjectInstance(entryByInstanceId.getKey(), resourcesMap.values()));
-                }
+            if (resource.getInstances().isEmpty()) {
+                throw new CodecException("Resource instances should not be not empty [path:%s]", path);
+            }
 
-                node = new LwM2mObject(path.getObjectId(), instances);
-            } else if (nodeClass == LwM2mObjectInstance.class) {
-                // validate we have resources for only 1 instance
-                if (recordsByInstanceId.size() != 1)
-                    throw new CodecException("One instance expected in the payload [path:%s]", path);
-
-                // Extract resources
-                Entry<Integer, Collection<ResolvedSenMLRecord>> instanceEntry = recordsByInstanceId.entrySet()
-                        .iterator().next();
-                Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(instanceEntry.getValue(), path, model);
-
-                // Create instance
-                node = new LwM2mObjectInstance(instanceEntry.getKey(), resourcesMap.values());
-            } else if (nodeClass == LwM2mResource.class) {
-                // validate we have resources for only 1 instance
-                if (recordsByInstanceId.size() > 1)
-                    throw new CodecException("Only one instance expected in the payload [path:%s]", path);
-
-                // Extract resources
-                Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(
-                        recordsByInstanceId.values().iterator().next(), path, model);
-
-                // validate there is only 1 resource
-                if (resourcesMap.size() != 1)
-                    throw new CodecException("One resource should be present in the payload [path:%s]", path);
-
-                node = resourcesMap.values().iterator().next();
-            } else if (nodeClass == LwM2mResourceInstance.class) {
-                // validate we have resources for only 1 instance
-                if (recordsByInstanceId.size() > 1)
-                    throw new CodecException("Only one instance expected in the payload [path:%s]", path);
-
-                // Extract resources
-                Map<Integer, LwM2mResource> resourcesMap = extractLwM2mResources(
-                        recordsByInstanceId.values().iterator().next(), path, model);
-
-                // validate there is only 1 resource
-                if (resourcesMap.size() != 1)
-                    throw new CodecException("One resource should be present in the payload [path:%s]", path);
-
-                LwM2mResource resource = resourcesMap.values().iterator().next();
-                if (!resource.isMultiInstances()) {
-                    throw new CodecException("Resource should be multi Instances resource [path:%s]", path);
-                }
+            if (resource.getInstances().size() > 1) {
+                throw new CodecException("Resource instances should not be > 1 [path:%s]", path);
+            }
 
-                if (resource.getInstances().isEmpty()) {
-                    throw new CodecException("Resource instances should not be not empty [path:%s]", path);
-                }
+            node = resourcesMap.values().iterator().next().getInstance(path.getResourceInstanceId());
+        } else {
+            throw new IllegalArgumentException("invalid node class: " + nodeClass);
+        }
+        return node;
+    }
 
-                if (resource.getInstances().size() > 1) {
-                    throw new CodecException("Resource instances should not be > 1 [path:%s]", path);
-                }
+    /**
+     * Resolved record then group it by LwM2mPath
+     */
+    private Map<LwM2mPath, Collection<LwM2mResolvedSenMLRecord>> groupByPath(List<SenMLRecord> records,
+            List<LwM2mPath> paths) throws SenMLException {
 
-                node = resourcesMap.values().iterator().next().getInstance(path.getResourceInstanceId());
-            } else {
-                throw new IllegalArgumentException("invalid node class: " + nodeClass);
+        // Prepare map result
+        Map<LwM2mPath, Collection<LwM2mResolvedSenMLRecord>> result = new HashMap<>(paths.size());
+        for (LwM2mPath path : paths) {
+            result.put(path, new ArrayList<LwM2mResolvedSenMLRecord>());
+        }
+
+        // Resolve record and add it to the map
+        LwM2mSenMLResolver resolver = new LwM2mSenMLResolver();
+        for (SenMLRecord record : records) {
+            LwM2mResolvedSenMLRecord resolvedRecord = resolver.resolve(record);
+
+            // Find the corresponding path for this record.
+            LwM2mPath selectedPath = selectPath(resolvedRecord.getPath(), paths);
+            if (selectedPath == null) {
+                throw new CodecException("Invalid path [%s] for resource, it should start by one of %s",
+                        resolvedRecord.getPath(), paths);
             }
 
-            // add time-stamped node
-            timestampedNodes.add(new TimestampedLwM2mNode(entryByTimestamp.getKey(), node));
+            result.get(selectedPath).add(resolvedRecord);
         }
+        return result;
+    }
 
-        return timestampedNodes;
-
+    /**
+     * Search in the list <code>paths<code> which one is a "start" for the given path.
+     * <p>
+     * E.g. for recordPath="/3/0/1" and paths=["/1/0","/2/0/","/3"] result will be "/3"
+     */
+    private LwM2mPath selectPath(LwM2mPath recordPath, List<LwM2mPath> paths) {
+        for (LwM2mPath path : paths) {
+            if (recordPath.startWith(path)) {
+                return path;
+            }
+        }
+        return null;
     }
 
     /**
@@ -192,9 +291,9 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
      * @return a sorted map (timestamp => collection of record) order by descending time-stamp (most recent one at first
      *         place). If null time-stamp (meaning no time information) exists it always at first place.
      */
-    private SortedMap<Long, Collection<ResolvedSenMLRecord>> groupRecordByTimestamp(List<SenMLRecord> records,
-            LwM2mPath requestPath) {
-        SortedMap<Long, Collection<ResolvedSenMLRecord>> result = new TreeMap<>(new Comparator<Long>() {
+    private SortedMap<Long, Collection<LwM2mResolvedSenMLRecord>> groupRecordByTimestamp(List<SenMLRecord> records,
+            LwM2mPath requestPath) throws SenMLException {
+        SortedMap<Long, Collection<LwM2mResolvedSenMLRecord>> result = new TreeMap<>(new Comparator<Long>() {
             @Override
             public int compare(Long o1, Long o2) {
                 // null at first place
@@ -208,56 +307,26 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
             }
         });
 
-        long currentTimestamp = System.currentTimeMillis();
-        String currentBasename = "";
-        Long currentBasetime = null;
+        LwM2mSenMLResolver resolver = new LwM2mSenMLResolver();
         for (SenMLRecord record : records) {
-            // Resolved record :
-            // TODO should we move record resolving into SenMLPack class as this is not specific to LWM2M ?
-            // resolve SenML name (see https://tools.ietf.org/html/rfc8428#section-4.5.1)
-            if (record.getBaseName() != null)
-                currentBasename = record.getBaseName();
+            LwM2mResolvedSenMLRecord resolvedRecord = resolver.resolve(record);
 
-            String path = record.getName() == null ? currentBasename : currentBasename + record.getName();
-            LwM2mPath resolvedName = new LwM2mPath(path);
-
-            // Validate SenML resolved name (lwm2m node path)
-            if (!resolvedName.isResourceInstance() && !resolvedName.isResource()) {
+            // Validate SenML resolved name
+            if (!resolvedRecord.getPath().isResourceInstance() && !resolvedRecord.getPath().isResource()) {
                 throw new CodecException(
                         "Invalid path [%s] for resource, it should be a resource or a resource instance path",
-                        resolvedName);
-            }
-
-            if (!resolvedName.startWith(requestPath)) {
-                throw new CodecException("Invalid path [%s] for resource, it should start by %s", resolvedName,
-                        requestPath);
+                        resolvedRecord.getName());
             }
-
-            // resolve SenML time (https://tools.ietf.org/html/rfc8428#section-4.5.3)
-            Long resolvedTime = null;
-            if (record.getBaseTime() != null)
-                currentBasetime = record.getBaseTime();
-            if (currentBasetime != null || record.getTime() != null) {
-                Long basetime = currentBasetime != null ? currentBasetime : 0l;
-                resolvedTime = record.getTime() != null ? currentBasetime + record.getTime() : basetime;
-
-                // Values less than 268,435,456 (2**28) represent time relative to the current time.
-                // A negative value indicates seconds in the past from roughly "now".
-                // Positive values up to 2**28 indicate seconds in the future from "now".
-                if (resolvedTime < 268_435_456) {
-                    resolvedTime = currentTimestamp + resolvedTime;
-                }
-                // else
-                // Values greater than or equal to 2**28 represent an absolute time relative to the Unix epoch
-                // (1970-01-01T00:00Z in UTC time)
+            if (!resolvedRecord.getPath().startWith(requestPath)) {
+                throw new CodecException("Invalid path [%s] for resource, it should start by %s",
+                        resolvedRecord.getName(), requestPath);
             }
-            ResolvedSenMLRecord resolvedRecord = new ResolvedSenMLRecord(record, resolvedName, resolvedTime);
 
             // Get record list for this time-stamp
-            Collection<ResolvedSenMLRecord> recordList = result.get(resolvedRecord.getTime());
+            Collection<LwM2mResolvedSenMLRecord> recordList = result.get(resolvedRecord.getTimeStamp());
             if (recordList == null) {
                 recordList = new ArrayList<>();
-                result.put(resolvedRecord.getTime(), recordList);
+                result.put(resolvedRecord.getTimeStamp(), recordList);
             }
             // Add it to the list
             recordList.add(resolvedRecord);
@@ -265,7 +334,7 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
 
         // Ensure there is at least one entry for null timestamp
         if (result.isEmpty()) {
-            Collection<ResolvedSenMLRecord> emptylist = Collections.emptyList();
+            Collection<LwM2mResolvedSenMLRecord> emptylist = Collections.emptyList();
             result.put((Long) null, emptylist);
         }
         return result;
@@ -276,15 +345,15 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
      * 
      * @return a map (instanceId => collection of SenML Record)
      */
-    private Map<Integer, Collection<ResolvedSenMLRecord>> groupRecordsByInstanceId(
-            Collection<ResolvedSenMLRecord> records) throws CodecException {
-        Map<Integer, Collection<ResolvedSenMLRecord>> result = new HashMap<>();
-        for (ResolvedSenMLRecord record : records) {
+    private Map<Integer, Collection<LwM2mResolvedSenMLRecord>> groupRecordsByInstanceId(
+            Collection<LwM2mResolvedSenMLRecord> records) throws CodecException {
+        Map<Integer, Collection<LwM2mResolvedSenMLRecord>> result = new HashMap<>();
+        for (LwM2mResolvedSenMLRecord record : records) {
             // Get SenML records for this instance
-            Collection<ResolvedSenMLRecord> recordForInstance = result.get(record.getFullpath().getObjectInstanceId());
+            Collection<LwM2mResolvedSenMLRecord> recordForInstance = result.get(record.getPath().getObjectInstanceId());
             if (recordForInstance == null) {
                 recordForInstance = new ArrayList<>();
-                result.put(record.getFullpath().getObjectInstanceId(), recordForInstance);
+                result.put(record.getPath().getObjectInstanceId(), recordForInstance);
             }
 
             // Add it to the list
@@ -293,7 +362,7 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
         return result;
     }
 
-    private Map<Integer, LwM2mResource> extractLwM2mResources(Collection<ResolvedSenMLRecord> records,
+    private Map<Integer, LwM2mResource> extractLwM2mResources(Collection<LwM2mResolvedSenMLRecord> records,
             LwM2mPath requestPath, LwM2mModel model) throws CodecException {
         if (records == null)
             return Collections.emptyMap();
@@ -302,9 +371,9 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
         Map<Integer, LwM2mResource> lwM2mResourceMap = new HashMap<>();
         Map<LwM2mPath, Map<Integer, SenMLRecord>> multiResourceMap = new HashMap<>();
 
-        for (ResolvedSenMLRecord resolvedRecord : records) {
+        for (LwM2mResolvedSenMLRecord resolvedRecord : records) {
             // Build resource path
-            LwM2mPath nodePath = resolvedRecord.getFullpath();
+            LwM2mPath nodePath = resolvedRecord.getPath();
             SenMLRecord record = resolvedRecord.getRecord();
 
             // handle LWM2M resources
@@ -442,29 +511,4 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder {
         // we get the better approximate value, meaning we can get precision loss
         return number.doubleValue();
     }
-
-    private static class ResolvedSenMLRecord {
-        private SenMLRecord record;
-        private LwM2mPath fullpath;
-        private Long time;
-
-        public SenMLRecord getRecord() {
-            return record;
-        }
-
-        public LwM2mPath getFullpath() {
-            return fullpath;
-        }
-
-        public Long getTime() {
-            return time;
-        }
-
-        public ResolvedSenMLRecord(SenMLRecord record, LwM2mPath fullpath, Long time) {
-            super();
-            this.record = record;
-            this.fullpath = fullpath;
-            this.time = time;
-        }
-    }
 }
\ No newline at end of file
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLEncoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLEncoder.java
index 78deb168..0aa824cd 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLEncoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mNodeSenMLEncoder.java
@@ -16,6 +16,7 @@ package org.eclipse.leshan.core.node.codec.senml;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 import java.util.Map.Entry;
 
 import org.eclipse.leshan.core.model.LwM2mModel;
@@ -32,6 +33,7 @@ import org.eclipse.leshan.core.node.ObjectLink;
 import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.node.codec.CodecException;
 import org.eclipse.leshan.core.node.codec.LwM2mValueConverter;
+import org.eclipse.leshan.core.node.codec.MultiNodeEncoder;
 import org.eclipse.leshan.core.node.codec.TimestampedNodeEncoder;
 import org.eclipse.leshan.core.util.Base64;
 import org.eclipse.leshan.core.util.Validate;
@@ -42,7 +44,7 @@ import org.eclipse.leshan.senml.SenMLRecord;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class LwM2mNodeSenMLEncoder implements TimestampedNodeEncoder {
+public class LwM2mNodeSenMLEncoder implements TimestampedNodeEncoder, MultiNodeEncoder {
     private static final Logger LOG = LoggerFactory.getLogger(LwM2mNodeSenMLEncoder.class);
 
     private final SenMLEncoder encoder;
@@ -74,6 +76,42 @@ public class LwM2mNodeSenMLEncoder implements TimestampedNodeEncoder {
         }
     }
 
+    @Override
+    public byte[] encodeNodes(Map<LwM2mPath, LwM2mNode> nodes, LwM2mModel model, LwM2mValueConverter converter)
+            throws CodecException {
+        // validate arguments
+        Validate.notEmpty(nodes);
+
+        // Encodes nodes to SenML pack
+        SenMLPack pack = new SenMLPack();
+        for (Entry<LwM2mPath, LwM2mNode> entry : nodes.entrySet()) {
+            LwM2mPath path = entry.getKey();
+            InternalEncoder internalEncoder = new InternalEncoder();
+            internalEncoder.objectId = path.getObjectId();
+            internalEncoder.model = model;
+            internalEncoder.requestPath = path;
+            internalEncoder.converter = converter;
+            internalEncoder.records = new ArrayList<>();
+            LwM2mNode node = entry.getValue();
+            if (node != null) {
+                node.accept(internalEncoder);
+                pack.addRecords(internalEncoder.records);
+            }
+            // else
+            // We just ignore null node as the LWM2M specification says that "Read-Composite operation is treated as
+            // non-atomic and handled as best effort by the client. That is, if any of the requested resources do not
+            // have a valid value to return, they will not be included in the response".
+            // Meaning that a given path could have no corresponding value.
+        }
+
+        // Encodes SenML pack using internal encoder (it could be SenML-JSON or SenML-CBOR encoder)
+        try {
+            return encoder.toSenML(pack);
+        } catch (SenMLException e) {
+            throw new CodecException(e, "Unable to encode multi node[paths:%s] : %s", nodes.keySet(), nodes);
+        }
+    }
+
     @Override
     public byte[] encodeTimestampedData(List<TimestampedLwM2mNode> timestampedNodes, LwM2mPath path, LwM2mModel model,
             LwM2mValueConverter converter) throws CodecException {
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mPathSenMLDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mPathSenMLDecoder.java
new file mode 100644
index 00000000..d9ab2f76
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mPathSenMLDecoder.java
@@ -0,0 +1,74 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec.senml;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.codec.CodecException;
+import org.eclipse.leshan.core.node.codec.PathDecoder;
+import org.eclipse.leshan.senml.SenMLDecoder;
+import org.eclipse.leshan.senml.SenMLException;
+import org.eclipse.leshan.senml.SenMLPack;
+import org.eclipse.leshan.senml.SenMLRecord;
+
+/**
+ * A {@link PathDecoder} for SenML format
+ */
+public class LwM2mPathSenMLDecoder implements PathDecoder {
+
+    private final SenMLDecoder decoder;
+
+    public LwM2mPathSenMLDecoder(SenMLDecoder decoder) {
+        this.decoder = decoder;
+
+    }
+
+    @Override
+    public List<LwM2mPath> decode(byte[] content) throws CodecException {
+        // Decode SenML Pack
+        SenMLPack pack;
+        try {
+            pack = decoder.fromSenML(content);
+        } catch (SenMLException e) {
+            throw new CodecException(e, "Unable to decode SenML for path decoding");
+        }
+
+        // Create Path list
+        List<LwM2mPath> res = new ArrayList<>();
+        LwM2mSenMLResolver resolver = new LwM2mSenMLResolver();
+        for (SenMLRecord record : pack.getRecords()) {
+            LwM2mResolvedSenMLRecord resolvedRecord;
+            try {
+                resolvedRecord = resolver.resolve(record);
+                if (record.getType() != null) {
+                    throw new CodecException("Invalid record for path encoding : record should not have value %s",
+                            record);
+                }
+                if (resolvedRecord.getTimeStamp() != null) {
+                    throw new CodecException(
+                            "Invalid record for path encoding : record should not have time or base time value %s",
+                            record);
+                }
+                res.add(resolvedRecord.getPath());
+            } catch (SenMLException e) {
+                throw new CodecException(e, "Unable to resolve record");
+            }
+        }
+        return res;
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mPathSenMLEncoder.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mPathSenMLEncoder.java
new file mode 100644
index 00000000..0e525820
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mPathSenMLEncoder.java
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec.senml;
+
+import java.util.List;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.codec.CodecException;
+import org.eclipse.leshan.core.node.codec.PathEncoder;
+import org.eclipse.leshan.senml.SenMLEncoder;
+import org.eclipse.leshan.senml.SenMLException;
+import org.eclipse.leshan.senml.SenMLPack;
+import org.eclipse.leshan.senml.SenMLRecord;
+
+/**
+ * A {@link PathEncoder} for SenML format
+ */
+public class LwM2mPathSenMLEncoder implements PathEncoder {
+
+    private final SenMLEncoder encoder;
+
+    public LwM2mPathSenMLEncoder(SenMLEncoder encoder) {
+        this.encoder = encoder;
+    }
+
+    @Override
+    public byte[] encode(List<LwM2mPath> paths) {
+        // Create SenML Pack
+        SenMLPack pack = new SenMLPack();
+        for (LwM2mPath path : paths) {
+            SenMLRecord record = new SenMLRecord();
+            record.setName(path.toString());
+            pack.addRecord(record);
+        }
+
+        // Encode Pack
+        try {
+            return encoder.toSenML(pack);
+        } catch (SenMLException e) {
+            throw new CodecException(e, "Unable to encode paths %s", paths);
+        }
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mResolvedSenMLRecord.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mResolvedSenMLRecord.java
new file mode 100644
index 00000000..06b01214
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mResolvedSenMLRecord.java
@@ -0,0 +1,49 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec.senml;
+
+import org.eclipse.leshan.core.node.LwM2mNodeException;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.senml.ResolvedSenMLRecord;
+import org.eclipse.leshan.senml.SenMLRecord;
+
+/**
+ * A {@link ResolvedSenMLRecord} dedicated for LWM2M as a new {@link #getPath()} is available to get the
+ * {@link LwM2mPath} of this resolved SenML record.
+ *
+ */
+public class LwM2mResolvedSenMLRecord extends ResolvedSenMLRecord {
+
+    private LwM2mPath path;
+
+    /**
+     * @throws IllegalArgumentException if path is invalid
+     * @throws LwM2mNodeException if path is invalid
+     */
+    public LwM2mResolvedSenMLRecord(SenMLRecord unresolvedRecord, String resolvedName, Long resolvedTimestamp)
+            throws IllegalArgumentException, LwM2mNodeException {
+        super(unresolvedRecord, resolvedName, resolvedTimestamp);
+        this.path = new LwM2mPath(resolvedName);
+    }
+
+    /**
+     * @return the resolved {@link LwM2mPath} of this record created from resolved record name.
+     */
+    public LwM2mPath getPath() {
+        return path;
+    }
+
+}
\ No newline at end of file
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mSenMLResolver.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mSenMLResolver.java
new file mode 100644
index 00000000..e8e664a7
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/codec/senml/LwM2mSenMLResolver.java
@@ -0,0 +1,37 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.node.codec.senml;
+
+import org.eclipse.leshan.core.node.LwM2mNodeException;
+import org.eclipse.leshan.senml.SenMLException;
+import org.eclipse.leshan.senml.SenMLRecord;
+import org.eclipse.leshan.senml.SenMLResolver;
+
+/**
+ * A {@link SenMLResolver} dedicated for LWM2M usage as it creates {@link LwM2mResolvedSenMLRecord}.
+ */
+public class LwM2mSenMLResolver extends SenMLResolver<LwM2mResolvedSenMLRecord> {
+
+    @Override
+    protected LwM2mResolvedSenMLRecord createResolvedRecord(SenMLRecord unresolvedRecord, String resolvedName,
+            Long resolvedTimestamp) throws SenMLException {
+        try {
+            return new LwM2mResolvedSenMLRecord(unresolvedRecord, resolvedName, resolvedTimestamp);
+        } catch (IllegalArgumentException | LwM2mNodeException e) {
+            throw new SenMLException(e, "Unable to resolve record, invalid path", resolvedName);
+        }
+    }
+};
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/AbstractDownlinkRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/AbstractSimpleDownlinkRequest.java
similarity index 87%
rename from leshan-core/src/main/java/org/eclipse/leshan/core/request/AbstractDownlinkRequest.java
rename to leshan-core/src/main/java/org/eclipse/leshan/core/request/AbstractSimpleDownlinkRequest.java
index 683fd087..d953288d 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/AbstractDownlinkRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/AbstractSimpleDownlinkRequest.java
@@ -24,12 +24,12 @@ import org.eclipse.leshan.core.response.LwM2mResponse;
  *
  * Provides generic support for specifying the target client and the resource path.
  */
-public abstract class AbstractDownlinkRequest<T extends LwM2mResponse> extends AbstractLwM2mRequest<T>
-        implements DownlinkRequest<T> {
+public abstract class AbstractSimpleDownlinkRequest<T extends LwM2mResponse> extends AbstractLwM2mRequest<T>
+        implements SimpleDownlinkRequest<T> {
 
     private final LwM2mPath path;
 
-    protected AbstractDownlinkRequest(LwM2mPath path, Object coapRequest) {
+    protected AbstractSimpleDownlinkRequest(LwM2mPath path, Object coapRequest) {
         super(coapRequest);
         if (path == null)
             throw new InvalidRequestException("path is mandatory");
@@ -61,7 +61,7 @@ public abstract class AbstractDownlinkRequest<T extends LwM2mResponse> extends A
             return false;
         if (getClass() != obj.getClass())
             return false;
-        AbstractDownlinkRequest<?> other = (AbstractDownlinkRequest<?>) obj;
+        AbstractSimpleDownlinkRequest<?> other = (AbstractSimpleDownlinkRequest<?>) obj;
         if (path == null) {
             if (other.path != null)
                 return false;
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDeleteRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDeleteRequest.java
index 51bd28e2..b0737a2a 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDeleteRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDeleteRequest.java
@@ -22,7 +22,7 @@ import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
 /**
  * A LWM2M request for deleting object instances during the bootstrap phase.
  */
-public class BootstrapDeleteRequest extends AbstractDownlinkRequest<BootstrapDeleteResponse>
+public class BootstrapDeleteRequest extends AbstractSimpleDownlinkRequest<BootstrapDeleteResponse>
         implements BootstrapDownlinkRequest<BootstrapDeleteResponse> {
 
     /**
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java
index 22a0940c..070751b3 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDiscoverRequest.java
@@ -25,7 +25,7 @@ import org.eclipse.leshan.core.response.BootstrapDiscoverResponse;
  * 
  * @since 1.1
  */
-public class BootstrapDiscoverRequest extends AbstractDownlinkRequest<BootstrapDiscoverResponse>
+public class BootstrapDiscoverRequest extends AbstractSimpleDownlinkRequest<BootstrapDiscoverResponse>
         implements BootstrapDownlinkRequest<BootstrapDiscoverResponse> {
 
     /**
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDownlinkRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDownlinkRequest.java
index 961e393c..76800b16 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDownlinkRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapDownlinkRequest.java
@@ -17,5 +17,5 @@ package org.eclipse.leshan.core.request;
 
 import org.eclipse.leshan.core.response.LwM2mResponse;
 
-public interface BootstrapDownlinkRequest<T extends LwM2mResponse> extends DownlinkRequest<T> {
+public interface BootstrapDownlinkRequest<T extends LwM2mResponse> extends SimpleDownlinkRequest<T> {
 }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapWriteRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapWriteRequest.java
index 8513edb9..bf37c9af 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapWriteRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/BootstrapWriteRequest.java
@@ -27,7 +27,7 @@ import org.eclipse.leshan.core.response.BootstrapWriteResponse;
 /**
  * A LWM2M request for writing object instances during the bootstrap phase.
  */
-public class BootstrapWriteRequest extends AbstractDownlinkRequest<BootstrapWriteResponse>
+public class BootstrapWriteRequest extends AbstractSimpleDownlinkRequest<BootstrapWriteResponse>
         implements BootstrapDownlinkRequest<BootstrapWriteResponse> {
 
     private final LwM2mNode node;
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 0c90a780..063a3068 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
@@ -27,7 +27,7 @@ import org.eclipse.leshan.core.util.Hex;
  * {@code ObservationService#cancelObservation()}
  * </p>
  */
-public class CancelObservationRequest extends AbstractDownlinkRequest<CancelObservationResponse> {
+public class CancelObservationRequest extends AbstractSimpleDownlinkRequest<CancelObservationResponse> {
 
     private final Observation observation;
 
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
new file mode 100644
index 00000000..511acb95
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CompositeDownlinkRequest.java
@@ -0,0 +1,32 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.request;
+
+import java.util.Collection;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.response.LwM2mResponse;
+
+/**
+ * A Composite {@link DownlinkRequest} which targets several nodes.
+ */
+public interface CompositeDownlinkRequest<T extends LwM2mResponse> extends DownlinkRequest<T> {
+
+    /**
+     * @return the collection of node path targeted by the request.
+     */
+    Collection<LwM2mPath> getPaths();
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java
index 40f91274..00ffdf32 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CreateRequest.java
@@ -29,7 +29,7 @@ import org.eclipse.leshan.core.response.CreateResponse;
 /**
  * A Lightweight M2M request for creating Object Instance(s) within the LWM2M Client.
  */
-public class CreateRequest extends AbstractDownlinkRequest<CreateResponse> {
+public class CreateRequest extends AbstractSimpleDownlinkRequest<CreateResponse> {
 
     private final List<LwM2mResource> resources;
     private final List<LwM2mObjectInstance> instances;
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DeleteRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DeleteRequest.java
index 050546c7..2eb7ca06 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DeleteRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DeleteRequest.java
@@ -22,7 +22,7 @@ import org.eclipse.leshan.core.response.DeleteResponse;
 /**
  * A Lightweight M2M request for deleting an Object Instance within the LWM2M Client.
  */
-public class DeleteRequest extends AbstractDownlinkRequest<DeleteResponse> {
+public class DeleteRequest extends AbstractSimpleDownlinkRequest<DeleteResponse> {
 
     /**
      * Creates a request for deleting a particular object instance implemented by a client.
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DiscoverRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DiscoverRequest.java
index f6a4fbd6..e5769caf 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DiscoverRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DiscoverRequest.java
@@ -22,7 +22,7 @@ import org.eclipse.leshan.core.response.DiscoverResponse;
 /**
  * A Lightweight M2M request for discovering LWM2M Attributes attached to an Object, Object Instances, and Resources.
  */
-public class DiscoverRequest extends AbstractDownlinkRequest<DiscoverResponse> {
+public class DiscoverRequest extends AbstractSimpleDownlinkRequest<DiscoverResponse> {
 
     /**
      * Creates a request for discovering the resources implemented by a client for a particular object type.
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 f0f6e36c..dce1b760 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
@@ -57,6 +57,10 @@ public class DownLinkRequestVisitorAdapter implements DownlinkRequestVisitor {
     public void visit(CancelObservationRequest request) {
     }
 
+    @Override
+    public void visit(ReadCompositeRequest request) {
+    }
+
     @Override
     public void visit(BootstrapDiscoverRequest request) {
     }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequest.java
index 86738632..19484d05 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/DownlinkRequest.java
@@ -15,7 +15,6 @@
  *******************************************************************************/
 package org.eclipse.leshan.core.request;
 
-import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.response.LwM2mResponse;
 
 /**
@@ -24,13 +23,6 @@ import org.eclipse.leshan.core.response.LwM2mResponse;
  */
 public interface DownlinkRequest<T extends LwM2mResponse> extends LwM2mRequest<T> {
 
-    /**
-     * Gets the requested resource path.
-     *
-     * @return the request path
-     */
-    LwM2mPath getPath();
-
     /**
      * Accept a visitor for this 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 17292d66..58fb029d 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
@@ -37,6 +37,8 @@ public interface DownlinkRequestVisitor {
 
     void visit(CancelObservationRequest request);
 
+    void visit(ReadCompositeRequest request);
+
     void visit(BootstrapDiscoverRequest request);
 
     void visit(BootstrapWriteRequest request);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ExecuteRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ExecuteRequest.java
index 11dd188e..c76e1d7a 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ExecuteRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ExecuteRequest.java
@@ -22,7 +22,7 @@ import org.eclipse.leshan.core.response.ExecuteResponse;
 /**
  * A Lightweight M2M request for initiate some action, it can only be performed on individual Resources.
  */
-public class ExecuteRequest extends AbstractDownlinkRequest<ExecuteResponse> {
+public class ExecuteRequest extends AbstractSimpleDownlinkRequest<ExecuteResponse> {
 
     private final String parameters;
 
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 6a19b949..fd5900bb 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
@@ -28,7 +28,7 @@ import org.eclipse.leshan.core.response.ObserveResponse;
  * A Lightweight M2M request for observing changes of a specific Resource, Resources within an Object Instance or for
  * all the Object Instances of an Object within the LWM2M Client.
  */
-public class ObserveRequest extends AbstractDownlinkRequest<ObserveResponse> {
+public class ObserveRequest extends AbstractSimpleDownlinkRequest<ObserveResponse> {
 
     private final ContentFormat format;
 
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
new file mode 100644
index 00000000..b9b7d9e5
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadCompositeRequest.java
@@ -0,0 +1,180 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.request;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.exception.InvalidRequestException;
+import org.eclipse.leshan.core.response.ReadCompositeResponse;
+
+/**
+ * The "Read-Composite" operation can be used by the LwM2M Server to selectively read any combination of Objects, Object
+ * Instance(s), Resources, and/or Resource Instances of different or same Objects in a single request.
+ */
+public class ReadCompositeRequest extends AbstractLwM2mRequest<ReadCompositeResponse>
+        implements CompositeDownlinkRequest<ReadCompositeResponse> {
+
+    private final List<LwM2mPath> paths;
+    private final ContentFormat requestContentFormat;
+    private final ContentFormat responseContentFormat;
+
+    /**
+     * Create ReadComposite 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 ReadCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
+            String... paths) {
+        this(newPaths(paths), requestContentFormat, responseContentFormat, null);
+    }
+
+    /**
+     * Create ReadComposite 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 ReadCompositeRequest(ContentFormat requestContentFormat, ContentFormat responseContentFormat,
+            List<String> paths) {
+        this(newPaths(paths), requestContentFormat, responseContentFormat, null);
+    }
+
+    /**
+     * Create ReadComposite Request.
+     * <p>
+     * This constructor is more for internal usage.
+     * 
+     * @param paths List of {@link LwM2mPath} corresponding to {@link LwM2mNode} to read.
+     * @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.
+     * 
+     */
+    public ReadCompositeRequest(List<LwM2mPath> paths, ContentFormat requestContentFormat,
+            ContentFormat responseContentFormat, Object coapRequest) {
+        super(coapRequest);
+        if (paths == null || paths.size() == 0)
+            throw new InvalidRequestException("path is mandatory");
+
+        // Ensure there is no overlapped Path (e.g. "3/0" and "/3/0/1")
+        for (int i = 0; i < paths.size(); i++) {
+            LwM2mPath firstPath = paths.get(i);
+            for (int j = i + 1; j < paths.size(); j++) {
+                LwM2mPath secondPath = paths.get(j);
+                if (firstPath.startWith(secondPath) || secondPath.startWith(firstPath)) {
+                    throw new InvalidRequestException("Invalid path list :  %s and %s are overlapped paths", firstPath,
+                            secondPath);
+                }
+            }
+        }
+        this.paths = paths;
+        this.requestContentFormat = requestContentFormat;
+        this.responseContentFormat = responseContentFormat;
+    }
+
+    @Override
+    public void accept(DownlinkRequestVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    public ContentFormat getRequestContentFormat() {
+        return requestContentFormat;
+    }
+
+    public ContentFormat getResponseContentFormat() {
+        return responseContentFormat;
+    }
+
+    @Override
+    public List<LwM2mPath> getPaths() {
+        return paths;
+    }
+
+    @Override
+    public String toString() {
+        return String.format("ReadCompositeRequest [paths=%s, request format=%s, response format= %s]", getPaths(),
+                requestContentFormat, responseContentFormat);
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        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 (obj == null)
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        ReadCompositeRequest other = (ReadCompositeRequest) 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;
+    }
+
+    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/request/ReadRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadRequest.java
index 769b4187..fb1fde22 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/ReadRequest.java
@@ -25,7 +25,7 @@ import org.eclipse.leshan.core.response.ReadResponse;
  * The request can be used to retrieve the value(s) of one or all attributes of one particular or all instances of a
  * particular object type.
  */
-public class ReadRequest extends AbstractDownlinkRequest<ReadResponse> {
+public class ReadRequest extends AbstractSimpleDownlinkRequest<ReadResponse> {
 
     private final ContentFormat format;
 
@@ -152,7 +152,18 @@ public class ReadRequest extends AbstractDownlinkRequest<ReadResponse> {
         this(format, new LwM2mPath(path), coapRequest);
     }
 
-    private ReadRequest(ContentFormat format, LwM2mPath target, Object coapRequest) {
+    /**
+     * Create a request for reading an object/instance/resource targeted by a specific path.
+     * <p>
+     * This constructor is mainly for internal purpore.
+     * 
+     * @param format the desired format for the response
+     * @param target the path to the LWM2M node to read
+     * @param coapRequest the underlying request
+     * 
+     * @throws IllegalArgumentException if the target path is not valid
+     */
+    public ReadRequest(ContentFormat format, LwM2mPath target, Object coapRequest) {
         super(target, coapRequest);
         if (target.isRoot())
             throw new InvalidRequestException("Read request cannot target root path");
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/SimpleDownlinkRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/SimpleDownlinkRequest.java
new file mode 100644
index 00000000..3632cb10
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/SimpleDownlinkRequest.java
@@ -0,0 +1,31 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.core.request;
+
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.response.LwM2mResponse;
+
+/**
+ * A simple {@link DownlinkRequest} which targets only 1 node.
+ */
+public interface SimpleDownlinkRequest<T extends LwM2mResponse> extends DownlinkRequest<T> {
+    /**
+     * Gets the node path targeted by the request.
+     *
+     * @return the request path
+     */
+    LwM2mPath getPath();
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteAttributesRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteAttributesRequest.java
index 766ef607..f2b685aa 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteAttributesRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteAttributesRequest.java
@@ -20,7 +20,7 @@ import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.request.exception.InvalidRequestException;
 import org.eclipse.leshan.core.response.WriteAttributesResponse;
 
-public class WriteAttributesRequest extends AbstractDownlinkRequest<WriteAttributesResponse> {
+public class WriteAttributesRequest extends AbstractSimpleDownlinkRequest<WriteAttributesResponse> {
 
     private final AttributeSet attributes;
 
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteRequest.java
index 0323170a..f6555d8b 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteRequest.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteRequest.java
@@ -36,7 +36,7 @@ import org.eclipse.leshan.core.util.datatype.ULong;
  * The request to change the value of a Resource, an array of Resources Instances or multiple Resources from an Object
  * Instance.
  */
-public class WriteRequest extends AbstractDownlinkRequest<WriteResponse> {
+public class WriteRequest extends AbstractSimpleDownlinkRequest<WriteResponse> {
 
     /**
      * Define the behavior of a write request.
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/ReadCompositeResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ReadCompositeResponse.java
new file mode 100644
index 00000000..4808d868
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ReadCompositeResponse.java
@@ -0,0 +1,104 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.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;
+
+public class ReadCompositeResponse extends AbstractLwM2mResponse {
+
+    protected final Map<LwM2mPath, LwM2mNode> content;
+
+    public ReadCompositeResponse(ResponseCode code, Map<LwM2mPath, LwM2mNode> content, String errorMessage,
+            Object coapResponse) {
+        super(code, errorMessage, coapResponse);
+        this.content = content;
+    }
+
+    public Map<LwM2mPath, LwM2mNode> getContent() {
+        return content;
+    }
+
+    public LwM2mNode getContent(String path) {
+        return content.get(new LwM2mPath(path));
+    }
+
+    @Override
+    public boolean isSuccess() {
+        return getCode() == ResponseCode.CONTENT;
+    }
+
+    @Override
+    public boolean isValid() {
+        switch (code.getCode()) {
+        case ResponseCode.CONTENT_CODE:
+        case ResponseCode.BAD_REQUEST_CODE:
+        case ResponseCode.UNAUTHORIZED_CODE:
+        case ResponseCode.NOT_FOUND_CODE:
+        case ResponseCode.METHOD_NOT_ALLOWED_CODE:
+        case ResponseCode.NOT_ACCEPTABLE_CODE:
+        case ResponseCode.INTERNAL_SERVER_ERROR_CODE:
+        case ResponseCode.UNSUPPORTED_CONTENT_FORMAT_CODE:
+            return true;
+        default:
+            return false;
+        }
+    }
+
+    @Override
+    public String toString() {
+        if (errorMessage != null)
+            return String.format("ReadCompositeResponse [code=%s, errormessage=%s]", code, errorMessage);
+        else
+            return String.format("ReadCompositeResponse [code=%s, content=%s]", code, content);
+    }
+
+    // Syntactic sugar static constructors :
+    public static ReadCompositeResponse success(Map<LwM2mPath, LwM2mNode> content) {
+        return new ReadCompositeResponse(ResponseCode.CONTENT, content, null, null);
+    }
+
+    public static ReadCompositeResponse notFound() {
+        return new ReadCompositeResponse(ResponseCode.NOT_FOUND, null, null, null);
+    }
+
+    public static ReadCompositeResponse unauthorized() {
+        return new ReadCompositeResponse(ResponseCode.UNAUTHORIZED, null, null, null);
+    }
+
+    public static ReadCompositeResponse methodNotAllowed() {
+        return new ReadCompositeResponse(ResponseCode.METHOD_NOT_ALLOWED, null, null, null);
+    }
+
+    public static ReadCompositeResponse notAcceptable() {
+        return new ReadCompositeResponse(ResponseCode.NOT_ACCEPTABLE, null, null, null);
+    }
+
+    public static ReadCompositeResponse unsupportedContentFormat() {
+        return new ReadCompositeResponse(ResponseCode.UNSUPPORTED_CONTENT_FORMAT, null, null, null);
+    }
+
+    public static ReadCompositeResponse badRequest(String errorMessage) {
+        return new ReadCompositeResponse(ResponseCode.BAD_REQUEST, null, errorMessage, null);
+    }
+
+    public static ReadCompositeResponse internalServerError(String errorMessage) {
+        return new ReadCompositeResponse(ResponseCode.INTERNAL_SERVER_ERROR, null, errorMessage, null);
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/senml/ResolvedSenMLRecord.java b/leshan-core/src/main/java/org/eclipse/leshan/senml/ResolvedSenMLRecord.java
new file mode 100644
index 00000000..c36ce1b8
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/senml/ResolvedSenMLRecord.java
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.senml;
+
+/**
+ * A resolved SenML record where name and time is resolved for each record, meaning that time or name is no more
+ * relative to a previous <code>basename</code> or <code>basetime</code>.
+ */
+public class ResolvedSenMLRecord {
+    private SenMLRecord record;
+    private String name;
+    private Long timestamp;
+
+    public ResolvedSenMLRecord(SenMLRecord unresolvedRecord, String resolvedName, Long resolvedTimestamp) {
+        super();
+        this.record = unresolvedRecord;
+        this.name = resolvedName;
+        this.timestamp = resolvedTimestamp;
+    }
+
+    /**
+     * @return the original record before we resolve it.
+     */
+    public SenMLRecord getRecord() {
+        return record;
+    }
+
+    /**
+     * @return the resolved name. (see
+     *         <a href="https://tools.ietf.org/html/rfc8428#section-4.5.1">rfc8428#section-4.5.1</a>)
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * @return the resolved timestamp (see
+     *         <a href="https://tools.ietf.org/html/rfc8428#section-4.5.3">rfc8428#section-4.5.3</a>)
+     */
+    public Long getTimeStamp() {
+        return timestamp;
+    }
+}
\ No newline at end of file
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/senml/SenMLResolver.java b/leshan-core/src/main/java/org/eclipse/leshan/senml/SenMLResolver.java
new file mode 100644
index 00000000..c60c9b06
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/senml/SenMLResolver.java
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.senml;
+
+/**
+ * Utility class used to resolve SenML record.
+ * 
+ * @see ResolvedSenMLRecord
+ */
+public abstract class SenMLResolver<T extends ResolvedSenMLRecord> {
+
+    private long currentTimestamp = System.currentTimeMillis();
+    private String currentBasename = null;
+    private Long currentBasetime = null;
+
+    public T resolve(SenMLRecord record) throws SenMLException {
+        // Resolve SenML name (see https://tools.ietf.org/html/rfc8428#section-4.5.1)
+        if (record.getBaseName() != null)
+            currentBasename = record.getBaseName();
+
+        String resolvedName = null;
+        if (currentBasename != null || record.getName() != null) {
+            String baseName = currentBasename != null ? currentBasename : "";
+            resolvedName = record.getName() == null ? baseName : baseName + record.getName();
+        }
+
+        // Resolve SenML time (https://tools.ietf.org/html/rfc8428#section-4.5.3)
+        Long resolvedTimestamp = null;
+        if (record.getBaseTime() != null)
+            currentBasetime = record.getBaseTime();
+        if (currentBasetime != null || record.getTime() != null) {
+            Long basetime = currentBasetime != null ? currentBasetime : 0l;
+            resolvedTimestamp = record.getTime() != null ? basetime + record.getTime() : basetime;
+
+            // Values less than 268,435,456 (2**28) represent time relative to the current time.
+            // A negative value indicates seconds in the past from roughly "now".
+            // Positive values up to 2**28 indicate seconds in the future from "now".
+            if (resolvedTimestamp < 268_435_456) {
+                resolvedTimestamp = currentTimestamp + resolvedTimestamp;
+            }
+            // else
+            // Values greater than or equal to 2**28 represent an absolute time relative to the Unix epoch
+            // (1970-01-01T00:00Z in UTC time)
+        }
+
+        return createResolvedRecord(record, resolvedName, resolvedTimestamp);
+    }
+
+    protected abstract T createResolvedRecord(SenMLRecord record, String resolvedName, Long resolvedTimestamp)
+            throws SenMLException;
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborPackSerDes.java b/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborPackSerDes.java
index 05d46bcd..a4d143b2 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborPackSerDes.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborPackSerDes.java
@@ -26,7 +26,6 @@ import org.eclipse.leshan.core.model.ResourceModel.Type;
 import org.eclipse.leshan.core.util.Base64;
 import org.eclipse.leshan.core.util.datatype.NumberUtil;
 import org.eclipse.leshan.core.util.datatype.ULong;
-import org.eclipse.leshan.core.util.json.JsonException;
 import org.eclipse.leshan.senml.SenMLException;
 import org.eclipse.leshan.senml.SenMLPack;
 import org.eclipse.leshan.senml.SenMLRecord;
@@ -37,6 +36,25 @@ import com.upokecenter.cbor.CBORType;
 
 public class SenMLCborPackSerDes {
 
+    private final boolean allowNoValue;
+
+    public SenMLCborPackSerDes() {
+        this(false);
+    }
+
+    /**
+     * Create SenML-CBOR serializer/deserializer based on CBOR-JAVA.
+     * <p>
+     * SenML value is defined as mandatory in <a href="https://tools.ietf.org/html/rfc8428#section-4.2">rfc8428</a>, but
+     * SenML records used with a Read-Composite operation do not contain any value field, so
+     * <code>allowNoValue=true</code> can be used skip this validation.
+     * 
+     * @param allowNoValue <code>True</code> to not check if there is a value for each SenML record.
+     */
+    public SenMLCborPackSerDes(boolean allowNoValue) {
+        this.allowNoValue = allowNoValue;
+    }
+
     public SenMLPack deserializeFromCbor(Collection<CBORObject> objects) throws SenMLException {
         try {
             SenMLPack senMLPack = new SenMLPack();
@@ -113,8 +131,9 @@ public class SenMLCborPackSerDes {
                     hasValue = true;
                 }
 
-                if (!hasValue)
-                    throw new JsonException("Invalid SenML record : record must have a value (v,vb,vlo,vd,vs) : %s", o);
+                if (!allowNoValue && !hasValue)
+                    throw new SenMLException("Invalid SenML record : record must have a value (v,vb,vlo,vd,vs) : %s",
+                            o);
 
                 senMLPack.addRecord(record);
             }
@@ -189,6 +208,11 @@ public class SenMLCborPackSerDes {
                     default:
                         break;
                     }
+                } else {
+                    if (!allowNoValue) {
+                        throw new SenMLException(
+                                "Invalid SenML record : record must have a value (v,vb,vlo,vd,vs) : %s", record);
+                    }
                 }
                 cborArray.Add(cborRecord);
             }
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborUpokecenterEncoderDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborUpokecenterEncoderDecoder.java
index 98ba1a30..e0053722 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborUpokecenterEncoderDecoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/senml/cbor/upokecenter/SenMLCborUpokecenterEncoderDecoder.java
@@ -32,18 +32,24 @@ public class SenMLCborUpokecenterEncoderDecoder implements SenMLDecoder, SenMLEn
     private final SenMLCborPackSerDes serDes;
 
     public SenMLCborUpokecenterEncoderDecoder() {
-        this(false);
+        this(false, false);
     }
 
     /**
-     * @param keepingInsertionOrder Set it to true allows to keep insertion order at serialization. This is a kind of
-     *        HACK using reflection which could make testing easier but could bring performance penalty
+     * Create an Encoder/Decoder for SenML-CBOR based on CBOR-JAVA.
      * 
+     * SenML value is defined as mandatory in <a href="https://tools.ietf.org/html/rfc8428#section-4.2">rfc8428</a>, but
+     * SenML records used with a Read-Composite operation do not contain any value field, so
+     * <code>allowNoValue=true</code> can be used skip this validation.
+     * 
+     * @param keepingInsertionOrder Set it to <code>True</code> allows to keep insertion order at serialization. This is
+     *        a kind of HACK using reflection which could make testing easier but could bring performance penalty
+     * @param allowNoValue <code>True</code> to not check if there is a value for each SenML record.
      * @see <a href="https://github.com/peteroupc/CBOR-Java/issues/13">CBOR-Java#13 issue</a>
      */
-    public SenMLCborUpokecenterEncoderDecoder(boolean keepingInsertionOrder) {
+    public SenMLCborUpokecenterEncoderDecoder(boolean keepingInsertionOrder, boolean allowNoValue) {
         if (keepingInsertionOrder) {
-            serDes = new SenMLCborPackSerDes() {
+            serDes = new SenMLCborPackSerDes(allowNoValue) {
                 @Override
                 CBORObject newMap() {
                     return CBORObject.NewOrderedMap();
@@ -51,7 +57,7 @@ public class SenMLCborUpokecenterEncoderDecoder implements SenMLDecoder, SenMLEn
             };
 
         } else {
-            serDes = new SenMLCborPackSerDes();
+            serDes = new SenMLCborPackSerDes(allowNoValue);
         }
     }
 
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonJacksonEncoderDecoder.java b/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonJacksonEncoderDecoder.java
index 54a6dd7d..3cabf77e 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonJacksonEncoderDecoder.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonJacksonEncoderDecoder.java
@@ -30,9 +30,26 @@ import com.fasterxml.jackson.databind.ObjectMapper;
  * Helper for encoding/decoding SenML JSON using Jackson
  */
 public class SenMLJsonJacksonEncoderDecoder implements SenMLDecoder, SenMLEncoder {
-    private static final SenMLJsonRecordSerDes serDes = new SenMLJsonRecordSerDes();
+    private final SenMLJsonRecordSerDes serDes;
     private static final ObjectMapper mapper = new ObjectMapper();
 
+    public SenMLJsonJacksonEncoderDecoder() {
+        this(false);
+    }
+
+    /**
+     * Create an Encoder/Decoder for SenML-JSON based on Jackson.
+     * 
+     * SenML value is defined as mandatory in <a href="https://tools.ietf.org/html/rfc8428#section-4.2">rfc8428</a>, but
+     * SenML records used with a Read-Composite operation do not contain any value field, so
+     * <code>allowNoValue=true</code> can be used skip this validation.
+     * 
+     * @param allowNoValue <code>True</code> to not check if there is a value for each SenML record.
+     */
+    public SenMLJsonJacksonEncoderDecoder(boolean allowNoValue) {
+        this.serDes = new SenMLJsonRecordSerDes(allowNoValue);
+    }
+
     @Override
     public byte[] toSenML(SenMLPack pack) throws SenMLException {
         if (pack == null)
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonRecordSerDes.java b/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonRecordSerDes.java
index 95bbc54e..e30b1600 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonRecordSerDes.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/senml/json/jackson/SenMLJsonRecordSerDes.java
@@ -29,6 +29,24 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 
 public class SenMLJsonRecordSerDes extends JacksonJsonSerDes<SenMLRecord> {
+    private boolean allowNoValue;
+
+    public SenMLJsonRecordSerDes() {
+        this(false);
+    }
+
+    /**
+     * Create SenML-JSON serializer/deserializer based on Jackson.
+     * <p>
+     * SenML value is defined as mandatory in <a href="https://tools.ietf.org/html/rfc8428#section-4.2">rfc8428</a>, but
+     * SenML records used with a Read-Composite operation do not contain any value field, so
+     * <code>allowNoValue=true</code> can be used skip this validation.
+     * 
+     * @param allowNoValue <code>True</code> to not check if there is a value for each SenML record.
+     */
+    public SenMLJsonRecordSerDes(boolean allowNoValue) {
+        this.allowNoValue = allowNoValue;
+    }
 
     @Override
     public JsonNode jSerialize(SenMLRecord record) throws JsonException {
@@ -94,6 +112,10 @@ public class SenMLJsonRecordSerDes extends JacksonJsonSerDes<SenMLRecord> {
             default:
                 break;
             }
+        } else {
+            if (!allowNoValue)
+                throw new JsonException("Invalid SenML record : record must have a value (v,vb,vlo,vd,vs) : %s",
+                        record);
         }
         return jsonObj;
     }
@@ -152,7 +174,7 @@ public class SenMLJsonRecordSerDes extends JacksonJsonSerDes<SenMLRecord> {
             hasValue = true;
         }
 
-        if (!hasValue)
+        if (!allowNoValue && !hasValue)
             throw new JsonException("Invalid SenML record : record must have a value (v,vb,vlo,vd,vs) : %s", o);
 
         return record;
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 3ca7abc8..f496f87e 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
@@ -42,6 +42,7 @@ 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.ObserveRequest;
+import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
 import org.eclipse.leshan.core.request.WriteAttributesRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
@@ -178,6 +179,17 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         applyLowerLayerConfig(coapRequest);
     }
 
+    @Override
+    public void visit(ReadCompositeRequest 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());
+        setTarget(coapRequest, LwM2mPath.ROOTPATH);
+        applyLowerLayerConfig(coapRequest);
+    }
+
     @Override
     public void visit(BootstrapWriteRequest request) {
         coapRequest = Request.newPut();
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 315ee9cb..6c9e2441 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
@@ -17,6 +17,9 @@ package org.eclipse.leshan.server.californium.request;
 
 import static org.eclipse.leshan.core.californium.ResponseCodeUtil.toLwM2mResponseCode;
 
+import java.util.List;
+import java.util.Map;
+
 import org.eclipse.californium.core.coap.MediaTypeRegistry;
 import org.eclipse.californium.core.coap.Request;
 import org.eclipse.californium.core.coap.Response;
@@ -41,6 +44,7 @@ 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.ObserveRequest;
+import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
 import org.eclipse.leshan.core.request.WriteAttributesRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
@@ -56,6 +60,7 @@ 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.ObserveResponse;
+import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
 import org.eclipse.leshan.core.response.WriteAttributesResponse;
 import org.eclipse.leshan.core.response.WriteResponse;
@@ -253,6 +258,24 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
         }
     }
 
+    @Override
+    public void visit(ReadCompositeRequest request) {
+        if (coapResponse.isError()) {
+            // 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) {
+            // handle success response:
+            Map<LwM2mPath, LwM2mNode> content = decodeCompositeCoapResponse(request.getPaths(), coapResponse, request,
+                    clientEndpoint);
+            lwM2mresponse = new ReadCompositeResponse(ResponseCode.CONTENT, content, null, coapResponse);
+        } else {
+            // handle unexpected response:
+            handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
+        }
+
+    }
+
     @Override
     public void visit(BootstrapDiscoverRequest request) {
         if (coapResponse.isError()) {
@@ -344,6 +367,30 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
         }
     }
 
+    private Map<LwM2mPath, LwM2mNode> decodeCompositeCoapResponse(List<LwM2mPath> paths, Response coapResponse,
+            LwM2mRequest<?> request, String endpoint) {
+
+        // Get content format
+        ContentFormat contentFormat = null;
+        if (coapResponse.getOptions().hasContentFormat()) {
+            contentFormat = ContentFormat.fromCode(coapResponse.getOptions().getContentFormat());
+        }
+
+        // Decode payload
+        try {
+            return decoder.decodeNodes(coapResponse.getPayload(), contentFormat, paths, model);
+        } catch (CodecException e) {
+            if (LOG.isDebugEnabled()) {
+                byte[] payload = coapResponse.getPayload() == null ? new byte[0] : coapResponse.getPayload();
+                LOG.debug(
+                        String.format("Unable to decode response payload of request [%s] from client [%s] [payload:%s]",
+                                request, endpoint, Hex.encodeHexString(payload)));
+            }
+            throw new InvalidResponseException(e, "Unable to decode response payload of request [%s] from client [%s]",
+                    request, endpoint);
+        }
+    }
+
     @SuppressWarnings("unchecked")
     public T getResponse() {
         return (T) lwM2mresponse;
diff --git a/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/BootstrapSessionManager.java b/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/BootstrapSessionManager.java
index c03d2678..219a914f 100644
--- a/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/BootstrapSessionManager.java
+++ b/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/BootstrapSessionManager.java
@@ -15,8 +15,8 @@
  *******************************************************************************/
 package org.eclipse.leshan.server.bootstrap;
 
+import org.eclipse.leshan.core.request.BootstrapDownlinkRequest;
 import org.eclipse.leshan.core.request.BootstrapRequest;
-import org.eclipse.leshan.core.request.DownlinkRequest;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.response.LwM2mResponse;
 
@@ -71,7 +71,8 @@ public interface BootstrapSessionManager {
      * @param bsSession the bootstrap session concerned.
      * @param request The request for which we get a successfull response.
      */
-    public void onResponseSuccess(BootstrapSession bsSession, DownlinkRequest<? extends LwM2mResponse> request);
+    public void onResponseSuccess(BootstrapSession bsSession,
+            BootstrapDownlinkRequest<? extends LwM2mResponse> request);
 
     /**
      * Called when we receive a error response to a request.
@@ -82,8 +83,8 @@ public interface BootstrapSessionManager {
      * 
      * @return a {@link BootstrapPolicy} given the way to continue the bootstrap session.
      */
-    public BootstrapPolicy onResponseError(BootstrapSession bsSession, DownlinkRequest<? extends LwM2mResponse> request,
-            LwM2mResponse response);
+    public BootstrapPolicy onResponseError(BootstrapSession bsSession,
+            BootstrapDownlinkRequest<? extends LwM2mResponse> request, LwM2mResponse response);
 
     /**
      * Called when a request failed to be sent.
@@ -95,7 +96,7 @@ public interface BootstrapSessionManager {
      * @return a {@link BootstrapPolicy} given the way to continue the bootstrap session.
      */
     public BootstrapPolicy onRequestFailure(BootstrapSession bsSession,
-            DownlinkRequest<? extends LwM2mResponse> request, Throwable cause);
+            BootstrapDownlinkRequest<? extends LwM2mResponse> request, Throwable cause);
 
     /**
      * Performs any housekeeping related to the successful ending of a Bootstrapping session.
diff --git a/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/DefaultBootstrapSessionManager.java b/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/DefaultBootstrapSessionManager.java
index dee92bb1..9b157706 100644
--- a/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/DefaultBootstrapSessionManager.java
+++ b/leshan-server-core/src/main/java/org/eclipse/leshan/server/bootstrap/DefaultBootstrapSessionManager.java
@@ -17,9 +17,9 @@ package org.eclipse.leshan.server.bootstrap;
 
 import java.util.Iterator;
 
+import org.eclipse.leshan.core.request.BootstrapDownlinkRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapRequest;
-import org.eclipse.leshan.core.request.DownlinkRequest;
 import org.eclipse.leshan.core.request.Identity;
 import org.eclipse.leshan.core.response.LwM2mResponse;
 import org.eclipse.leshan.server.security.BootstrapSecurityStore;
@@ -88,15 +88,16 @@ public class DefaultBootstrapSessionManager implements BootstrapSessionManager {
     }
 
     @Override
-    public void onResponseSuccess(BootstrapSession bsSession, DownlinkRequest<? extends LwM2mResponse> request) {
+    public void onResponseSuccess(BootstrapSession bsSession,
+            BootstrapDownlinkRequest<? extends LwM2mResponse> request) {
         if (LOG.isTraceEnabled())
             LOG.trace("{} {} receives success response for {} : {}", request.getClass().getSimpleName(),
                     request.getPath(), bsSession, request);
     }
 
     @Override
-    public BootstrapPolicy onResponseError(BootstrapSession bsSession, DownlinkRequest<? extends LwM2mResponse> request,
-            LwM2mResponse response) {
+    public BootstrapPolicy onResponseError(BootstrapSession bsSession,
+            BootstrapDownlinkRequest<? extends LwM2mResponse> request, LwM2mResponse response) {
         if (LOG.isTraceEnabled())
             LOG.trace("{} {} receives error response {} for {} : {}", request.getClass().getSimpleName(),
                     request.getPath(), response, bsSession, request);
@@ -109,7 +110,7 @@ public class DefaultBootstrapSessionManager implements BootstrapSessionManager {
 
     @Override
     public BootstrapPolicy onRequestFailure(BootstrapSession bsSession,
-            DownlinkRequest<? extends LwM2mResponse> request, Throwable cause) {
+            BootstrapDownlinkRequest<? extends LwM2mResponse> request, Throwable cause) {
         if (LOG.isTraceEnabled())
             LOG.trace("{} {} failed because of {} for {} : {}", request.getClass().getSimpleName(), request.getPath(),
                     cause, bsSession, request);


Test patch:
diff --git a/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java b/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java
index f5492c1f..5da08b5a 100644
--- a/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java
+++ b/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeDecoderTest.java
@@ -20,8 +20,11 @@ import static org.junit.Assert.*;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import org.eclipse.leshan.core.json.LwM2mJsonException;
 import org.eclipse.leshan.core.model.LwM2mModel;
@@ -32,6 +35,7 @@ import org.eclipse.leshan.core.model.ResourceModel.Operations;
 import org.eclipse.leshan.core.model.ResourceModel.Type;
 import org.eclipse.leshan.core.model.StaticModel;
 import org.eclipse.leshan.core.node.LwM2mMultipleResource;
+import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mObject;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
@@ -45,6 +49,7 @@ import org.eclipse.leshan.core.tlv.Tlv.TlvType;
 import org.eclipse.leshan.core.tlv.TlvEncoder;
 import org.eclipse.leshan.core.util.Hex;
 import org.eclipse.leshan.core.util.datatype.ULong;
+import org.junit.Assert;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
@@ -1018,4 +1023,121 @@ public class LwM2mNodeDecoderTest {
         assertEquals("a string",
                 ((LwM2mObject) timestampedResources.get(2).getNode()).getInstance(0).getResource(0).getValue());
     }
+
+    @Test
+    public void senml_json_decode_resources() {
+        // Prepare data to decode
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/3/0/0\",\"vs\":\"Open Mobile Alliance\"},");
+        b.append("{\"bn\":\"/3/0/9\",\"v\":95},");
+        b.append("{\"bn\":\"/1/0/1\",\"v\":86400}]");
+        List<LwM2mPath> paths = Arrays.asList(new LwM2mPath("3/0/0"), new LwM2mPath("3/0/9"), new LwM2mPath("1/0/1"));
+
+        // Decode
+        Map<LwM2mPath, LwM2mNode> res = decoder.decodeNodes(b.toString().getBytes(), ContentFormat.SENML_JSON, paths,
+                model);
+
+        // Expected result
+        Map<LwM2mPath, LwM2mNode> nodes = new HashMap<>();
+        nodes.put(new LwM2mPath("3/0/0"), LwM2mSingleResource.newStringResource(0, "Open Mobile Alliance"));
+        nodes.put(new LwM2mPath("3/0/9"), LwM2mSingleResource.newIntegerResource(9, 95));
+        nodes.put(new LwM2mPath("1/0/1"), LwM2mSingleResource.newIntegerResource(1, 86400));
+
+        Assert.assertEquals(nodes, res);
+    }
+
+    @Test
+    public void senml_json_decode_mixed_resource_and_instance() {
+        // Prepare data to decode
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/4/0/0\",\"v\":45},");
+        b.append("{\"bn\":\"/4/0/1\",\"v\":30},");
+        b.append("{\"bn\":\"/4/0/2\",\"v\":100},");
+        b.append("{\"bn\":\"/6/0/\",\"n\":\"0\",\"v\":43.918998},");
+        b.append("{\"n\":\"1\",\"v\":2.351149},");
+        b.append("{\"n\":\"5\",\"v\":1610029880}]");
+
+        List<LwM2mPath> paths = Arrays.asList(new LwM2mPath("4/0/0"), new LwM2mPath("4/0/1"), new LwM2mPath("4/0/2"),
+                new LwM2mPath("6/0"));
+
+        // Decode
+        Map<LwM2mPath, LwM2mNode> res = decoder.decodeNodes(b.toString().getBytes(), ContentFormat.SENML_JSON, paths,
+                model);
+
+        // Expected result
+        Map<LwM2mPath, LwM2mNode> nodes = new HashMap<>();
+        nodes.put(new LwM2mPath("4/0/0"), LwM2mSingleResource.newIntegerResource(0, 45));
+        nodes.put(new LwM2mPath("4/0/1"), LwM2mSingleResource.newIntegerResource(1, 30));
+        nodes.put(new LwM2mPath("4/0/2"), LwM2mSingleResource.newIntegerResource(2, 100));
+        nodes.put(new LwM2mPath("6/0"),
+                new LwM2mObjectInstance(0, LwM2mSingleResource.newFloatResource(0, 43.918998),
+                        LwM2mSingleResource.newFloatResource(1, 2.351149),
+                        LwM2mSingleResource.newDateResource(5, new Date(1610029880000l))));
+
+        Assert.assertEquals(nodes, res);
+    }
+
+    @Test
+    public void senml_json_decode_path_using_name() {
+        // Prepare data to decode
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"n\":\"/4/0/0\"},");
+        b.append("{\"n\":\"/4/0/1\"},");
+        b.append("{\"n\":\"/4/0/2\"}]");
+
+        // Decode
+        List<LwM2mPath> res = decoder.decodePaths(b.toString().getBytes(), ContentFormat.SENML_JSON);
+
+        // Expected result
+        List<LwM2mPath> paths = Arrays.asList( //
+                new LwM2mPath("4/0/0"), //
+                new LwM2mPath("4/0/1"), //
+                new LwM2mPath("4/0/2"));
+
+        Assert.assertEquals(paths, res);
+    }
+
+    @Test
+    public void senml_json_decode_path_using_base_name() {
+        // Prepare data to decode
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/4/0/\", \"n\":\"0\"},");
+        b.append("{\"n\":\"1\"},");
+        b.append("{\"n\":\"2\"}]");
+
+        // Decode
+        List<LwM2mPath> res = decoder.decodePaths(b.toString().getBytes(), ContentFormat.SENML_JSON);
+
+        // Expected result
+        List<LwM2mPath> paths = Arrays.asList( //
+                new LwM2mPath("4/0/0"), //
+                new LwM2mPath("4/0/1"), //
+                new LwM2mPath("4/0/2"));
+
+        Assert.assertEquals(paths, res);
+    }
+
+    @Test(expected = CodecException.class)
+    public void senml_json_decode_invalid_path_with_value() {
+        // Prepare data to decode
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/4/0/\", \"n\":\"0\"},");
+        b.append("{\"n\":\"1\", \"v\":200},");
+        b.append("{\"n\":\"2\"}]");
+
+        // Decode
+        decoder.decodePaths(b.toString().getBytes(), ContentFormat.SENML_JSON);
+    }
+
+    @Test(expected = CodecException.class)
+    public void senml_json_decode_invalid_path_with_timestamp() {
+        // Prepare data to decode
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/4/0/\", \"n\":\"0\"},");
+        b.append("{\"n\":\"1\", \"t\":20000000},");
+        b.append("{\"n\":\"2\"}]");
+
+        // Decode
+        decoder.decodePaths(b.toString().getBytes(), ContentFormat.SENML_JSON);
+    }
 }
diff --git a/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoderTest.java b/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoderTest.java
index d32629ca..bcc78487 100644
--- a/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoderTest.java
+++ b/leshan-core/src/test/java/org/eclipse/leshan/core/node/codec/LwM2mNodeEncoderTest.java
@@ -18,9 +18,11 @@ package org.eclipse.leshan.core.node.codec;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -28,6 +30,7 @@ import org.eclipse.leshan.core.model.LwM2mModel;
 import org.eclipse.leshan.core.model.ObjectLoader;
 import org.eclipse.leshan.core.model.StaticModel;
 import org.eclipse.leshan.core.node.LwM2mMultipleResource;
+import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mObject;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
@@ -366,4 +369,69 @@ public class LwM2mNodeEncoderTest {
         String expected = b.toString();
         Assert.assertEquals(expected, new String(encoded));
     }
+
+    @Test
+    public void senml_json_encode_resources() {
+        // Nodes to encode
+        Map<LwM2mPath, LwM2mNode> nodes = new LinkedHashMap<>();
+        nodes.put(new LwM2mPath("3/0/0"), LwM2mSingleResource.newStringResource(0, "Open Mobile Alliance"));
+        nodes.put(new LwM2mPath("3/0/9"), LwM2mSingleResource.newIntegerResource(9, 95));
+        nodes.put(new LwM2mPath("1/0/1"), LwM2mSingleResource.newIntegerResource(1, 86400));
+
+        // Encode
+        byte[] encoded = encoder.encodeNodes(nodes, ContentFormat.SENML_JSON, model);
+
+        // Expected value
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/3/0/0\",\"vs\":\"Open Mobile Alliance\"},");
+        b.append("{\"bn\":\"/3/0/9\",\"v\":95},");
+        b.append("{\"bn\":\"/1/0/1\",\"v\":86400}]");
+        String expected = b.toString();
+
+        Assert.assertEquals(expected, new String(encoded));
+    }
+
+    @Test
+    public void senml_json_encode_mixed_resource_and_instance() {
+        // Nodes to encode
+        Map<LwM2mPath, LwM2mNode> nodes = new LinkedHashMap<>();
+        nodes.put(new LwM2mPath("4/0/0"), LwM2mSingleResource.newIntegerResource(0, 45));
+        nodes.put(new LwM2mPath("4/0/1"), LwM2mSingleResource.newIntegerResource(1, 30));
+        nodes.put(new LwM2mPath("4/0/2"), LwM2mSingleResource.newIntegerResource(2, 100));
+        nodes.put(new LwM2mPath("6/0"), new LwM2mObjectInstance(0, LwM2mSingleResource.newFloatResource(0, 43.918998),
+                LwM2mSingleResource.newFloatResource(1, 2.351149)));
+
+        // Encode
+        byte[] encoded = encoder.encodeNodes(nodes, ContentFormat.SENML_JSON, model);
+
+        // Expected value
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"bn\":\"/4/0/0\",\"v\":45},");
+        b.append("{\"bn\":\"/4/0/1\",\"v\":30},");
+        b.append("{\"bn\":\"/4/0/2\",\"v\":100},");
+        b.append("{\"bn\":\"/6/0/\",\"n\":\"0\",\"v\":43.918998},");
+        b.append("{\"n\":\"1\",\"v\":2.351149}]");
+        String expected = b.toString();
+
+        Assert.assertEquals(expected, new String(encoded));
+    }
+
+    @Test
+    public void senml_json_encode_path_using_name() {
+        // Prepare data to encode
+        List<LwM2mPath> paths = Arrays.asList( //
+                new LwM2mPath("4/0/0"), //
+                new LwM2mPath("4/0/1"), //
+                new LwM2mPath("4/0/2"));
+
+        // Decode
+        byte[] res = encoder.encodePaths(paths, ContentFormat.SENML_JSON);
+
+        // Expected result
+        StringBuilder b = new StringBuilder();
+        b.append("[{\"n\":\"/4/0/0\"},");
+        b.append("{\"n\":\"/4/0/1\"},");
+        b.append("{\"n\":\"/4/0/2\"}]");
+        Assert.assertEquals(b.toString(), new String(res));
+    }
 }
diff --git a/leshan-core/src/test/java/org/eclipse/leshan/core/senml/cbor/SenMLCborSerializerTest.java b/leshan-core/src/test/java/org/eclipse/leshan/core/senml/cbor/SenMLCborSerializerTest.java
index 0cd712f2..14fbc729 100644
--- a/leshan-core/src/test/java/org/eclipse/leshan/core/senml/cbor/SenMLCborSerializerTest.java
+++ b/leshan-core/src/test/java/org/eclipse/leshan/core/senml/cbor/SenMLCborSerializerTest.java
@@ -34,7 +34,7 @@ public class SenMLCborSerializerTest extends AbstractSenMLTest {
 
     @Parameterized.Parameters(name = "{2}")
     public static Collection<?> senMLJsonencoderDecoder() {
-        SenMLCborUpokecenterEncoderDecoder cborJava = new SenMLCborUpokecenterEncoderDecoder(true);
+        SenMLCborUpokecenterEncoderDecoder cborJava = new SenMLCborUpokecenterEncoderDecoder(true, false);
         SenMLCborJacksonEncoderDecoder jackson = new SenMLCborJacksonEncoderDecoder();
         return Arrays.asList(new Object[][] { //
                                 { cborJava, cborJava, "cbor-java" }, //
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/read/ReadCompositeTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/read/ReadCompositeTest.java
new file mode 100644
index 00000000..31e25e47
--- /dev/null
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/read/ReadCompositeTest.java
@@ -0,0 +1,131 @@
+/*******************************************************************************
+ * Copyright (c) 2021 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.integration.tests.read;
+
+import static org.eclipse.leshan.core.ResponseCode.CONTENT;
+import static org.eclipse.leshan.integration.tests.util.TestUtil.assertContentFormat;
+import static org.junit.Assert.assertEquals;
+
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.eclipse.leshan.core.model.ResourceModel.Type;
+import org.eclipse.leshan.core.node.LwM2mObject;
+import org.eclipse.leshan.core.node.LwM2mResourceInstance;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.ReadCompositeRequest;
+import org.eclipse.leshan.core.response.ReadCompositeResponse;
+import org.eclipse.leshan.integration.tests.util.IntegrationTestHelper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class ReadCompositeTest {
+    protected IntegrationTestHelper helper = new IntegrationTestHelper();
+
+    @Parameters(name = "{0}{1}")
+    public static Collection<?> contentFormats() {
+        return Arrays.asList(new Object[][] { //
+                                // {request content format, response content format}
+                                { ContentFormat.SENML_JSON, ContentFormat.SENML_JSON }, //
+                                { ContentFormat.SENML_CBOR, ContentFormat.SENML_CBOR } });
+    }
+
+    private ContentFormat requestContentFormat;
+    private ContentFormat responseContentFormat;
+
+    public ReadCompositeTest(ContentFormat requestContentFormat, ContentFormat responseContentFormat) {
+        this.requestContentFormat = requestContentFormat;
+        this.responseContentFormat = responseContentFormat;
+    }
+
+    @Before
+    public void start() {
+        helper.initialize();
+        helper.createServer();
+        helper.server.start();
+        helper.createClient();
+        helper.client.start();
+        helper.waitForRegistrationAtServerSide(1);
+    }
+
+    @After
+    public void stop() {
+        helper.client.destroy(false);
+        helper.server.destroy();
+        helper.dispose();
+    }
+
+    @Test
+    public void can_read_resources() throws InterruptedException {
+        // read device model number
+        ReadCompositeResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new ReadCompositeRequest(requestContentFormat, responseContentFormat, "/3/0/0", "/1/0/1"));
+
+        // verify result
+        assertEquals(CONTENT, response.getCode());
+        assertContentFormat(responseContentFormat, response);
+
+        LwM2mSingleResource resource = (LwM2mSingleResource) response.getContent("/3/0/0");
+        assertEquals(0, resource.getId());
+        assertEquals(Type.STRING, resource.getType());
+
+        resource = (LwM2mSingleResource) response.getContent("/1/0/1");
+        assertEquals(1, resource.getId());
+        assertEquals(Type.INTEGER, resource.getType());
+
+    }
+
+    @Test
+    public void can_read_resource_instance() throws InterruptedException {
+        // read device model number
+        ReadCompositeResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new ReadCompositeRequest(requestContentFormat, responseContentFormat, "/2000/0/10/1"));
+
+        // verify result
+        assertEquals(CONTENT, response.getCode());
+        assertContentFormat(responseContentFormat, response);
+
+        LwM2mResourceInstance resource = (LwM2mResourceInstance) response.getContent("/2000/0/10/1");
+        assertEquals(1, resource.getId());
+        assertEquals(Type.STRING, resource.getType());
+
+    }
+
+    @Test
+    public void can_read_resource_and_instance() throws InterruptedException {
+        // read device model number
+        ReadCompositeResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new ReadCompositeRequest(requestContentFormat, responseContentFormat, "/3/0/0", "/1"));
+
+        // verify result
+        assertEquals(CONTENT, response.getCode());
+        assertContentFormat(responseContentFormat, response);
+
+        LwM2mSingleResource resource = (LwM2mSingleResource) response.getContent("/3/0/0");
+        assertEquals(0, resource.getId());
+        assertEquals(Type.STRING, resource.getType());
+
+        LwM2mObject object = (LwM2mObject) response.getContent("/1");
+        assertEquals(1, object.getId());
+        assertEquals(1, object.getInstances().size());
+    }
+}
diff --git a/leshan-server-core/src/test/java/org/eclipse/leshan/server/bootstrap/BootstrapHandlerTest.java b/leshan-server-core/src/test/java/org/eclipse/leshan/server/bootstrap/BootstrapHandlerTest.java
index 67ce5ae7..7d5c6422 100644
--- a/leshan-server-core/src/test/java/org/eclipse/leshan/server/bootstrap/BootstrapHandlerTest.java
+++ b/leshan-server-core/src/test/java/org/eclipse/leshan/server/bootstrap/BootstrapHandlerTest.java
@@ -25,6 +25,7 @@ import java.util.Map;
 
 import org.eclipse.leshan.core.ResponseCode;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
+import org.eclipse.leshan.core.request.BootstrapDownlinkRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
@@ -256,13 +257,14 @@ public class BootstrapHandlerTest {
         }
 
         @Override
-        public void onResponseSuccess(BootstrapSession bsSession, DownlinkRequest<? extends LwM2mResponse> request) {
+        public void onResponseSuccess(BootstrapSession bsSession,
+                BootstrapDownlinkRequest<? extends LwM2mResponse> request) {
 
         }
 
         @Override
         public BootstrapPolicy onResponseError(BootstrapSession bsSession,
-                DownlinkRequest<? extends LwM2mResponse> request, LwM2mResponse response) {
+                BootstrapDownlinkRequest<? extends LwM2mResponse> request, LwM2mResponse response) {
             if (request instanceof BootstrapFinishRequest) {
                 return BootstrapPolicy.STOP;
             }
@@ -271,7 +273,7 @@ public class BootstrapHandlerTest {
 
         @Override
         public BootstrapPolicy onRequestFailure(BootstrapSession bsSession,
-                DownlinkRequest<? extends LwM2mResponse> request, Throwable cause) {
+                BootstrapDownlinkRequest<? extends LwM2mResponse> request, Throwable cause) {
             return BootstrapPolicy.STOP;
         }