Repository: eclipse/leshan
Pull request #1054 (2021-07-19 14:58:49)

Add "Observe resource instance" support
It aims to implement  #1041.


Patch:
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
index b0623432..96be9d30 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ObjectResource.java
@@ -426,14 +426,10 @@ public class ObjectResource extends LwM2mClientCoapResource implements ObjectLis
     }
 
     @Override
-    public void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourceIds) {
+    public void resourceChanged(LwM2mPath... paths) {
         // notify CoAP layer than resources changes, this will send observe notification if an observe relationship
         // exits.
-        changed(new ResourceObserveFilter(object.getId() + ""));
-        changed(new ResourceObserveFilter(object.getId() + "/" + instanceId));
-        for (int resourceId : resourceIds) {
-            changed(new ResourceObserveFilter(object.getId() + "/" + instanceId + "/" + resourceId));
-        }
+        changed(new ResourceObserveFilter(paths));
     }
 
     @Override
diff --git a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ResourceObserveFilter.java b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ResourceObserveFilter.java
index 430165fa..a3ac5770 100644
--- a/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ResourceObserveFilter.java
+++ b/leshan-client-cf/src/main/java/org/eclipse/leshan/client/californium/object/ResourceObserveFilter.java
@@ -18,30 +18,29 @@ package org.eclipse.leshan.client.californium.object;
 
 import org.eclipse.californium.core.observe.ObserveRelation;
 import org.eclipse.californium.core.observe.ObserveRelationFilter;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.leshan.core.node.LwM2mPath;
 
 /**
  * An {@link ObserveRelationFilter} which select {@link ObserveRelation} based on resource URI.
  */
 public class ResourceObserveFilter implements ObserveRelationFilter {
 
-    private static final Logger LOG = LoggerFactory.getLogger(ResourceObserveFilter.class);
+    protected final LwM2mPath[] paths;
 
-    protected final String notifyURI;
-
-    public ResourceObserveFilter(String notifyURI) {
-        this.notifyURI = notifyURI;
+    public ResourceObserveFilter(LwM2mPath... paths) {
+        this.paths = paths;
     }
 
     @Override
     public boolean accept(ObserveRelation relation) {
-        String relationURI = relation.getExchange().getRequest().getOptions().getUriPathString();
-        boolean result = relationURI.equals(notifyURI);
-        if (LOG.isTraceEnabled()) {
-            LOG.trace("observe " + relationURI + " / " + notifyURI + ": " + result);
+        String relationURI = "/" + relation.getExchange().getRequest().getOptions().getUriPathString();
+        LwM2mPath relationPath = new LwM2mPath(relationURI);
+        for (LwM2mPath path : paths) {
+            if (path.startWith(relationPath)) {
+                return true;
+            }
         }
-        return result;
+        return false;
     }
 
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/RegistrationUpdateHandler.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/RegistrationUpdateHandler.java
index e3125823..a5169374 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/RegistrationUpdateHandler.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/RegistrationUpdateHandler.java
@@ -26,6 +26,7 @@ import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.client.servers.ServersInfoExtractor;
 import org.eclipse.leshan.client.util.LinkFormatHelper;
 import org.eclipse.leshan.core.LwM2mId;
+import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.request.BindingMode;
 
 /**
@@ -73,37 +74,49 @@ public class RegistrationUpdateHandler {
             }
 
             @Override
-            public void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourceIds) {
+            public void resourceChanged(LwM2mPath... paths) {
                 if (!bsHandler.isBootstrapping())
-                    if (object.getId() == LwM2mId.SERVER) {
-                        // handle lifetime changes
-                        for (int i = 0; i < resourceIds.length; i++) {
-                            if (resourceIds[i] == LwM2mId.SRV_LIFETIME) {
-                                Long lifetime = ServersInfoExtractor.getLifeTime(object, instanceId);
-                                Long serverId = ServersInfoExtractor.getServerId(object, instanceId);
-                                if (lifetime != null && serverId != null) {
-                                    ServerIdentity server = engine.getRegisteredServer(serverId);
-                                    if (server != null) {
-                                        engine.triggerRegistrationUpdate(server,
-                                                new RegistrationUpdate(lifetime, null, null, null, null));
-                                        return;
+                    // TODO we should be smarter here and manage the case where server lifetime and supportedBinding is
+                    // modified at same time.
+                    // /!\ lifetime trigger update to only 1 server and supportedBinding to all the registered ones.
+                    for (LwM2mPath path : paths) {
+                        if (path.isResource()) {
+                            if (path.getObjectId() == LwM2mId.SERVER) {
+                                // handle lifetime changes
+                                if (path.getResourceId() == LwM2mId.SRV_LIFETIME) {
+                                    LwM2mObjectEnabler enabler = objecTree.getObjectEnabler(LwM2mId.SERVER);
+                                    if (enabler != null) {
+                                        Long lifetime = ServersInfoExtractor.getLifeTime(enabler,
+                                                path.getObjectInstanceId());
+                                        Long serverId = ServersInfoExtractor.getServerId(enabler,
+                                                path.getObjectInstanceId());
+                                        if (lifetime != null && serverId != null) {
+                                            ServerIdentity server = engine.getRegisteredServer(serverId);
+                                            if (server != null) {
+                                                engine.triggerRegistrationUpdate(server,
+                                                        new RegistrationUpdate(lifetime, null, null, null, null));
+                                                return;
+                                            }
+                                        }
+                                    }
+                                }
+                            } else if (path.getObjectId() == LwM2mId.DEVICE) {
+                                // handle supported binding changes
+                                EnumSet<BindingMode> bindingMode = null;
+                                if (path.getResourceId() == LwM2mId.DVC_SUPPORTED_BINDING) {
+                                    LwM2mObjectEnabler enabler = objecTree.getObjectEnabler(LwM2mId.SERVER);
+                                    if (enabler != null) {
+                                        bindingMode = ServersInfoExtractor.getDeviceSupportedBindingMode(enabler,
+                                                path.getObjectInstanceId());
                                     }
                                 }
-                            }
-                        }
-                    } else if (object.getId() == LwM2mId.DEVICE) {
-                        // handle supported binding changes
-                        EnumSet<BindingMode> bindingMode = null;
-                        for (int i = 0; i < resourceIds.length; i++) {
-                            if (resourceIds[i] == LwM2mId.DVC_SUPPORTED_BINDING) {
-                                bindingMode = ServersInfoExtractor.getDeviceSupportedBindingMode(object, instanceId);
-                            }
-                        }
 
-                        if (bindingMode != null) {
-                            engine.triggerRegistrationUpdate(
-                                    new RegistrationUpdate(null, null, bindingMode, null, null));
-                            return;
+                                if (bindingMode != null) {
+                                    engine.triggerRegistrationUpdate(
+                                            new RegistrationUpdate(null, null, bindingMode, null, null));
+                                    return;
+                                }
+                            }
                         }
                     }
             }
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 1ccbf965..481f6164 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
@@ -106,12 +106,12 @@ public class Device extends BaseInstanceEnabler {
 
         case 14: // utc offset
             utcOffset = (String) value.getValue();
-            fireResourcesChange(resourceid);
+            fireResourceChange(resourceid);
             return WriteResponse.success();
 
         case 15: // timezone
             timezone = (String) value.getValue();
-            fireResourcesChange(resourceid);
+            fireResourceChange(resourceid);
             return WriteResponse.success();
 
         default:
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 9924e6fa..dfa60625 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
@@ -120,7 +120,7 @@ public class Server extends BaseInstanceEnabler {
             int previousShortServerId = shortServerId;
             shortServerId = ((Long) value.getValue()).intValue();
             if (previousShortServerId != shortServerId)
-                fireResourcesChange(resourceid);
+                fireResourceChange(resourceid);
             return WriteResponse.success();
 
         case 1:
@@ -130,7 +130,7 @@ public class Server extends BaseInstanceEnabler {
             long previousLifetime = lifetime;
             lifetime = (Long) value.getValue();
             if (previousLifetime != lifetime)
-                fireResourcesChange(resourceid);
+                fireResourceChange(resourceid);
             return WriteResponse.success();
 
         case 2:
@@ -140,7 +140,7 @@ public class Server extends BaseInstanceEnabler {
             Long previousDefaultMinPeriod = defaultMinPeriod;
             defaultMinPeriod = (Long) value.getValue();
             if (!Objects.equals(previousDefaultMinPeriod, defaultMinPeriod))
-                fireResourcesChange(resourceid);
+                fireResourceChange(resourceid);
             return WriteResponse.success();
 
         case 3:
@@ -150,7 +150,7 @@ public class Server extends BaseInstanceEnabler {
             Long previousDefaultMaxPeriod = defaultMaxPeriod;
             defaultMaxPeriod = (Long) value.getValue();
             if (!Objects.equals(previousDefaultMaxPeriod, defaultMaxPeriod))
-                fireResourcesChange(resourceid);
+                fireResourceChange(resourceid);
             return WriteResponse.success();
 
         case 6: // notification storing when disable or offline
@@ -160,7 +160,7 @@ public class Server extends BaseInstanceEnabler {
             boolean previousNotifyWhenDisable = notifyWhenDisable;
             notifyWhenDisable = (boolean) value.getValue();
             if (previousNotifyWhenDisable != notifyWhenDisable)
-                fireResourcesChange(resourceid);
+                fireResourceChange(resourceid);
             return WriteResponse.success();
 
         case 7: // binding
@@ -171,7 +171,7 @@ public class Server extends BaseInstanceEnabler {
                 EnumSet<BindingMode> previousBinding = binding;
                 binding = BindingMode.parse((String) value.getValue());
                 if (!Objects.equals(previousBinding, binding))
-                    fireResourcesChange(resourceid);
+                    fireResourceChange(resourceid);
                 return WriteResponse.success();
             } catch (IllegalArgumentException e) {
                 return WriteResponse.badRequest("invalid value");
@@ -184,7 +184,7 @@ public class Server extends BaseInstanceEnabler {
                 BindingMode previousPreferedTransport = preferredTransport;
                 preferredTransport = BindingMode.valueOf((String) value.getValue());
                 if (!Objects.equals(previousPreferedTransport, preferredTransport))
-                    fireResourcesChange(resourceid);
+                    fireResourceChange(resourceid);
                 return WriteResponse.success();
             } catch (IllegalArgumentException e) {
                 return WriteResponse.badRequest("invalid 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 5c1bfe51..b9f4fc43 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
@@ -24,12 +24,13 @@ import java.util.List;
 import java.util.Map;
 
 import org.eclipse.leshan.client.LwM2mClient;
+import org.eclipse.leshan.client.resource.listener.ResourceListener;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 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.response.ExecuteResponse;
@@ -43,7 +44,7 @@ import org.eclipse.leshan.core.response.WriteResponse;
  */
 public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
 
-    protected List<ResourceChangedListener> listeners = new ArrayList<>();
+    protected List<ResourceListener> listeners = new ArrayList<>();
     protected Integer id = null;
     protected ObjectModel model;
     protected LwM2mClient lwm2mClient;
@@ -84,12 +85,12 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
     }
 
     @Override
-    public void addResourceChangedListener(ResourceChangedListener listener) {
+    public void addResourceListener(ResourceListener listener) {
         listeners.add(listener);
     }
 
     @Override
-    public void removeResourceChangedListener(ResourceChangedListener listener) {
+    public void removeResourceListener(ResourceListener listener) {
         listeners.remove(listener);
     }
 
@@ -101,14 +102,39 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
      * <p>
      * Calling this method is needed to trigger NOTIFICATION when an observe relation is established.
      * 
-     * @param resourceIds the list of resources which change.
+     * @param resources the list of path of resource and resource instances which changes.
+     * 
+     * @see #getResourcePath(int)
+     * @see #getResourceInstancePath(int, int)
      */
-    public void fireResourcesChange(int... resourceIds) {
-        for (ResourceChangedListener listener : listeners) {
-            listener.resourcesChanged(resourceIds);
+    public void fireResourcesChange(LwM2mPath... resources) {
+        for (ResourceListener listener : listeners) {
+            listener.resourceChanged(resources);
         }
     }
 
+    /**
+     * @see #fireResourcesChange(LwM2mPath...)
+     */
+    public void fireResourceChange(int resourceId) {
+        fireResourcesChange(getResourcePath(resourceId));
+    }
+
+    /**
+     * @see #fireResourcesChange(LwM2mPath...)
+     */
+    public void fireResourceInstanceChange(int resourceId, int resourceInstanceId) {
+        fireResourcesChange(getResourceInstancePath(resourceId, resourceInstanceId));
+    }
+
+    protected LwM2mPath getResourcePath(int resourceId) {
+        return new LwM2mPath(getModel().id, getId(), resourceId);
+    }
+
+    protected LwM2mPath getResourceInstancePath(int resourceId, int resourceInstanceId) {
+        return new LwM2mPath(getModel().id, getId(), resourceId, resourceInstanceId);
+    }
+
     @Override
     public ReadResponse read(ServerIdentity identity) {
         List<LwM2mResource> resources = new ArrayList<>();
@@ -174,21 +200,15 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
     @Override
     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);
-        if (response.isSuccess()) {
-            LwM2mNode content = response.getContent();
-            if (content instanceof LwM2mMultipleResource) {
-                LwM2mMultipleResource multiresource = ((LwM2mMultipleResource) content);
-                Map<Integer, LwM2mResourceInstance> instances = new HashMap<>(multiresource.getInstances());
-                if (addIfAbsent || instances.containsKey(resourceInstanceId)) {
-                    instances.put(resourceInstanceId, value);
-                    return write(identity, true, resourceid,
-                            new LwM2mMultipleResource(resourceid, value.getType(), instances.values()));
-                }
+        if (!addIfAbsent) {
+            ReadResponse response = read(ServerIdentity.SYSTEM, resourceid, resourceInstanceId);
+            if (!response.isSuccess()) {
+                return WriteResponse.notFound();
             }
         }
-        return WriteResponse.notFound();
+        return write(identity, false, resourceid, new LwM2mMultipleResource(resourceid, value.getType(), value));
     }
 
     @Override
@@ -212,6 +232,14 @@ public class BaseInstanceEnabler implements LwM2mInstanceEnabler {
                 readResponse.getErrorMessage());
     }
 
+    @Override
+    public ObserveResponse observe(ServerIdentity identity, int resourceid, int resourceInstanceId) {
+        // Perform a read by default
+        ReadResponse readResponse = this.read(identity, resourceid, resourceInstanceId);
+        return new ObserveResponse(readResponse.getCode(), readResponse.getContent(), null, null,
+                readResponse.getErrorMessage());
+    }
+
     @Override
     public void onDelete(ServerIdentity identity) {
         // No default behavior
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 96e303ef..656bfafe 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
@@ -458,12 +458,14 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
                 return ObserveResponse.notFound();
 
             // check if the resource is readable.
-            if (path.isResource()) {
+            if (path.isResource() || path.isResourceInstance()) {
                 ResourceModel resourceModel = objectModel.resources.get(path.getResourceId());
                 if (resourceModel == null) {
                     return ObserveResponse.notFound();
                 } else if (!resourceModel.operations.isReadable()) {
                     return ObserveResponse.methodNotAllowed();
+                } else if (path.isResourceInstance() && !resourceModel.multiple) {
+                    return ObserveResponse.badRequest("invalid path : resource is not multiple");
                 }
             }
         }
@@ -528,8 +530,8 @@ public abstract class BaseObjectEnabler implements LwM2mObjectEnabler {
         transactionalListener.objectInstancesRemoved(this, instanceIds);
     }
 
-    protected void fireResourcesChanged(int instanceid, int... resourceIds) {
-        transactionalListener.resourceChanged(this, instanceid, resourceIds);
+    protected void fireResourcesChanged(LwM2mPath... paths) {
+        transactionalListener.resourceChanged(paths);
     }
 
     @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 493dc3ff..cce9633d 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
@@ -21,6 +21,7 @@ package org.eclipse.leshan.client.resource;
 import java.util.List;
 
 import org.eclipse.leshan.client.LwM2mClient;
+import org.eclipse.leshan.client.resource.listener.ResourceListener;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.core.Destroyable;
 import org.eclipse.leshan.core.Startable;
@@ -90,14 +91,14 @@ public interface LwM2mInstanceEnabler {
      * 
      * @param listener the handler to add, a <code>null</code> value is silently ignored
      */
-    void addResourceChangedListener(ResourceChangedListener listener);
+    void addResourceListener(ResourceListener listener);
 
     /**
      * Stops a callback handler from getting notified about changes to any of this LWM2M object instance's resources.
      * 
      * @param listener the handler to remove, a <code>null</code> value is silently ignored
      */
-    void removeResourceChangedListener(ResourceChangedListener listener);
+    void removeResourceListener(ResourceListener listener);
 
     /**
      * Gets values of all readable resources of this instance.
@@ -208,10 +209,20 @@ 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 resourceid the ID of the resource to set the value for
+     * @param resourceid the ID of the resource to observe
      */
     ObserveResponse observe(ServerIdentity identity, int resourceid);
 
+    /**
+     * Performs an observe register on one of this LWM2M object instance's resource instance.
+     * 
+     * @param identity the identity of the requester. This could be an internal call in this case
+     *        <code> identity == ServerIdentity.SYSTEM</code>.
+     * @param resourceid the ID of the resource to observe
+     * @param resourceInstanceId the ID of the resource instance to observe
+     */
+    ObserveResponse observe(ServerIdentity identity, int resourceid, int resourceInstanceId);
+
     /**
      * A callback called when this instance is deleted
      * 
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 de877f04..95da9c07 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
@@ -176,9 +176,9 @@ public class LwM2mObjectTree implements Startable, Stoppable, Destroyable {
         }
 
         @Override
-        public void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourcesIds) {
+        public void resourceChanged(LwM2mPath... paths) {
             for (ObjectsListener listener : listeners) {
-                listener.resourceChanged(object, instanceId, resourcesIds);
+                listener.resourceChanged(paths);
             }
         }
     }
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 ecdfe3d0..3d0d9f95 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
@@ -26,6 +26,7 @@ import java.util.Map;
 import java.util.Map.Entry;
 
 import org.eclipse.leshan.client.LwM2mClient;
+import org.eclipse.leshan.client.resource.listener.ResourceListener;
 import org.eclipse.leshan.client.servers.ServerIdentity;
 import org.eclipse.leshan.client.servers.ServersInfoExtractor;
 import org.eclipse.leshan.core.Destroyable;
@@ -59,6 +60,8 @@ import org.eclipse.leshan.core.response.ExecuteResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
 import org.eclipse.leshan.core.response.WriteResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * A specific implementation of {@link LwM2mObjectEnabler} which matchs each LWM2M instance concept to a
@@ -69,6 +72,8 @@ import org.eclipse.leshan.core.response.WriteResponse;
  */
 public class ObjectEnabler extends BaseObjectEnabler implements Destroyable, Startable, Stoppable {
 
+    private static Logger LOG = LoggerFactory.getLogger(DummyInstanceEnabler.class);
+
     protected Map<Integer, LwM2mInstanceEnabler> instances;
     protected LwM2mInstanceEnablerFactory instanceFactory;
     protected ContentFormat defaultContentFormat;
@@ -260,7 +265,12 @@ public class ObjectEnabler extends BaseObjectEnabler implements Destroyable, Sta
         }
 
         // Manage Resource case
-        return instance.observe(identity, path.getResourceId());
+        if (path.getResourceInstanceId() == null) {
+            return instance.observe(identity, path.getResourceId());
+        }
+
+        // Manage Resource Instance case
+        return instance.observe(identity, path.getResourceId(), path.getResourceInstanceId());
     }
 
     @Override
@@ -406,14 +416,35 @@ public class ObjectEnabler extends BaseObjectEnabler implements Destroyable, Sta
     }
 
     protected void listenInstance(LwM2mInstanceEnabler instance, final int instanceId) {
-        instance.addResourceChangedListener(new ResourceChangedListener() {
+        instance.addResourceListener(new ResourceListener() {
             @Override
-            public void resourcesChanged(int... resourceIds) {
-                fireResourcesChanged(instanceId, resourceIds);
+            public void resourceChanged(LwM2mPath... paths) {
+                for (LwM2mPath path : paths) {
+                    if (!isValid(instanceId, path)) {
+                        LOG.warn("InstanceEnabler (%d) of object (%d) try to raise a change of %s which seems invalid.",
+                                instanceId, getId(), path);
+                    }
+                }
+                fireResourcesChanged(paths);
             }
         });
     }
 
+    protected boolean isValid(int instanceId, LwM2mPath pathToValidate) {
+        if (!pathToValidate.isResource() || !pathToValidate.isResourceInstance())
+            return false;
+
+        if (pathToValidate.getObjectId() != getId()) {
+            return false;
+        }
+
+        if (pathToValidate.getObjectInstanceId() != id) {
+            return false;
+        }
+
+        return true;
+    }
+
     @Override
     public ContentFormat getDefaultEncodingFormat(DownlinkRequest<?> request) {
         return defaultContentFormat;
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ResourceChangedListener.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ResourceChangedListener.java
deleted file mode 100644
index 2e753608..00000000
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/ResourceChangedListener.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/*******************************************************************************
- * Copyright (c) 2015 Sierra Wireless and others.
- * 
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the Eclipse Public License v2.0
- * and Eclipse Distribution License v1.0 which accompany this distribution.
- * 
- * The Eclipse Public License is available at
- *    http://www.eclipse.org/legal/epl-v20.html
- * and the Eclipse Distribution License is available at
- *    http://www.eclipse.org/org/documents/edl-v10.html.
- *
- * Contributors:
- *     Sierra Wireless - initial API and implementation
- *******************************************************************************/
-package org.eclipse.leshan.client.resource;
-
-public interface ResourceChangedListener {
-    void resourcesChanged(int... resourceIds);
-}
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 6b08fe21..b576e653 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
@@ -18,15 +18,19 @@
  *******************************************************************************/
 package org.eclipse.leshan.client.resource;
 
+import java.util.ArrayList;
 import java.util.Collections;
 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.client.servers.ServerIdentity;
 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.LwM2mPath;
 import org.eclipse.leshan.core.node.LwM2mResource;
 import org.eclipse.leshan.core.node.LwM2mResourceInstance;
 import org.eclipse.leshan.core.node.LwM2mSingleResource;
@@ -79,6 +83,7 @@ public class SimpleInstanceEnabler extends BaseInstanceEnabler {
     public WriteResponse write(ServerIdentity identity, boolean replace, int resourceid, LwM2mResource value) {
         // define new Value
         LwM2mResource newValue;
+        List<LwM2mPath> newInstances = new ArrayList<>();
         if (value instanceof LwM2mMultipleResource && !replace) {
             // This is the special case of multiple instance resource where we do not replace the resource instances but
             // we
@@ -86,7 +91,14 @@ public class SimpleInstanceEnabler extends BaseInstanceEnabler {
             LwM2mMultipleResource multipleResource = (LwM2mMultipleResource) resources.get(resourceid);
             if (multipleResource != null) {
                 Map<Integer, LwM2mResourceInstance> mergedInstances = new HashMap<>(multipleResource.getInstances());
-                mergedInstances.putAll(value.getInstances());
+                // try to detect resource instance changes
+                for (Entry<Integer, LwM2mResourceInstance> entry : ((LwM2mMultipleResource) value).getInstances()
+                        .entrySet()) {
+                    LwM2mResourceInstance previous = mergedInstances.put(entry.getKey(), entry.getValue());
+                    if (!entry.getValue().equals(previous)) {
+                        newInstances.add(getResourceInstancePath(resourceid, entry.getKey()));
+                    }
+                }
                 newValue = new LwM2mMultipleResource(resourceid, value.getType(), mergedInstances.values());
             } else {
                 newValue = value;
@@ -97,8 +109,12 @@ public class SimpleInstanceEnabler extends BaseInstanceEnabler {
 
         LwM2mResource previousValue = resources.put(resourceid, newValue);
 
-        if (!newValue.equals(previousValue))
-            fireResourcesChange(resourceid);
+        // Either we raise resource instance changes or resource change.
+        if (!newInstances.isEmpty()) {
+            fireResourcesChange(newInstances.toArray(new LwM2mPath[newInstances.size()]));
+        } else if (!newValue.equals(previousValue)) {
+            fireResourceChange(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 007d445b..acb6be1b 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
@@ -16,11 +16,10 @@
 package org.eclipse.leshan.client.resource;
 
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 
 import org.eclipse.leshan.client.resource.listener.ObjectListener;
+import org.eclipse.leshan.core.node.LwM2mPath;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -37,7 +36,7 @@ public class TransactionalObjectListener implements ObjectListener {
     protected int currentLevel = 0;
     protected List<Integer> instancesAdded = new ArrayList<>();
     protected List<Integer> instancesRemoved = new ArrayList<>();
-    protected Map<Integer, List<Integer>> resourcesChangedByInstance = new HashMap<>();
+    protected List<LwM2mPath> resourcesChanged = new ArrayList<>();
 
     protected LwM2mObjectEnabler objectEnabler;
     protected List<ObjectListener> innerListeners = new ArrayList<ObjectListener>();
@@ -87,7 +86,7 @@ public class TransactionalObjectListener implements ObjectListener {
             }
             instancesAdded.clear();
             instancesRemoved.clear();
-            resourcesChangedByInstance.clear();
+            resourcesChanged.clear();
             currentLevel = 0;
         }
     }
@@ -101,10 +100,8 @@ public class TransactionalObjectListener implements ObjectListener {
             fireObjectInstancesAdded(toIntArray(instancesAdded));
         if (!instancesRemoved.isEmpty())
             fireObjectInstancesRemoved(toIntArray(instancesRemoved));
-
-        for (Map.Entry<Integer, List<Integer>> entry : resourcesChangedByInstance.entrySet()) {
-            fireResourcesChanged(entry.getKey(), toIntArray(entry.getValue()));
-        }
+        if (!resourcesChanged.isEmpty())
+            fireResourcesChanged(resourcesChanged.toArray(new LwM2mPath[resourcesChanged.size()]));
     }
 
     @Override
@@ -140,17 +137,14 @@ public class TransactionalObjectListener implements ObjectListener {
     }
 
     @Override
-    public void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourcesIds) {
+    public void resourceChanged(LwM2mPath... paths) {
         if (!inTransaction()) {
-            fireResourcesChanged(instanceId, resourcesIds);
+            fireResourcesChanged(paths);
         } else {
-            List<Integer> resourcesChanged = resourcesChangedByInstance.get(instanceId);
-            if (resourcesChanged == null) {
-                resourcesChanged = new ArrayList<Integer>();
-                resourcesChangedByInstance.put(instanceId, resourcesChanged);
-            }
-            for (int resourceId : resourcesIds) {
-                resourcesChanged.add(resourceId);
+            for (LwM2mPath path : paths) {
+                if (!resourcesChanged.contains(path)) {
+                    resourcesChanged.add(path);
+                }
             }
         }
     }
@@ -175,9 +169,9 @@ public class TransactionalObjectListener implements ObjectListener {
         }
     }
 
-    protected void fireResourcesChanged(int instanceid, int... resourceIds) {
+    protected void fireResourcesChanged(LwM2mPath... path) {
         for (ObjectListener listener : innerListeners) {
-            listener.resourceChanged(objectEnabler, instanceid, resourceIds);
+            listener.resourceChanged(path);
         }
     }
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ObjectsListenerAdapter.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ObjectsListenerAdapter.java
index e37c2000..c4b6622d 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ObjectsListenerAdapter.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ObjectsListenerAdapter.java
@@ -16,6 +16,7 @@
 package org.eclipse.leshan.client.resource.listener;
 
 import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
+import org.eclipse.leshan.core.node.LwM2mPath;
 
 public class ObjectsListenerAdapter implements ObjectsListener {
 
@@ -36,6 +37,6 @@ public class ObjectsListenerAdapter implements ObjectsListener {
     }
 
     @Override
-    public void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourcesIds) {
+    public void resourceChanged(LwM2mPath... paths) {
     }
 }
diff --git a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ResourceListener.java b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ResourceListener.java
index 1494574b..97a44a2d 100644
--- a/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ResourceListener.java
+++ b/leshan-client-core/src/main/java/org/eclipse/leshan/client/resource/listener/ResourceListener.java
@@ -15,8 +15,8 @@
  *******************************************************************************/
 package org.eclipse.leshan.client.resource.listener;
 
-import org.eclipse.leshan.client.resource.LwM2mObjectEnabler;
+import org.eclipse.leshan.core.node.LwM2mPath;
 
 public interface ResourceListener {
-    void resourceChanged(LwM2mObjectEnabler object, int instanceId, int... resourcesIds);
+    void resourceChanged(LwM2mPath... paths);
 }
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 c29eb38e..72a78995 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
@@ -40,7 +40,7 @@ public class MyDevice extends BaseInstanceEnabler implements Destroyable {
         timer.schedule(new TimerTask() {
             @Override
             public void run() {
-                fireResourcesChange(13);
+                fireResourceChange(13);
             }
         }, 5000, 5000);
     }
@@ -122,11 +122,11 @@ public class MyDevice extends BaseInstanceEnabler implements Destroyable {
             return WriteResponse.notFound();
         case 14:
             setUtcOffset((String) value.getValue());
-            fireResourcesChange(resourceid);
+            fireResourceChange(resourceid);
             return WriteResponse.success();
         case 15:
             setTimezone((String) value.getValue());
-            fireResourcesChange(resourceid);
+            fireResourceChange(resourceid);
             return WriteResponse.success();
         default:
             return super.write(identity, replace, resourceid, value);
diff --git a/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyLocation.java b/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyLocation.java
index 0c6fe8a6..abf06fed 100644
--- a/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyLocation.java
+++ b/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/MyLocation.java
@@ -82,13 +82,13 @@ public class MyLocation extends BaseInstanceEnabler {
     private void moveLatitude(float delta) {
         latitude = latitude + delta * scaleFactor;
         timestamp = new Date();
-        fireResourcesChange(0, 5);
+        fireResourcesChange(getResourcePath(0), getResourcePath(5));
     }
 
     private void moveLongitude(float delta) {
         longitude = longitude + delta * scaleFactor;
         timestamp = new Date();
-        fireResourcesChange(1, 5);
+        fireResourcesChange(getResourcePath(1), getResourcePath(5));
     }
 
     public float getLatitude() {
diff --git a/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/RandomTemperatureSensor.java b/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/RandomTemperatureSensor.java
index 8a1fa3f5..281ee66c 100644
--- a/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/RandomTemperatureSensor.java
+++ b/leshan-client-demo/src/main/java/org/eclipse/leshan/client/demo/RandomTemperatureSensor.java
@@ -87,9 +87,9 @@ public class RandomTemperatureSensor extends BaseInstanceEnabler implements Dest
         currentTemp += delta;
         Integer changedResource = adjustMinMaxMeasuredValue(currentTemp);
         if (changedResource != null) {
-            fireResourcesChange(SENSOR_VALUE, changedResource);
+            fireResourcesChange(getResourcePath(SENSOR_VALUE), getResourcePath(changedResource));
         } else {
-            fireResourcesChange(SENSOR_VALUE);
+            fireResourceChange(SENSOR_VALUE);
         }
     }
 
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 063a3068..3f60300a 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
@@ -38,9 +38,6 @@ public class CancelObservationRequest extends AbstractSimpleDownlinkRequest<Canc
         super(observation.getPath(), null);
         if (getPath().isRoot())
             throw new InvalidRequestException("Observe request cannot target root path");
-        if (getPath().isResourceInstance())
-            throw new InvalidRequestException(
-                    "Observe request cannot target resource instance path: %s (not yet implemented)", getPath());
         this.observation = observation;
     }
 
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 fd5900bb..0599e0e6 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
@@ -98,6 +98,30 @@ public class ObserveRequest extends AbstractSimpleDownlinkRequest<ObserveRespons
         this(format, new LwM2mPath(objectId, objectInstanceId, resourceId), null, null);
     }
 
+    /**
+     * Creates a request for observing future changes of a specific resource instance of a client.
+     * 
+     * @param objectId the object ID of the resource
+     * @param objectInstanceId the object instance ID
+     * @param resourceId the (individual) resource's ID
+     */
+    public ObserveRequest(int objectId, int objectInstanceId, int resourceId, int resourceInstanceId) {
+        this(null, new LwM2mPath(objectId, objectInstanceId, resourceId, resourceInstanceId), null, null);
+    }
+
+    /**
+     * Creates a request for observing future changes of a specific resource instance of a client.
+     *
+     * @param format the desired format for the response (TLV, JSON, TEXT or OPAQUE)
+     * @param objectId the object ID of the resource
+     * @param objectInstanceId the object instance ID
+     * @param resourceId the (individual) resource's ID
+     */
+    public ObserveRequest(ContentFormat format, int objectId, int objectInstanceId, int resourceId,
+            int resourceInstanceId) {
+        this(format, new LwM2mPath(objectId, objectInstanceId, resourceId, resourceInstanceId), null, null);
+    }
+
     /**
      * Creates a request for observing future changes of a particular LWM2M node (object, object instance or resource).
      * 
@@ -150,9 +174,6 @@ public class ObserveRequest extends AbstractSimpleDownlinkRequest<ObserveRespons
         super(target, coapRequest);
         if (target.isRoot())
             throw new InvalidRequestException("Observe request cannot target root path");
-        if (target.isResourceInstance())
-            throw new InvalidRequestException(
-                    "Observe request cannot target resource instance path: %s (not yet implemented)", target);
 
         this.format = format;
         if (context == null || context.isEmpty())
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 f2b685aa..8fdfd90f 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
@@ -38,6 +38,11 @@ public class WriteAttributesRequest extends AbstractSimpleDownlinkRequest<WriteA
         this(new LwM2mPath(objectId, objectInstanceId, resourceId), attributes, null);
     }
 
+    public WriteAttributesRequest(int objectId, int objectInstanceId, int resourceId, int resourceInstanceId,
+            AttributeSet attributes) throws InvalidRequestException {
+        this(new LwM2mPath(objectId, objectInstanceId, resourceId, resourceInstanceId), attributes, null);
+    }
+
     public WriteAttributesRequest(String path, AttributeSet attributes) {
         this(newPath(path), attributes, null);
     }
@@ -51,9 +56,6 @@ public class WriteAttributesRequest extends AbstractSimpleDownlinkRequest<WriteA
         super(path, coapRequest);
         if (path.isRoot())
             throw new InvalidRequestException("WriteAttributes request cannot target root path");
-        if (path.isResourceInstance())
-            throw new InvalidRequestException(
-                    "WriteAttributes request cannot target resource instance path: %s (not yet implemented)", path);
 
         if (attributes == null)
             throw new InvalidRequestException("attributes are mandatory for %s", path);
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 0663a4b2..73d25061 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
@@ -389,12 +389,19 @@ public class WriteRequest extends AbstractSimpleDownlinkRequest<WriteResponse> {
             throw new InvalidRequestException("new node value is mandatory for %s", target);
 
         // Validate Mode
-        if (getPath().isResource() && mode == Mode.UPDATE
-                && (node instanceof LwM2mSingleResource || node instanceof LwM2mResourceInstance))
-            throw new InvalidRequestException("Invalid mode for '%s': update is not allowed on resource", target);
+        if (mode == Mode.UPDATE) {
+            if (target.isResource() && node instanceof LwM2mSingleResource || target.isResourceInstance())
+                throw new InvalidRequestException("Invalid mode for '%s': update is not allowed on %s", target,
+                        target.isResource() ? "single resource" : "resource instance");
+        }
 
         // Validate node and path coherence
-        if (getPath().isResource()) {
+        if (getPath().isResourceInstance()) {
+            if (!(node instanceof LwM2mResourceInstance)) {
+                throw new InvalidRequestException("path '%s' and node type '%s' do not match", target,
+                        node.getClass().getSimpleName());
+            }
+        } else if (getPath().isResource()) {
             if (!(node instanceof LwM2mResource)) {
                 throw new InvalidRequestException("path '%s' and node type '%s' do not match", target,
                         node.getClass().getSimpleName());


Test patch:
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java
index 249ec52b..200e115f 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/observe/ObserveTest.java
@@ -19,6 +19,7 @@
 
 package org.eclipse.leshan.integration.tests.observe;
 
+import static org.eclipse.leshan.integration.tests.util.IntegrationTestHelper.*;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.core.Is.is;
 import static org.hamcrest.core.IsInstanceOf.instanceOf;
@@ -32,6 +33,7 @@ import org.eclipse.leshan.core.model.StaticModel;
 import org.eclipse.leshan.core.node.LwM2mObject;
 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.node.codec.LwM2mValueChecker;
 import org.eclipse.leshan.core.node.codec.json.LwM2mNodeJsonEncoder;
@@ -41,6 +43,7 @@ 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.WriteRequest;
+import org.eclipse.leshan.core.request.WriteRequest.Mode;
 import org.eclipse.leshan.core.response.CancelObservationResponse;
 import org.eclipse.leshan.core.response.LwM2mResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
@@ -105,6 +108,146 @@ public class ObserveTest {
         assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
     }
 
+    @Test
+    public void can_observe_resource_instance() throws InterruptedException {
+        TestObservationListener listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+
+        // multi instance string
+        String expectedPath = "/" + TEST_OBJECT_ID + "/0/" + STRING_RESOURCE_INSTANCE_ID + "/0";
+        ObserveResponse observeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ObserveRequest(expectedPath));
+        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(expectedPath, observation.getPath().toString());
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // write a new value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(), new WriteRequest(Mode.REPLACE,
+                ContentFormat.TLV, expectedPath, LwM2mResourceInstance.newStringInstance(0, "a new string")));
+
+        // verify result
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertTrue(listener.receivedNotify().get());
+        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"), listener.getResponse().getContent());
+        assertNotNull(listener.getResponse().getCoapResponse());
+        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+    }
+
+    @Test
+    public void can_observe_resource_instance_then_passive_cancel() throws InterruptedException {
+        TestObservationListener listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+
+        // multi instance string
+        String expectedPath = "/" + TEST_OBJECT_ID + "/0/" + STRING_RESOURCE_INSTANCE_ID + "/0";
+        ObserveResponse observeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ObserveRequest(expectedPath));
+        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(expectedPath, observation.getPath().toString());
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // write a new value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(), new WriteRequest(Mode.REPLACE,
+                ContentFormat.TLV, expectedPath, LwM2mResourceInstance.newStringInstance(0, "a new string")));
+
+        // verify result
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertTrue(listener.receivedNotify().get());
+        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"), listener.getResponse().getContent());
+        assertNotNull(listener.getResponse().getCoapResponse());
+
+        // cancel observation : passive way
+        helper.server.getObservationService().cancelObservation(observation);
+        observations = helper.server.getObservationService().getObservations(helper.getCurrentRegistration());
+        assertTrue("Observation should be removed", observations.isEmpty());
+
+        // write device timezone
+        listener.reset();
+        writeResponse = helper.server.send(helper.getCurrentRegistration(), new WriteRequest(Mode.REPLACE,
+                ContentFormat.TLV, expectedPath, LwM2mResourceInstance.newStringInstance(0, "a another new string")));
+
+        // verify result
+        listener.waitForNotification(1000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertFalse("Observation should be cancelled", listener.receivedNotify().get());
+    }
+
+    @Test
+    public void can_observe_resource_instance_then_active_cancel() throws InterruptedException {
+        TestObservationListener listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+
+        // multi instance string
+        String expectedPath = "/" + TEST_OBJECT_ID + "/0/" + STRING_RESOURCE_INSTANCE_ID + "/0";
+        ObserveResponse observeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ObserveRequest(expectedPath));
+        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(expectedPath, observation.getPath().toString());
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // write a new value
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(), new WriteRequest(Mode.REPLACE,
+                ContentFormat.TLV, expectedPath, LwM2mResourceInstance.newStringInstance(0, "a new string")));
+
+        // verify result
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertTrue(listener.receivedNotify().get());
+        assertEquals(LwM2mResourceInstance.newStringInstance(0, "a new string"), listener.getResponse().getContent());
+        assertNotNull(listener.getResponse().getCoapResponse());
+
+        // cancel observation : active way
+        CancelObservationResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new CancelObservationRequest(observation));
+        assertTrue(response.isSuccess());
+        assertEquals(ResponseCode.CONTENT, response.getCode());
+        assertEquals("a new string", ((LwM2mResourceInstance) response.getContent()).getValue());
+        // active cancellation does not remove observation from store : it should be done manually using
+        // ObservationService().cancelObservation(observation)
+        observations = helper.server.getObservationService().getObservations(helper.getCurrentRegistration());
+        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("Observation should still be there", observations.contains(observation));
+
+        // write device timezone
+        listener.reset();
+        writeResponse = helper.server.send(helper.getCurrentRegistration(), new WriteRequest(Mode.REPLACE,
+                ContentFormat.TLV, expectedPath, LwM2mResourceInstance.newStringInstance(0, "a another new string")));
+
+        // verify result
+        listener.waitForNotification(1000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertFalse("Observation should be cancelled", listener.receivedNotify().get());
+    }
+
     @Test
     public void can_observe_resource_then_passive_cancel() throws InterruptedException {
         TestObservationListener listener = new TestObservationListener();