Repository: eclipse/leshan
Pull request #959 (2021-01-20 14:48:08)

Add Write Composite Operation
This aims to add Write Composite support.

Atomicity at client side is not supported.

API at server side looks like : 
```java
Map<String, LwM2mNode> nodes = new HashMap<>();
nodes.put("/3/0/14", LwM2mSingleResource.newStringResource(14, "+02"));
nodes.put("/1/0/2", LwM2mSingleResource.newIntegerResource(2, 100));

WriteCompositeResponse response = server.send(helper.getCurrentRegistration(),
        new WriteCompositeRequest(ContentFormat.SENML_COBOR, nodes));
```

This is not so good because resource Id is duplicate but for now I didn't find a better idea.

(This is a work in progress)

sbernard31: This is probably not perfect but I think it's now in reviewable/testable state.

The `WriteCompositeRequest` API at server side is still not so good. Ideas are welcomed.

At client side : 
- There isn't real transaction support for atomicity. Maybe we need a kind of rollback/commit support ?
- We probably also need a new kind of synchronization, locking/synchronized at Object level could be not enough. 


Patch:
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 b439ec8c..e26999a3 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
@@ -19,6 +19,7 @@ package org.eclipse.leshan.client.californium;
 import static org.eclipse.leshan.core.californium.ResponseCodeUtil.toCoapResponseCode;
 
 import java.util.List;
+import java.util.Map;
 
 import org.eclipse.californium.core.CoapResource;
 import org.eclipse.californium.core.CoapServer;
@@ -32,6 +33,7 @@ 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.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeDecoder;
 import org.eclipse.leshan.core.node.codec.LwM2mNodeEncoder;
@@ -39,9 +41,11 @@ 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.request.WriteCompositeRequest;
 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.response.WriteCompositeResponse;
 import org.eclipse.leshan.core.util.StringUtils;
 
 /**
@@ -129,6 +133,35 @@ public class RootResource extends LwM2mClientCoapResource {
         return;
     }
 
+    @Override
+    public void handleIPATCH(CoapExchange exchange) {
+        ServerIdentity identity = getServerOrRejectRequest(exchange);
+        if (identity == null)
+            return;
+
+        // Manage Read Composite request
+        Request coapRequest = exchange.advanced().getRequest();
+
+        // Handle content format
+        ContentFormat contentFormat = ContentFormat.fromCode(exchange.getRequestOptions().getContentFormat());
+        if (!decoder.isSupported(contentFormat)) {
+            exchange.respond(ResponseCode.UNSUPPORTED_CONTENT_FORMAT);
+            return;
+        }
+
+        Map<LwM2mPath, LwM2mNode> nodes = decoder.decodeNodes(coapRequest.getPayload(), contentFormat, null,
+                rootEnabler.getModel());
+
+        WriteCompositeResponse response = rootEnabler.write(identity,
+                new WriteCompositeRequest(contentFormat, nodes, coapRequest));
+        if (response.getCode().isError()) {
+            exchange.respond(toCoapResponseCode(response.getCode()), response.getErrorMessage());
+        } else {
+            exchange.respond(toCoapResponseCode(response.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/object/Device.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Device.java
index f91c53c3..1ccbf965 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Device.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Device.java
@@ -100,7 +100,7 @@ public class Device extends BaseInstanceEnabler {
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value) {
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
 
         switch (resourceid) {
 
@@ -115,7 +115,7 @@ public class Device extends BaseInstanceEnabler {
             return WriteResponse.success();
 
         default:
-            return super.write(identity, resourceid, value);
+            return super.write(identity, replace, resourceid, value);
         }
     }
 
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Security.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Security.java
index 39bfe916..58733f4b 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Security.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Security.java
@@ -154,7 +154,7 @@ public class Security extends BaseInstanceEnabler {
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceId, LwM2mResource value) {
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceId, LwM2mResource value) {
         if (!identity.isSystem())
             LOG.debug("Write on Security resource /{}/{}/{}", getModel().id, getId(), resourceId);
 
@@ -212,7 +212,7 @@ public class Security extends BaseInstanceEnabler {
             return WriteResponse.success();
 
         default:
-            return super.write(identity, resourceId, value);
+            return super.write(identity, replace, resourceId, value);
         }
 
     }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Server.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Server.java
index ad7e9681..ee9a4088 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Server.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/object/Server.java
@@ -108,7 +108,7 @@ public class Server extends BaseInstanceEnabler {
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value) {
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
         if (!identity.isSystem())
             LOG.debug("Write on Server resource /{}/{}/{}", getModel().id, getId(), resourceid);
 
@@ -191,7 +191,7 @@ public class Server extends BaseInstanceEnabler {
             }
 
         default:
-            return super.write(identity, resourceid, value);
+            return super.write(identity, replace, resourceid, value);
         }
     }
 
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseInstanceEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseInstanceEnabler.java
index 5d729170..5c1bfe51 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseInstanceEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseInstanceEnabler.java
@@ -152,7 +152,7 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
                 if (!identity.isLwm2mServer() || resourceModel.operations.isWritable()) {
                     LwM2mResource writeResource = resourcesToWrite.remove(resourceModel.id);
                     if (null != writeResource) {
-                        write(identity, resourceModel.id, writeResource);
+                        write(identity, true, resourceModel.id, writeResource);
                     } else {
                         reset(resourceModel.id);
                     }
@@ -161,18 +161,18 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
         }
         // UPDATE and resources currently not in the model
         for (LwM2mResource resource : resourcesToWrite.values()) {
-            write(identity, resource.getId(), resource);
+            write(identity, false, resource.getId(), resource);
         }
         return WriteResponse.success();
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value) {
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
         return WriteResponse.notFound();
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, int resourceInstanceId,
+    public WriteResponse write(ServerIdentity identity, boolean addIfAbsent, int resourceid, int resourceInstanceId,
             LwM2mResourceInstance value) {
         // this is a sub-optimal default implementation
         ReadResponse response = read(ServerIdentity.SYSTEM, resourceid);
@@ -181,10 +181,10 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
             if (content instanceof LwM2mMultipleResource) {
                 LwM2mMultipleResource multiresource = ((LwM2mMultipleResource) content);
                 Map<Integer, LwM2mResourceInstance> instances = new HashMap<>(multiresource.getInstances());
-                if (instances.containsKey(resourceInstanceId)) {
+                if (addIfAbsent || instances.containsKey(resourceInstanceId)) {
                     instances.put(resourceInstanceId, value);
-                    return write(identity, resourceid,
-                        new LwM2mMultipleResource(resourceid, value.getType(), instances.values()));
+                    return write(identity, true, resourceid,
+                            new LwM2mMultipleResource(resourceid, value.getType(), instances.values()));
                 }
             }
         }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java
index 8f18e509..dfb83ac2 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/BaseObjectEnabler.java
@@ -107,7 +107,7 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
     @Override
     public synchronized CreateResponse create(ServerIdentity identity, CreateRequest request) {
         try {
-            beginTransaction();
+            beginTransaction(LwM2mPath.OBJECT_DEPTH);
 
             if (!identity.isSystem()) {
                 if (id == LwM2mId.SECURITY) {
@@ -133,7 +133,7 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
             return doCreate(identity, request);
 
         } finally {
-            endTransaction();
+            endTransaction(LwM2mPath.OBJECT_DEPTH);
         }
     }
 
@@ -183,7 +183,7 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
     @Override
     public synchronized WriteResponse write(ServerIdentity identity, WriteRequest request) {
         try {
-            beginTransaction();
+            beginTransaction(LwM2mPath.OBJECT_DEPTH);
 
             LwM2mPath path = request.getPath();
 
@@ -234,7 +234,7 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
 
             return doWrite(identity, request);
         } finally {
-            endTransaction();
+            endTransaction(LwM2mPath.OBJECT_DEPTH);
         }
     }
 
@@ -476,12 +476,14 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
         transactionalListener.removeListener(listener);
     }
 
-    protected void beginTransaction() {
-        transactionalListener.beginTransaction();
+    @Override
+    public synchronized void beginTransaction(byte level) {
+        transactionalListener.beginTransaction(level);
     }
 
-    protected void endTransaction() {
-        transactionalListener.endTransaction();
+    @Override
+    public synchronized void endTransaction(byte level) {
+        transactionalListener.endTransaction(level);
     }
 
     @Override
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/DummyInstanceEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/DummyInstanceEnabler.java
index ff528bf2..cbe76934 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/DummyInstanceEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/DummyInstanceEnabler.java
@@ -65,17 +65,17 @@ public class DummyInstanceEnabler extends SimpleInstanceEnabler {
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value) {
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
         LOG.info("Write on {} Resource /{}/{}/{} ", getModel().name, getModel().id, getId(), resourceid);
-        return super.write(identity, resourceid, value);
+        return super.write(identity, replace, resourceid, value);
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, int resourceInstance,
+    public WriteResponse write(ServerIdentity identity, boolean addIfAbsent, int resourceid, int resourceInstance,
             LwM2mResourceInstance value) {
         LOG.info("Write on {} Resource  Instance/{}/{}/{}/{} ", getModel().name, getModel().id, getId(), resourceid,
                 resourceInstance);
-        return super.write(identity, resourceid, resourceInstance, value);
+        return super.write(identity, addIfAbsent, resourceid, resourceInstance, value);
     }
 
     @Override
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mInstanceEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mInstanceEnabler.java
index 3d3028c8..493dc3ff 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mInstanceEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mInstanceEnabler.java
@@ -153,19 +153,23 @@ public interface LwM2mInstanceEnabler {
      * 
      * @param identity the identity of the requester. This could be an internal call in this case
      *        <code> identity == ServerIdentity.SYSTEM</code>.
+     * @param replace If replace is true that means that the resource value completely replace the previous value. The
+     *        behavior difference is only for Multiple resource. If replace is false the existing array of Resource
+     *        Instances is updated meaning some Instances may be created or overwritten but not deleted.
      * @param resourceid the ID of the resource to set the value for
      * @param value the value to set the resource to
      * @return the response object representing the outcome of the operation. An implementation should set the result's
      *         {@link WriteResponse#getCode() response code} to either reflect the success or reason for failure to set
      *         the value.
      */
-    WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value);
+    WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value);
 
     /**
      * Sets the value of one of this LWM2M object instance's resources instance.
      * 
      * @param identity the identity of the requester. This could be an internal call in this case
      *        <code> identity == ServerIdentity.SYSTEM</code>.
+     * @param addIfAbsent If resource instance does not already exist it must be addded only if addIfAbsent is True
      * @param resourceid the ID of the resource to set the value for
      * @param resourceInstance the ID of the resource instance to set the value of
      * @param value the value to set the resource instance to
@@ -173,7 +177,8 @@ public interface LwM2mInstanceEnabler {
      *         {@link WriteResponse#getCode() response code} to either reflect the success or reason for failure to set
      *         the value.
      */
-    WriteResponse write(ServerIdentity identity, int resourceid, int resourceInstance, LwM2mResourceInstance value);
+    WriteResponse write(ServerIdentity identity, boolean addIfAbsent, int resourceid, int resourceInstance,
+            LwM2mResourceInstance value);
 
     /**
      * Executes the operation represented by one of this LWM2M object instance's resources.
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler.java
index 4143cca3..6b31e748 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectEnabler.java
@@ -103,5 +103,9 @@ public interface LwM2mObjectEnabler {
 
     void setLwM2mClient(LwM2mClient client);
 
+    void beginTransaction(byte level);
+
+    void endTransaction(byte level);
+
     ContentFormat getDefaultEncodingFormat(DownlinkRequest<?> request);
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java
index 71b22278..e1596020 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mObjectTree.java
@@ -28,6 +28,7 @@ import org.eclipse.leshan.client.resource.listener.ObjectsListener;
 import org.eclipse.leshan.core.Destroyable;
 import org.eclipse.leshan.core.Startable;
 import org.eclipse.leshan.core.Stoppable;
+import org.eclipse.leshan.core.node.LwM2mPath;
 
 /**
  * The LWM2M Object Tree.
@@ -148,4 +149,17 @@ public class LwM2mObjectTree implements Startable, Stoppable, Destroyable {
             }
         }
     }
+
+    public void beginTransaction(Map<Integer, LwM2mObjectEnabler> enablers) {
+        for (LwM2mObjectEnabler enabler : enablers.values()) {
+            enabler.beginTransaction(LwM2mPath.ROOT_DEPTH);
+        }
+    }
+
+    public void endTransaction(Map<Integer, LwM2mObjectEnabler> enablers) {
+        for (LwM2mObjectEnabler enabler : enablers.values()) {
+            enabler.endTransaction(LwM2mPath.ROOT_DEPTH);
+        }
+    }
+
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
index c1c2b616..6b33f00f 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/LwM2mRootEnabler.java
@@ -18,7 +18,9 @@ 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.request.WriteCompositeRequest;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
+import org.eclipse.leshan.core.response.WriteCompositeResponse;
 
 /**
  * Enable request on root path.
@@ -27,5 +29,7 @@ public interface LwM2mRootEnabler {
 
     ReadCompositeResponse read(ServerIdentity identity, ReadCompositeRequest request);
 
+    WriteCompositeResponse write(ServerIdentity identity, WriteCompositeRequest request);
+
     LwM2mModel getModel();
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java
index 437d97b5..78a444cd 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ObjectEnabler.java
@@ -184,7 +184,7 @@ public class ObjectEnabler extends BaseObjectEnabler implements Destroyable, Sta
 
         // add/write resource
         for (LwM2mResource resource : resources) {
-            newInstance.write(identity, resource.getId(), resource);
+            newInstance.write(identity, true, resource.getId(), resource);
         }
 
         return newInstance;
@@ -268,11 +268,11 @@ public class ObjectEnabler extends BaseObjectEnabler implements Destroyable, Sta
 
         // Manage Resource case
         if (path.getResourceInstanceId() == null) {
-            return instance.write(identity, path.getResourceId(), (LwM2mResource) request.getNode());
+            return instance.write(identity, true, path.getResourceId(), (LwM2mResource) request.getNode());
         }
 
         // Manage Resource Instance case
-        return instance.write(identity, path.getResourceId(), path.getResourceInstanceId(),
+        return instance.write(identity, false, path.getResourceId(), path.getResourceInstanceId(),
                 ((LwM2mResourceInstance) request.getNode()));
     }
 
@@ -314,7 +314,7 @@ public class ObjectEnabler extends BaseObjectEnabler implements Destroyable, Sta
             doCreate(identity, new CreateRequest(path.getObjectId(),
                     new LwM2mObjectInstance(path.getObjectInstanceId(), resource)));
         } else {
-            instanceEnabler.write(identity, path.getResourceId(), resource);
+            instanceEnabler.write(identity, true, path.getResourceId(), resource);
         }
         return BootstrapWriteResponse.success();
     }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
index c461ef15..e4b3a51e 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/RootEnabler.java
@@ -19,17 +19,28 @@ import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 
 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.LwM2mMultipleResource;
 import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.LwM2mResource;
+import org.eclipse.leshan.core.node.LwM2mResourceInstance;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.request.ReadCompositeRequest;
 import org.eclipse.leshan.core.request.ReadRequest;
+import org.eclipse.leshan.core.request.WriteCompositeRequest;
+import org.eclipse.leshan.core.request.WriteRequest;
+import org.eclipse.leshan.core.request.WriteRequest.Mode;
 import org.eclipse.leshan.core.response.ReadCompositeResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
+import org.eclipse.leshan.core.response.WriteCompositeResponse;
+import org.eclipse.leshan.core.response.WriteResponse;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -116,6 +127,98 @@ public class RootEnabler implements LwM2mRootEnabler {
         }
     }
 
+    @Override
+    public WriteCompositeResponse write(ServerIdentity identity, WriteCompositeRequest request) {
+        // We first need to check if targeted object and instance exist and if there are writable.
+        Map<Integer, LwM2mObjectEnabler> enablers = new HashMap<>();
+        for (Entry<LwM2mPath, LwM2mNode> entry : request.getNodes().entrySet()) {
+            LwM2mPath path = entry.getKey();
+            LwM2mObjectEnabler objectEnabler = tree.getObjectEnabler(path.getObjectId());
+
+            // check if object is supported
+            if (objectEnabler == null) {
+                return WriteCompositeResponse.notFound(String.format("object %s not found", path.toObjectPath()));
+            }
+
+            // check if instance is available
+            if (!objectEnabler.getAvailableInstanceIds().contains(path.getObjectInstanceId())) {
+                return WriteCompositeResponse
+                        .notFound(String.format("object instance %s not found", path.toObjectInstancePath()));
+            }
+
+            // check if resource is writable
+            ObjectModel model = objectEnabler.getObjectModel();
+            ResourceModel resourceModel = model.resources.get(path.getResourceId());
+            if (!resourceModel.operations.isWritable()) {
+                return WriteCompositeResponse
+                        .methodNotAllowed(String.format("resource %s is not writable", path.toResourcePath()));
+            }
+
+            // check about single/multi instance resource
+            LwM2mNode node = entry.getValue();
+            if (path.isResource()) {
+                if (resourceModel.multiple) {
+                    return WriteCompositeResponse.badRequest(String.format("resource %s is multi instance", path));
+                }
+                if (!(node instanceof LwM2mSingleResource)) {
+                    return WriteCompositeResponse
+                            .badRequest(String.format("invalid path %s for %s", path, node.getClass().getSimpleName()));
+                }
+            } else if (path.isResourceInstance()) {
+                if (!resourceModel.multiple) {
+                    return WriteCompositeResponse.badRequest(String.format("resource %s is single instance", path));
+                }
+                if (!(node instanceof LwM2mResourceInstance)) {
+                    return WriteCompositeResponse
+                            .badRequest(String.format("invalid path %s for %s", path, node.getClass().getSimpleName()));
+                }
+            }
+            enablers.put(path.getObjectId(), objectEnabler);
+        }
+
+        // TODO all of this should be done in an atomic way. So I suppose if we want to support this we need to add a
+        // kind of transaction mechanism with rollback feature and also a way to lock objectTree?
+        // current transaction mechanism is just about regroup event and so observe notification.
+
+        // Write Nodes
+        try {
+            tree.beginTransaction(enablers);
+            for (Entry<LwM2mPath, LwM2mNode> entry : request.getNodes().entrySet()) {
+                // Get corresponding object enabler
+                LwM2mPath path = entry.getKey();
+                LwM2mNode node = entry.getValue();
+                LwM2mObjectEnabler objectEnabler = enablers.get(path.getObjectId());
+
+                // WriteComposite is about patching so we need to use write UPDATE Mode
+                // which is only available on instance.
+                // So we create an instance from resource or resource instance.
+                LwM2mObjectInstance instance;
+                if (node instanceof LwM2mResource) {
+                    instance = new LwM2mObjectInstance(path.getObjectInstanceId(), (LwM2mResource) node);
+                } else if (node instanceof LwM2mResourceInstance) {
+                    LwM2mResourceInstance resourceInstance = (LwM2mResourceInstance) node;
+                    instance = new LwM2mObjectInstance(path.getObjectInstanceId(), new LwM2mMultipleResource(
+                            path.getResourceId(), resourceInstance.getType(), resourceInstance));
+                } else {
+                    return WriteCompositeResponse.internalServerError(
+                            String.format("node %s should be a resource or a resource instance, not a %s", path,
+                                    node.getClass().getSimpleName()));
+                }
+
+                WriteResponse response = objectEnabler.write(identity, new WriteRequest(Mode.UPDATE,
+                        request.getContentFormat(), path.toObjectInstancePath(), instance, request.getCoapRequest()));
+
+                if (response.isFailure()) {
+                    return new WriteCompositeResponse(response.getCode(), response.getErrorMessage(), null);
+                }
+            }
+        } finally {
+            tree.endTransaction(enablers);
+        }
+        return WriteCompositeResponse.success();
+
+    }
+
     @Override
     public LwM2mModel getModel() {
         return model;
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/SimpleInstanceEnabler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/SimpleInstanceEnabler.java
index 7f6befc4..6b08fe21 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/SimpleInstanceEnabler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/SimpleInstanceEnabler.java
@@ -28,6 +28,7 @@ import org.eclipse.leshan.core.model.ObjectModel;
 import org.eclipse.leshan.core.model.ResourceModel;
 import org.eclipse.leshan.core.node.LwM2mMultipleResource;
 import org.eclipse.leshan.core.node.LwM2mResource;
+import org.eclipse.leshan.core.node.LwM2mResourceInstance;
 import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.response.ExecuteResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
@@ -75,9 +76,28 @@ public class SimpleInstanceEnabler extends BaseInstanceEnabler {
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value) {
-        LwM2mResource previousValue = resources.put(resourceid, value);
-        if (!value.equals(previousValue))
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
+        // define new Value
+        LwM2mResource newValue;
+        if (value instanceof LwM2mMultipleResource && !replace) {
+            // This is the special case of multiple instance resource where we do not replace the resource instances but
+            // we
+            // merge it.
+            LwM2mMultipleResource multipleResource = (LwM2mMultipleResource) resources.get(resourceid);
+            if (multipleResource != null) {
+                Map<Integer, LwM2mResourceInstance> mergedInstances = new HashMap<>(multipleResource.getInstances());
+                mergedInstances.putAll(value.getInstances());
+                newValue = new LwM2mMultipleResource(resourceid, value.getType(), mergedInstances.values());
+            } else {
+                newValue = value;
+            }
+        } else {
+            newValue = value;
+        }
+
+        LwM2mResource previousValue = resources.put(resourceid, newValue);
+
+        if (!newValue.equals(previousValue))
             fireResourcesChange(resourceid);
         return WriteResponse.success();
     }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/TransactionalObjectListener.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/TransactionalObjectListener.java
index 3c42a767..007d445b 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/TransactionalObjectListener.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/TransactionalObjectListener.java
@@ -21,6 +21,8 @@ import java.util.List;
 import java.util.Map;
 
 import org.eclipse.leshan.client.resource.listener.ObjectListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * An {@link ObjectListener} which is able to store notification during transaction and raise all grouped event at the
@@ -29,7 +31,10 @@ import org.eclipse.leshan.client.resource.listener.ObjectListener;
  * This class is not threadsafe.
  */
 public class TransactionalObjectListener implements ObjectListener {
-    protected boolean inTransaction = false;
+
+    private static Logger LOG = LoggerFactory.getLogger(TransactionalObjectListener.class);
+
+    protected int currentLevel = 0;
     protected List<Integer> instancesAdded = new ArrayList<>();
     protected List<Integer> instancesRemoved = new ArrayList<>();
     protected Map<Integer, List<Integer>> resourcesChangedByInstance = new HashMap<>();
@@ -49,16 +54,46 @@ public class TransactionalObjectListener implements ObjectListener {
         innerListeners.remove(listener);
     }
 
-    public void beginTransaction() {
-        inTransaction = true;
+    /**
+     * Open a transaction with a given level. Same level must be used to open and close a transaction.
+     * <p>
+     * a transaction can be started in another transaction but in that case the inner transaction should use a higher
+     * level.
+     * 
+     * @param level the transaction level, a not 0 positive integer.
+     */
+    public void beginTransaction(byte level) {
+        if (level <= 0) {
+            throw new IllegalArgumentException("level must be > 0.");
+        }
+        if (currentLevel == 0) {
+            currentLevel = level;
+        } else if (level <= currentLevel) {
+            LOG.warn(
+                    "Begin transaction with a lower level {} than the current one {} for object {}, this could bring to unexpected behavior",
+                    objectEnabler.getId(), currentLevel, level);
+            currentLevel = level;
+        }
+        // else if level > currentLevel
+        // there is nothing to do has this transaction is inner a another one.
+    }
+
+    public void endTransaction(byte level) {
+        if (currentLevel == level) {
+            try {
+                fireStoredEvents();
+            } catch (Exception e) {
+                LOG.warn("Exception raised when we fired Event about object {}", objectEnabler.getId(), e);
+            }
+            instancesAdded.clear();
+            instancesRemoved.clear();
+            resourcesChangedByInstance.clear();
+            currentLevel = 0;
+        }
     }
 
-    public void endTransaction() {
-        fireStoredEvents();
-        instancesAdded.clear();
-        instancesRemoved.clear();
-        resourcesChangedByInstance.clear();
-        inTransaction = false;
+    protected boolean inTransaction() {
+        return currentLevel > 0;
     }
 
     protected void fireStoredEvents() {
@@ -74,7 +109,7 @@ public class TransactionalObjectListener implements ObjectListener {
 
     @Override
     public void objectInstancesAdded(LwM2mObjectEnabler object, int... instanceIds) {
-        if (!inTransaction) {
+        if (!inTransaction()) {
             fireObjectInstancesAdded(instanceIds);
         } else {
             // store additions
@@ -90,7 +125,7 @@ public class TransactionalObjectListener implements ObjectListener {
 
     @Override
     public void objectInstancesRemoved(LwM2mObjectEnabler object, int... instanceIds) {
-        if (!inTransaction) {
+        if (!inTransaction()) {
             fireObjectInstancesRemoved(instanceIds);
         } else {
             // store deletion
@@ -106,7 +141,7 @@ public class TransactionalObjectListener implements ObjectListener {
 
     @Override
     public void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourcesIds) {
-        if (!inTransaction) {
+        if (!inTransaction()) {
             fireResourcesChanged(instanceId, resourcesIds);
         } else {
             List<Integer> resourcesChanged = resourcesChangedByInstance.get(instanceId);
diff --git a/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyDevice.java b/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyDevice.java
index a51c5219..c29eb38e 100644
--- a/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyDevice.java
+++ b/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyDevice.java
@@ -114,7 +114,7 @@ public class MyDevice extends BaseInstanceEnabler implements Destroyable {
     }
 
     @Override
-    public WriteResponse write(ServerIdentity identity, int resourceid, LwM2mResource value) {
+    public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
         LOG.info("Write on Device resource /{}/{}/{}", getModel().id, getId(), resourceid);
 
         switch (resourceid) {
@@ -129,7 +129,7 @@ public class MyDevice extends BaseInstanceEnabler implements Destroyable {
             fireResourcesChange(resourceid);
             return WriteResponse.success();
         default:
-            return super.write(identity, resourceid, value);
+            return super.write(identity, replace, resourceid, value);
         }
     }
 
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mIncompletePath.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mIncompletePath.java
index f715aac4..bbe749c8 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mIncompletePath.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mIncompletePath.java
@@ -73,6 +73,18 @@ public class LwM2mIncompletePath extends LwM2mPath {
         }
     }
 
+    @Override
+    public LwM2mPath toObjectInstancePath() {
+        throw new IllegalStateException(
+                String.format("an resource path can not be created from incomplete path %s", this));
+    }
+
+    @Override
+    public LwM2mPath toResourcePath() {
+        throw new IllegalStateException(
+                String.format("an resource path can not be created from incomplete path %s", this));
+    }
+
     @Override
     protected void validate() {
         LwM2mNodeUtil.validateIncompletePath(this);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java
index 210a88df..4786af19 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mPath.java
@@ -22,6 +22,12 @@ import org.eclipse.leshan.core.util.Validate;
  */
 public class LwM2mPath {
 
+    public static final byte ROOT_DEPTH = 1;
+    public static final byte OBJECT_DEPTH = 2;
+    public static final byte OBJECT_INSTANCE_DEPTH = 3;
+    public static final byte RESOURCE_DEPTH = 4;
+    public static final byte RESOURCE_INSTANCE_DEPTH = 5;
+
     private final Integer objectId;
     private final Integer objectInstanceId;
     private final Integer resourceId;
@@ -259,6 +265,33 @@ public class LwM2mPath {
         return objectId != null && objectInstanceId != null && resourceId != null && resourceInstanceId != null;
     }
 
+    /**
+     * @return a new {@link LwM2mPath} targeting an object from current path.
+     */
+    public LwM2mPath toObjectPath() {
+        if (getObjectId() != null)
+            return new LwM2mPath(getObjectId());
+        throw new IllegalStateException(String.format("an object path can not be created from %s", this));
+    }
+
+    /**
+     * @return a new {@link LwM2mPath} targeting an object instance from current path.
+     */
+    public LwM2mPath toObjectInstancePath() {
+        if (getObjectInstanceId() != null)
+            return new LwM2mPath(getObjectId(), getObjectInstanceId());
+        throw new IllegalStateException(String.format("an object instance path can not be created from %s", this));
+    }
+
+    /**
+     * @return a new {@link LwM2mPath} targeting an resource from current path.
+     */
+    public LwM2mPath toResourcePath() {
+        if (getResourceId() != null)
+            return new LwM2mPath(getObjectId(), getObjectInstanceId(), getResourceId());
+        throw new IllegalStateException(String.format("an resource path can not be created from %s", this));
+    }
+
     /**
      * The string representation of the path: /{Object ID}/{ObjectInstance ID}/{Resource ID}/{ResourceInstance ID}
      */
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java
index c378561c..5f869ec4 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mResourceInstance.java
@@ -19,6 +19,7 @@ import java.util.Arrays;
 import java.util.Date;
 
 import org.eclipse.leshan.core.model.ResourceModel.Type;
+import org.eclipse.leshan.core.util.datatype.ULong;
 
 /**
  * An instance of {@link LwM2mMultipleResource}.
@@ -38,6 +39,37 @@ public class LwM2mResourceInstance implements LwM2mNode {
         this.type = type;
     }
 
+    public static LwM2mResourceInstance newInstance(int id, Object value) {
+        LwM2mNodeUtil.validateNotNull(value, "value MUST NOT be null");
+
+        if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) {
+            return new LwM2mResourceInstance(id, ((Number) value).longValue(), Type.INTEGER);
+        }
+        if (value instanceof Float || value instanceof Double) {
+            return new LwM2mResourceInstance(id, ((Number) value).doubleValue(), Type.FLOAT);
+        }
+        if (value instanceof Boolean) {
+            return new LwM2mResourceInstance(id, value, Type.BOOLEAN);
+        }
+        if (value instanceof byte[]) {
+            return new LwM2mResourceInstance(id, value, Type.OPAQUE);
+        }
+        if (value instanceof String) {
+            return new LwM2mResourceInstance(id, value, Type.STRING);
+        }
+        if (value instanceof Date) {
+            return new LwM2mResourceInstance(id, value, Type.TIME);
+        }
+        if (value instanceof ObjectLink) {
+            return new LwM2mResourceInstance(id, value, Type.OBJLNK);
+        }
+        if (value instanceof ULong) {
+            return new LwM2mResourceInstance(id, value, Type.UNSIGNED_INTEGER);
+        }
+        throw new LwM2mNodeException(
+                String.format("Unsupported type %s for resource", value.getClass().getCanonicalName()));
+    }
+
     public static LwM2mResourceInstance newInstance(int id, Object value, Type type) {
         String doesNotMatchMessage = "Value does not match the given datatype";
         switch (type) {
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java
index ff876163..e78fe81c 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/node/LwM2mSingleResource.java
@@ -43,6 +43,37 @@ public class LwM2mSingleResource implements LwM2mResource {
         this.type = type;
     }
 
+    public static LwM2mSingleResource newResource(int id, Object value) {
+        LwM2mNodeUtil.validateNotNull(value, "value MUST NOT be null");
+
+        if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) {
+            return new LwM2mSingleResource(id, ((Number) value).longValue(), Type.INTEGER);
+        }
+        if (value instanceof Float || value instanceof Double) {
+            return new LwM2mSingleResource(id, ((Number) value).doubleValue(), Type.FLOAT);
+        }
+        if (value instanceof Boolean) {
+            return new LwM2mSingleResource(id, value, Type.BOOLEAN);
+        }
+        if (value instanceof byte[]) {
+            return new LwM2mSingleResource(id, value, Type.OPAQUE);
+        }
+        if (value instanceof String) {
+            return new LwM2mSingleResource(id, value, Type.STRING);
+        }
+        if (value instanceof Date) {
+            return new LwM2mSingleResource(id, value, Type.TIME);
+        }
+        if (value instanceof ObjectLink) {
+            return new LwM2mSingleResource(id, value, Type.OBJLNK);
+        }
+        if (value instanceof ULong) {
+            return new LwM2mSingleResource(id, value, Type.UNSIGNED_INTEGER);
+        }
+        throw new LwM2mNodeException(
+                String.format("Unsupported type %s for resource", value.getClass().getCanonicalName()));
+    }
+
     public static LwM2mSingleResource newResource(int id, Object value, Type type) {
         String doesNotMatchMessage = "Value does not match the given datatype";
         switch (type) {
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 43af3eea..168d68a6 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
@@ -159,7 +159,8 @@ public class DefaultLwM2mNodeDecoder implements LwM2mNodeDecoder {
     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 (paths != null)
+            Validate.notEmpty(paths);
 
         if (format == null) {
             throw new CodecException("Content format is mandatory. [%s]", paths);
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 cf980e79..24c8810e 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
@@ -21,6 +21,8 @@ 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.node.LwM2mResourceInstance;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.request.ContentFormat;
 
@@ -69,7 +71,9 @@ public interface LwM2mNodeDecoder {
      *
      * @param content the content
      * @param format the content format
-     * @param paths the list of path of node to build.
+     * @param paths the list of path of node to build. The list of path can be <code>null</code> meaning that we don't
+     *        know which kind of {@link LwM2mNode} is encoded. In this case, let's assume this is a list of
+     *        {@link LwM2mSingleResource} or {@link LwM2mResourceInstance}.
      * @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
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
index 18643e89..7be878ae 100644
--- 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
@@ -21,6 +21,8 @@ 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.node.LwM2mResourceInstance;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
 import org.eclipse.leshan.core.request.ContentFormat;
 
 /**
@@ -36,7 +38,9 @@ public interface MultiNodeDecoder {
      * Expected type is guess from list of {@link LwM2mPath}.
      *
      * @param content the content
-     * @param paths the list of path of node to build.
+     * @param paths the list of path of node to build. The list of path can be <code>null</code> meaning that we don't
+     *        know which kind of {@link LwM2mNode} is encoded. In this case, let's assume this is a list of
+     *        {@link LwM2mSingleResource} or {@link LwM2mResourceInstance}.
      * @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
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 f0bc445f..2faf0a91 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
@@ -14,6 +14,7 @@
 package org.eclipse.leshan.core.node.codec.senml;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
@@ -107,28 +108,43 @@ public class LwM2mNodeSenMLDecoder implements TimestampedNodeDecoder, MultiNodeD
             // 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,
+            if (paths != null) {
+                // Resolve records & Group it by time-stamp
+                Map<LwM2mPath, Collection<LwM2mResolvedSenMLRecord>> recordsByPath = groupByPath(pack.getRecords(),
+                        paths);
+
+                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);
+                    }
+                }
+            } else {
+                // Paths are not given so we given so we can not regroup by path
+                // let's assume that each path refer to a single resource or single resource instances.
+                LwM2mSenMLResolver resolver = new LwM2mSenMLResolver();
+                for (SenMLRecord record : pack.getRecords()) {
+                    LwM2mResolvedSenMLRecord resolvedRecord = resolver.resolve(record);
+                    LwM2mPath path = resolvedRecord.getPath();
+                    LwM2mNode node = parseRecords(Arrays.asList(resolvedRecord), path, model,
                             DefaultLwM2mNodeDecoder.nodeClassFromPath(path));
                     nodes.put(path, node);
                 }
             }
             return nodes;
-        } catch (SenMLException e) {
+        } catch (
+
+        SenMLException e) {
             String jsonStrValue = content != null ? new String(content) : "";
             throw new CodecException(e, "Unable to decode nodes[path:%s] : %s", paths, jsonStrValue, e);
         }
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 dce1b760..26abd6da 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
@@ -61,6 +61,10 @@ public class DownLinkRequestVisitorAdapter implements DownlinkRequestVisitor {
     public void visit(ReadCompositeRequest request) {
     }
 
+    @Override
+    public void visit(WriteCompositeRequest writeCompositeRequest) {
+    }
+
     @Override
     public void visit(BootstrapDiscoverRequest 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 58fb029d..92a43765 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
@@ -39,6 +39,8 @@ public interface DownlinkRequestVisitor {
 
     void visit(ReadCompositeRequest request);
 
+    void visit(WriteCompositeRequest writeCompositeRequest);
+
     void visit(BootstrapDiscoverRequest request);
 
     void visit(BootstrapWriteRequest request);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteCompositeRequest.java b/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteCompositeRequest.java
new file mode 100644
index 00000000..bc1e6fb4
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/WriteCompositeRequest.java
@@ -0,0 +1,130 @@
+/*******************************************************************************
+ * 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.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.LwM2mResourceInstance;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.node.ObjectLink;
+import org.eclipse.leshan.core.request.exception.InvalidRequestException;
+import org.eclipse.leshan.core.response.WriteCompositeResponse;
+import org.eclipse.leshan.core.util.Validate;
+import org.eclipse.leshan.core.util.datatype.ULong;
+
+/**
+ * The "Write-Composite" operation can be used by the LwM2M Server to update values of a number of different Resources
+ * across different Instances of one or more Objects. Similar to Read-Composite the Write-Composite operation provides a
+ * list of all resources to be updated, and their new values, using the SenML JSON/CBOR format. Unlike for Write
+ * operation, the Resources that are not provided are not impacted by the operation.
+ * <p>
+ * The "Write-Composite" operation is atomic and cannot have partial success.
+ */
+public class WriteCompositeRequest extends AbstractLwM2mRequest<WriteCompositeResponse>
+        implements CompositeDownlinkRequest<WriteCompositeResponse> {
+
+    private final ContentFormat contentFormat;
+    private final Map<LwM2mPath, LwM2mNode> nodes;
+
+    /**
+     * Create WriteComposite Request.
+     * <p>
+     * 
+     * @param values The values to write as a the Map of string path to value. Value can not be <code>null</code>. And
+     *        must be {@link Boolean} or {@link String} or a byte array or {@link Date} or {@link Long} or
+     *        {@link Double} or {@link ULong} or {@link ObjectLink}.
+     * @param contentFormat The {@link ContentFormat} used to encode this value
+     * 
+     */
+    public WriteCompositeRequest(ContentFormat contentFormat, Map<String, Object> values) {
+        super(null);
+        HashMap<LwM2mPath, LwM2mNode> internalNodes = new HashMap<>();
+        for (Entry<String, Object> entry : values.entrySet()) {
+            LwM2mPath path = new LwM2mPath(entry.getKey());
+            if (path.isResource()) {
+                internalNodes.put(path, LwM2mSingleResource.newResource(path.getResourceId(), entry.getValue()));
+            } else if (path.isResourceInstance()) {
+                internalNodes.put(path, LwM2mResourceInstance.newInstance(path.getResourceId(), entry.getValue()));
+            } else {
+                new InvalidRequestException("Invalid value : path (%s) should target a resource or resource instance");
+            }
+        }
+        this.nodes = internalNodes;
+        this.contentFormat = contentFormat;
+    }
+
+    /**
+     * Create WriteComposite Request.
+     * <p>
+     * This constructor is more for internal usage.
+     * 
+     * @param nodes The {@link LwM2mNode} to write as a the Map of {@link LwM2mPath} to {@link LwM2mNode}. Value can not
+     *        be <code>null</code>. LwM2mNode MUST be {@link LwM2mSingleResource} or {@link LwM2mResourceInstance}.
+     * @param contentFormat The {@link ContentFormat} used to encode the Map of {@link LwM2mNode}
+     * @param coapRequest the underlying request.
+     * 
+     */
+    public WriteCompositeRequest(ContentFormat contentFormat, Map<LwM2mPath, LwM2mNode> nodes, Object coapRequest) {
+        super(coapRequest);
+
+        validateNodes(nodes);
+        this.contentFormat = contentFormat;
+        this.nodes = nodes;
+    }
+
+    private void validateNodes(Map<LwM2mPath, LwM2mNode> nodes) {
+        Validate.notEmpty(nodes);
+        for (Entry<LwM2mPath, LwM2mNode> entry : nodes.entrySet()) {
+            LwM2mPath path = entry.getKey();
+            LwM2mNode node = entry.getValue();
+            Validate.notNull(path);
+            Validate.notNull(node);
+
+            if (path.isResource() && node instanceof LwM2mSingleResource)
+                return;
+            if (path.isResourceInstance() && node instanceof LwM2mResourceInstance)
+                return;
+
+            throw new InvalidRequestException("Invalid value : path (%s) should not refer to a %s value", path,
+                    node.getClass().getSimpleName());
+        }
+    }
+
+    public Map<LwM2mPath, LwM2mNode> getNodes() {
+        return nodes;
+    }
+
+    @Override
+    public List<LwM2mPath> getPaths() {
+        return new ArrayList<>(nodes.keySet());
+    }
+
+    @Override
+    public void accept(DownlinkRequestVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    public ContentFormat getContentFormat() {
+        return contentFormat;
+    }
+}
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 f6555d8b..d49cde3f 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
@@ -364,7 +364,20 @@ public class WriteRequest extends AbstractSimpleDownlinkRequest<WriteResponse> {
         this(mode, contentFormat, newPath(path), node, coapRequest);
     }
 
-    private WriteRequest(Mode mode, ContentFormat format, LwM2mPath target, LwM2mNode node, Object coapRequest) {
+    /**
+     * A generic constructor to write request.
+     * <p>
+     * Mainly for internal purpore.
+     * 
+     * @param mode the mode of the request : replace or update.
+     * @param format Format of the payload (TLV,JSON,TEXT,OPAQUE ..).
+     * @param target the path of the LWM2M node to write (object instance or resource).
+     * @param node the {@link LwM2mNode} to write.
+     * @param coapRequest the underlying request
+     * 
+     * @exception InvalidRequestException if parameters are invalid.
+     */
+    public WriteRequest(Mode mode, ContentFormat format, LwM2mPath target, LwM2mNode node, Object coapRequest) {
         super(target, coapRequest);
         if (target.isRoot())
             throw new InvalidRequestException("Write request cannot target root path");
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/WriteCompositeResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/WriteCompositeResponse.java
new file mode 100644
index 00000000..d1cdacad
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/WriteCompositeResponse.java
@@ -0,0 +1,78 @@
+/*******************************************************************************
+ * 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 org.eclipse.leshan.core.ResponseCode;
+
+public class WriteCompositeResponse extends AbstractLwM2mResponse {
+
+    public WriteCompositeResponse(ResponseCode code, String errorMessage) {
+        super(code, errorMessage, null);
+    }
+
+    public WriteCompositeResponse(ResponseCode code, String errorMessage, Object coapResponse) {
+        super(code, errorMessage, coapResponse);
+    }
+
+    @Override
+    public boolean isSuccess() {
+        return ResponseCode.CHANGED.equals(getCode());
+    }
+
+    @Override
+    public boolean isValid() {
+        switch (code.getCode()) {
+        case ResponseCode.CHANGED_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:
+            return true;
+        default:
+            return false;
+        }
+    }
+
+    public static WriteCompositeResponse success() {
+        return new WriteCompositeResponse(ResponseCode.CHANGED, null);
+    }
+
+    public static WriteCompositeResponse badRequest(String errorMessage) {
+        return new WriteCompositeResponse(ResponseCode.BAD_REQUEST, errorMessage);
+    }
+
+    public static WriteCompositeResponse unauthorized() {
+        return new WriteCompositeResponse(ResponseCode.UNAUTHORIZED, null);
+    }
+
+    public static WriteCompositeResponse notFound(String errorMessage) {
+        return new WriteCompositeResponse(ResponseCode.NOT_FOUND, errorMessage);
+    }
+
+    public static WriteCompositeResponse methodNotAllowed(String errorMessage) {
+        return new WriteCompositeResponse(ResponseCode.METHOD_NOT_ALLOWED, errorMessage);
+    }
+
+    public static WriteCompositeResponse notAcceptable() {
+        return new WriteCompositeResponse(ResponseCode.NOT_ACCEPTABLE, null);
+    }
+
+    public static WriteCompositeResponse internalServerError(String errorMessage) {
+        return new WriteCompositeResponse(ResponseCode.INTERNAL_SERVER_ERROR, errorMessage);
+    }
+}
\ No newline at end of file
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 f496f87e..5a31fada 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
@@ -45,6 +45,7 @@ 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.WriteCompositeRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.util.StringUtils;
 import org.eclipse.leshan.server.californium.observation.ObserveUtil;
@@ -190,6 +191,15 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         applyLowerLayerConfig(coapRequest);
     }
 
+    @Override
+    public void visit(WriteCompositeRequest request) {
+        coapRequest = Request.newIPatch();
+        coapRequest.getOptions().setContentFormat(request.getContentFormat().getCode());
+        coapRequest.setPayload(encoder.encodeNodes(request.getNodes(), request.getContentFormat(), model));
+        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 6c9e2441..d6f2032b 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
@@ -47,6 +47,7 @@ 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.WriteCompositeRequest;
 import org.eclipse.leshan.core.request.WriteRequest;
 import org.eclipse.leshan.core.request.exception.InvalidResponseException;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
@@ -63,6 +64,7 @@ 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.WriteCompositeResponse;
 import org.eclipse.leshan.core.response.WriteResponse;
 import org.eclipse.leshan.core.util.Hex;
 import org.eclipse.leshan.server.californium.observation.ObserveUtil;
@@ -273,7 +275,21 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
             // handle unexpected response:
             handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
         }
+    }
 
+    @Override
+    public void visit(WriteCompositeRequest request) {
+        if (coapResponse.isError()) {
+            // handle error response:
+            lwM2mresponse = new WriteCompositeResponse(toLwM2mResponseCode(coapResponse.getCode()),
+                    coapResponse.getPayloadString(), coapResponse);
+        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+            // handle success response:
+            lwM2mresponse = new WriteCompositeResponse(ResponseCode.CHANGED, null, coapResponse);
+        } else {
+            // handle unexpected response:
+            handleUnexpectedResponseCode(clientEndpoint, request, coapResponse);
+        }
     }
 
     @Override


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 5da08b5a..ceb3484b 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
@@ -1077,6 +1077,33 @@ public class LwM2mNodeDecoderTest {
         Assert.assertEquals(nodes, res);
     }
 
+    @Test
+    public void senml_json_decode_mixed_resource_without_given_path() {
+        // 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}]");
+
+        // Decode
+        Map<LwM2mPath, LwM2mNode> res = decoder.decodeNodes(b.toString().getBytes(), ContentFormat.SENML_JSON, null,
+                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/0"), LwM2mSingleResource.newFloatResource(0, 43.918998));
+        nodes.put(new LwM2mPath("6/0/1"), LwM2mSingleResource.newFloatResource(1, 2.351149));
+        nodes.put(new LwM2mPath("6/0/5"), LwM2mSingleResource.newDateResource(5, new Date(1610029880000l)));
+
+        Assert.assertEquals(nodes, res);
+    }
+
     @Test
     public void senml_json_decode_path_using_name() {
         // Prepare data to decode
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java
index 8c1bcf5b..6b976515 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/TestObservationListener.java
@@ -3,6 +3,7 @@ package org.eclipse.leshan.integration.tests.observe;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import org.eclipse.leshan.core.observation.Observation;
 import org.eclipse.leshan.core.response.ObserveResponse;
@@ -13,6 +14,7 @@ public class TestObservationListener implements ObservationListener {
 
     private CountDownLatch latch = new CountDownLatch(1);
     private final AtomicBoolean receivedNotify = new AtomicBoolean();
+    private AtomicInteger counter = new AtomicInteger(0);
     private ObserveResponse response;
     private Exception error;
 
@@ -21,6 +23,7 @@ public class TestObservationListener implements ObservationListener {
         receivedNotify.set(true);
         this.response = response;
         this.error = null;
+        this.counter.incrementAndGet();
         latch.countDown();
     }
 
@@ -57,10 +60,15 @@ public class TestObservationListener implements ObservationListener {
         latch.await(timeout, TimeUnit.MILLISECONDS);
     }
 
+    public int getNotificationCount() {
+        return counter.get();
+    }
+
     public void reset() {
         latch = new CountDownLatch(1);
         receivedNotify.set(false);
         response = null;
         error = null;
+        this.counter = new AtomicInteger(0);
     }
 }
\ No newline at end of file
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/TestObjectsInitializer.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/TestObjectsInitializer.java
index ea8ef214..27d6e6af 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/TestObjectsInitializer.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/util/TestObjectsInitializer.java
@@ -184,6 +184,16 @@ public class TestObjectsInitializer extends ObjectsInitializer {
             public void addListener(ObjectListener listener) {
                 nodeEnabler.addListener(listener);
             }
+
+            @Override
+            public void beginTransaction(byte level) {
+                nodeEnabler.beginTransaction(level);
+            }
+
+            @Override
+            public void endTransaction(byte level) {
+                nodeEnabler.endTransaction(level);
+            }
         };
     }
 }
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java
new file mode 100644
index 00000000..6e90c131
--- /dev/null
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/write/WriteCompositeTest.java
@@ -0,0 +1,210 @@
+/*******************************************************************************
+ * 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.write;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsInstanceOf.instanceOf;
+import static org.junit.Assert.*;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.californium.core.coap.Response;
+import org.eclipse.leshan.core.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mMultipleResource;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.LwM2mObjectInstance;
+import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.node.LwM2mResourceInstance;
+import org.eclipse.leshan.core.node.LwM2mSingleResource;
+import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.request.ContentFormat;
+import org.eclipse.leshan.core.request.ObserveRequest;
+import org.eclipse.leshan.core.request.ReadRequest;
+import org.eclipse.leshan.core.request.WriteCompositeRequest;
+import org.eclipse.leshan.core.response.LwM2mResponse;
+import org.eclipse.leshan.core.response.ObserveResponse;
+import org.eclipse.leshan.core.response.ReadResponse;
+import org.eclipse.leshan.core.response.WriteCompositeResponse;
+import org.eclipse.leshan.integration.tests.observe.TestObservationListener;
+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 WriteCompositeTest {
+    protected IntegrationTestHelper helper = new IntegrationTestHelper();
+
+    @Parameters(name = "{0}")
+    public static Collection<?> contentFormats() {
+        return Arrays.asList(new Object[][] { //
+                                { ContentFormat.SENML_JSON }, //
+                                { ContentFormat.SENML_CBOR } });
+    }
+
+    private ContentFormat contentFormat;
+
+    public WriteCompositeTest(ContentFormat contentFormat) {
+        this.contentFormat = contentFormat;
+    }
+
+    @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_write_resources() throws InterruptedException {
+        // write device timezone and offset
+        Map<String, Object> nodes = new HashMap<>();
+        nodes.put("/3/0/14", "+02");
+        nodes.put("/1/0/2", 100);
+
+        WriteCompositeResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new WriteCompositeRequest(contentFormat, nodes));
+
+        // verify result
+        assertEquals(ResponseCode.CHANGED, response.getCode());
+        assertNotNull(response.getCoapResponse());
+        assertThat(response.getCoapResponse(), is(instanceOf(Response.class)));
+
+        // read resource to check the value changed
+        ReadResponse readResponse = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(3, 0, 14));
+        assertEquals("+02", ((LwM2mSingleResource) readResponse.getContent()).getValue());
+        readResponse = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(1, 0, 2));
+        assertEquals(100l, ((LwM2mSingleResource) readResponse.getContent()).getValue());
+    }
+
+    @Test
+    public void can_write_resource_and_instance() throws InterruptedException {
+        // create value
+        LwM2mSingleResource utcOffset = LwM2mSingleResource.newStringResource(14, "+02");
+        LwM2mPath resourceInstancePath = new LwM2mPath(IntegrationTestHelper.TEST_OBJECT_ID, 0,
+                IntegrationTestHelper.STRING_RESOURCE_INSTANCE_ID, 100);
+        LwM2mResourceInstance testStringResourceInstance = LwM2mResourceInstance
+                .newStringInstance(resourceInstancePath.getResourceInstanceId(), "test_string_instance");
+
+        // add it to the map
+        Map<LwM2mPath, LwM2mNode> nodes = new HashMap<>();
+        nodes.put(new LwM2mPath("/3/0/14"), utcOffset);
+        nodes.put(resourceInstancePath, testStringResourceInstance);
+
+        WriteCompositeResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new WriteCompositeRequest(contentFormat, nodes, null));
+
+        // verify result
+        assertEquals(ResponseCode.CHANGED, response.getCode());
+        assertNotNull(response.getCoapResponse());
+        assertThat(response.getCoapResponse(), is(instanceOf(Response.class)));
+
+        // read resource to check the value changed
+        ReadResponse readResponse = helper.server.send(helper.getCurrentRegistration(), new ReadRequest(3, 0, 14));
+        assertEquals(utcOffset, readResponse.getContent());
+
+        readResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ReadRequest(contentFormat, resourceInstancePath, null));
+        assertEquals(testStringResourceInstance, readResponse.getContent());
+    }
+
+    @Test
+    public void can_add_resource_instances() throws InterruptedException {
+        // Prepare node
+        LwM2mPath resourceInstancePath = new LwM2mPath(IntegrationTestHelper.TEST_OBJECT_ID, 0,
+                IntegrationTestHelper.STRING_RESOURCE_INSTANCE_ID, 100);
+        LwM2mResourceInstance testStringResourceInstance = LwM2mResourceInstance
+                .newStringInstance(resourceInstancePath.getResourceInstanceId(), "test_string_instance");
+        Map<LwM2mPath, LwM2mNode> nodes = new HashMap<>();
+        nodes.put(resourceInstancePath, testStringResourceInstance);
+
+        // Write it
+        WriteCompositeResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new WriteCompositeRequest(contentFormat, nodes, null));
+
+        // verify result
+        assertEquals(ResponseCode.CHANGED, response.getCode());
+        assertNotNull(response.getCoapResponse());
+        assertThat(response.getCoapResponse(), is(instanceOf(Response.class)));
+
+        // read resource to check the value changed
+        ReadResponse readResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ReadRequest(contentFormat, resourceInstancePath.toResourcePath(), null));
+        LwM2mMultipleResource multiResource = (LwM2mMultipleResource) readResponse.getContent();
+        assertEquals(3, multiResource.getInstances().size());
+        assertEquals(testStringResourceInstance,
+                multiResource.getInstance(resourceInstancePath.getResourceInstanceId()));
+    }
+
+    @Test
+    public void can_observe_instance_with_composite_write() throws InterruptedException {
+        TestObservationListener listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+
+        // observe device instance
+        ObserveResponse observeResponse = helper.server.send(helper.getCurrentRegistration(), new ObserveRequest(3, 0));
+        assertEquals(ResponseCode.CONTENT, observeResponse.getCode());
+        assertNotNull(observeResponse.getCoapResponse());
+        assertThat(observeResponse.getCoapResponse(), is(instanceOf(Response.class)));
+
+        // an observation response should have been sent
+        Observation observation = observeResponse.getObservation();
+        assertEquals("/3/0", observation.getPath().toString());
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        System.out.println(observeResponse.getContent());
+
+        // write device timezone
+        Map<String, Object> nodes = new HashMap<>();
+        nodes.put("/3/0/14", "+11");
+        nodes.put("/3/0/15", "Moon");
+
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteCompositeRequest(ContentFormat.SENML_CBOR, nodes));
+
+        // verify result both resource must have new value
+        listener.waitForNotification(1000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertTrue(listener.receivedNotify().get());
+        assertTrue(listener.getResponse().getContent() instanceof LwM2mObjectInstance);
+        assertNotNull(listener.getResponse().getCoapResponse());
+        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+
+        LwM2mObjectInstance instance = (LwM2mObjectInstance) listener.getResponse().getContent();
+        assertEquals("+11", instance.getResource(14).getValue());
+        assertEquals("Moon", instance.getResource(15).getValue());
+
+        // Ensure we received only one notification.
+        Thread.sleep(1000);// wait 1 second more to catch more notification ?
+        assertEquals(1, listener.getNotificationCount());
+    }
+}