Repository: eclipse/leshan
Pull request #662 (2019-03-07 16:21:47)

Add support of active cancel observation request at server side.


Patch:
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java
index 4c26adde..6c56e2c4 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/observation/Observation.java
@@ -21,6 +21,7 @@ import java.util.HashMap;
 import java.util.Map;
 
 import org.eclipse.leshan.core.node.LwM2mPath;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.util.Hex;
 
 /**
@@ -30,6 +31,7 @@ public class Observation {
 
     private final byte[] id;
     private final LwM2mPath path;
+    private final ContentFormat contentFormat;
     private final String registrationId;
     private final Map<String, String> context;
 
@@ -39,11 +41,14 @@ public class Observation {
      * @param id token identifier of the observation
      * @param registrationId client's unique registration identifier.
      * @param path resource path for which the observation is set.
+     * @param contentFormat contentFormat used to read the resource (could be null).
      * @param context additional information relative to this observation.
      */
-    public Observation(byte[] id, String registrationId, LwM2mPath path, Map<String, String> context) {
+    public Observation(byte[] id, String registrationId, LwM2mPath path, ContentFormat contentFormat,
+            Map<String, String> context) {
         this.id = id;
         this.path = path;
+        this.contentFormat = contentFormat;
         this.registrationId = registrationId;
         if (context != null)
             this.context = Collections.unmodifiableMap(new HashMap<>(context));
@@ -77,6 +82,15 @@ public class Observation {
         return path;
     }
 
+    /**
+     * Gets the requested contentFormat (could be null).
+     * 
+     * @return the resource path
+     */
+    public ContentFormat getContentFormat() {
+        return contentFormat;
+    }
+
     /**
      * @return the contextual information relative to this observation.
      */
@@ -86,8 +100,8 @@ public class Observation {
 
     @Override
     public String toString() {
-        return String.format("Observation [id=%s, path=%s, registrationId=%s, context=%s]", Hex.encodeHexString(id),
-                path, registrationId, context);
+        return String.format("Observation [id=%s, path=%s, registrationId=%s, contentFormat=%s context=%s]",
+                Hex.encodeHexString(id), path, registrationId, contentFormat, context);
     }
 
     @Override
@@ -129,5 +143,4 @@ public class Observation {
             return false;
         return true;
     }
-
 }
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
new file mode 100644
index 00000000..4a68904a
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/request/CancelObservationRequest.java
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright (c) 2013-2015 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v10.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.leshan.core.request;
+
+import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.request.exception.InvalidRequestException;
+import org.eclipse.leshan.core.response.CancelObservationResponse;
+import org.eclipse.leshan.util.Hex;
+
+/**
+ * A Lightweight M2M request for actively cancel an observation.
+ * <p>
+ * At server side this will not remove the observation from the observation store, to do it you need to use
+ * {@code ObservationService#cancelObservation()}
+ * </p>
+ */
+public class CancelObservationRequest extends AbstractDownlinkRequest<CancelObservationResponse> {
+
+    private final Observation observation;
+
+    /**
+     * @param observation the observation to cancel actively
+     */
+    public CancelObservationRequest(Observation observation) {
+        super(observation.getPath());
+        if (getPath().isRoot())
+            throw new InvalidRequestException("Observe request cannot target root path");
+        this.observation = observation;
+    }
+
+    public Observation getObservation() {
+        return observation;
+    }
+
+    @Override
+    public void accept(DownlinkRequestVisitor visitor) {
+        visitor.visit(this);
+    }
+
+    @Override
+    public final String toString() {
+        return String.format("CancelObservation [path=%s token=%s]", getPath(),
+                Hex.encodeHexString(observation.getId()));
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = super.hashCode();
+        result = prime * result + ((observation == null) ? 0 : observation.hashCode());
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (!super.equals(obj))
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        CancelObservationRequest other = (CancelObservationRequest) obj;
+        if (observation == null) {
+            if (other.observation != null)
+                return false;
+        } else if (!observation.equals(other.observation))
+            return false;
+        return true;
+    }
+}
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 0be64dd4..b4104b8f 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
@@ -53,6 +53,10 @@ public class DownLinkRequestVisitorAdapter implements DownlinkRequestVisitor {
     public void visit(ObserveRequest request) {
     }
 
+    @Override
+    public void visit(CancelObservationRequest request) {
+    }
+
     @Override
     public void visit(BootstrapWriteRequest 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 d8f74f5b..02dd4f18 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
@@ -35,6 +35,8 @@ public interface DownlinkRequestVisitor {
 
     void visit(ObserveRequest request);
 
+    void visit(CancelObservationRequest request);
+
     void visit(BootstrapWriteRequest request);
 
     void visit(BootstrapDeleteRequest request);
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java
new file mode 100644
index 00000000..fbd1d5a2
--- /dev/null
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/CancelObservationResponse.java
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright (c) 2013-2015 Sierra Wireless and others.
+ * 
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * and Eclipse Distribution License v1.0 which accompany this distribution.
+ * 
+ * The Eclipse Public License is available at
+ *    http://www.eclipse.org/legal/epl-v10.html
+ * and the Eclipse Distribution License is available at
+ *    http://www.eclipse.org/org/documents/edl-v10.html.
+ * 
+ * Contributors:
+ *     Sierra Wireless - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.leshan.core.response;
+
+import java.util.List;
+
+import org.eclipse.leshan.ResponseCode;
+import org.eclipse.leshan.core.node.LwM2mNode;
+import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
+import org.eclipse.leshan.core.observation.Observation;
+
+public class CancelObservationResponse extends ObserveResponse {
+
+    public CancelObservationResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
+            Observation observation, String errorMessage) {
+        super(code, content, timestampedValues, observation, errorMessage);
+    }
+
+    public CancelObservationResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
+            Observation observation, String errorMessage, Object coapResponse) {
+        super(code, content, timestampedValues, observation, errorMessage, coapResponse);
+    }
+
+    @Override
+    public String toString() {
+        if (errorMessage != null)
+            return String.format("CancelObservationResponse [code=%s, errormessage=%s]", code, errorMessage);
+        else if (timestampedValues != null)
+            return String.format(
+                    "CancelObservationResponse [code=%s, content=%s, observation=%s, timestampedValues= %d nodes]",
+                    code, content, observation, timestampedValues.size());
+        else
+            return String.format("CancelObservationResponse [code=%s, content=%s, observation=%s]", code, content,
+                    observation);
+    }
+
+    // Syntactic sugar static constructors :
+
+    public static CancelObservationResponse success(LwM2mNode content) {
+        return new CancelObservationResponse(ResponseCode.CONTENT, content, null, null, null);
+    }
+
+    public static CancelObservationResponse success(List<TimestampedLwM2mNode> timestampedValues) {
+        return new CancelObservationResponse(ResponseCode.CONTENT, null, timestampedValues, null, null);
+    }
+
+    public static CancelObservationResponse badRequest(String errorMessage) {
+        return new CancelObservationResponse(ResponseCode.BAD_REQUEST, null, null, null, errorMessage);
+    }
+
+    public static CancelObservationResponse notFound() {
+        return new CancelObservationResponse(ResponseCode.NOT_FOUND, null, null, null, null);
+    }
+
+    public static CancelObservationResponse unauthorized() {
+        return new CancelObservationResponse(ResponseCode.UNAUTHORIZED, null, null, null, null);
+    }
+
+    public static CancelObservationResponse methodNotAllowed() {
+        return new CancelObservationResponse(ResponseCode.METHOD_NOT_ALLOWED, null, null, null, null);
+    }
+
+    public static CancelObservationResponse notAcceptable() {
+        return new CancelObservationResponse(ResponseCode.NOT_ACCEPTABLE, null, null, null, null);
+    }
+
+    public static CancelObservationResponse internalServerError(String errorMessage) {
+        return new CancelObservationResponse(ResponseCode.INTERNAL_SERVER_ERROR, null, null, null, errorMessage);
+    }
+}
diff --git a/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java
index 218b7342..a5d39a66 100644
--- a/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java
+++ b/leshan-core/src/main/java/org/eclipse/leshan/core/response/ObserveResponse.java
@@ -30,8 +30,8 @@ import org.eclipse.leshan.core.request.exception.InvalidResponseException;
  */
 public class ObserveResponse extends ReadResponse {
 
-    private final Observation observation;
-    private final List<TimestampedLwM2mNode> timestampedValues;
+    protected final Observation observation;
+    protected final List<TimestampedLwM2mNode> timestampedValues;
 
     public ObserveResponse(ResponseCode code, LwM2mNode content, List<TimestampedLwM2mNode> timestampedValues,
             Observation observation, String errorMessage) {
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/ObserveUtil.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/ObserveUtil.java
index caa12a4c..30f65bfe 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/ObserveUtil.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/ObserveUtil.java
@@ -22,6 +22,7 @@ import java.util.Map.Entry;
 import org.eclipse.californium.core.coap.Request;
 import org.eclipse.leshan.core.node.LwM2mPath;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.ObserveRequest;
 
 /**
@@ -60,7 +61,12 @@ public class ObserveUtil {
                 context.put(ctx.getKey(), ctx.getValue());
             }
         }
-        return new Observation(request.getToken().getBytes(), regId, new LwM2mPath(lwm2mPath), context);
+
+        ContentFormat contentFormat = null;
+        if (request.getOptions().hasAccept()) {
+            contentFormat = ContentFormat.fromCode(request.getOptions().getAccept());
+        }
+        return new Observation(request.getToken().getBytes(), regId, new LwM2mPath(lwm2mPath), contentFormat, context);
     }
 
     /**
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/CoapRequestBuilder.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/CoapRequestBuilder.java
index eaf99275..3028a2af 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/CoapRequestBuilder.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/CoapRequestBuilder.java
@@ -29,6 +29,7 @@ import org.eclipse.leshan.core.node.codec.LwM2mNodeEncoder;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
+import org.eclipse.leshan.core.request.CancelObservationRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
 import org.eclipse.leshan.core.request.DeleteRequest;
@@ -144,6 +145,16 @@ public class CoapRequestBuilder implements DownlinkRequestVisitor {
         coapRequest.setUserContext(ObserveUtil.createCoapObserveRequestContext(endpoint, registrationId, request));
     }
 
+    @Override
+    public void visit(CancelObservationRequest request) {
+        coapRequest = Request.newGet();
+        coapRequest.setObserveCancel();
+        coapRequest.setToken(request.getObservation().getId());
+        if (request.getObservation().getContentFormat() != null)
+            coapRequest.getOptions().setAccept(request.getObservation().getContentFormat().getCode());
+        setTarget(coapRequest, request.getPath());
+    }
+
     @Override
     public void visit(BootstrapWriteRequest request) {
         coapRequest = Request.newPut();
diff --git a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/LwM2mResponseBuilder.java b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/LwM2mResponseBuilder.java
index 7ad3f0da..80ce28d8 100644
--- a/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/LwM2mResponseBuilder.java
+++ b/leshan-server-cf/src/main/java/org/eclipse/leshan/server/californium/impl/LwM2mResponseBuilder.java
@@ -31,6 +31,7 @@ import org.eclipse.leshan.core.observation.Observation;
 import org.eclipse.leshan.core.request.BootstrapDeleteRequest;
 import org.eclipse.leshan.core.request.BootstrapFinishRequest;
 import org.eclipse.leshan.core.request.BootstrapWriteRequest;
+import org.eclipse.leshan.core.request.CancelObservationRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
 import org.eclipse.leshan.core.request.DeleteRequest;
@@ -46,6 +47,7 @@ import org.eclipse.leshan.core.request.exception.InvalidResponseException;
 import org.eclipse.leshan.core.response.BootstrapDeleteResponse;
 import org.eclipse.leshan.core.response.BootstrapFinishResponse;
 import org.eclipse.leshan.core.response.BootstrapWriteResponse;
+import org.eclipse.leshan.core.response.CancelObservationResponse;
 import org.eclipse.leshan.core.response.CreateResponse;
 import org.eclipse.leshan.core.response.DeleteResponse;
 import org.eclipse.leshan.core.response.DiscoverResponse;
@@ -229,6 +231,35 @@ public class LwM2mResponseBuilder<T extends LwM2mResponse> implements DownlinkRe
         }
     }
 
+    @Override
+    public void visit(CancelObservationRequest request) {
+        if (coapResponse.isError()) {
+            // handle error response:
+            lwM2mresponse = new CancelObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), null, null, null,
+                    coapResponse.getPayloadString(), coapResponse);
+        } else if (coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CONTENT
+                // This is for backward compatibility, when the spec say notification used CHANGED code
+                || coapResponse.getCode() == org.eclipse.californium.core.coap.CoAP.ResponseCode.CHANGED) {
+            // handle success response:
+            LwM2mNode content = decodeCoapResponse(request.getPath(), coapResponse, request,
+                    registration.getEndpoint());
+            if (coapResponse.getOptions().hasObserve()) {
+                // observe request successful
+                Observation observation = ObserveUtil.createLwM2mObservation(coapRequest);
+                observationService.addObservation(registration, observation);
+                // add the observation to an ObserveResponse instance
+                lwM2mresponse = new CancelObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), content,
+                        null, observation, null, coapResponse);
+            } else {
+                lwM2mresponse = new CancelObservationResponse(toLwM2mResponseCode(coapResponse.getCode()), content,
+                        null, null, null, coapResponse);
+            }
+        } else {
+            // handle unexpected response:
+            handleUnexpectedResponseCode(registration.getEndpoint(), request, coapResponse);
+        }
+    }
+
     @Override
     public void visit(BootstrapWriteRequest request) {
         if (coapResponse.isError()) {
diff --git a/leshan-server-cluster/src/main/java/org/eclipse/leshan/server/cluster/serialization/DownlinkRequestSerDes.java b/leshan-server-cluster/src/main/java/org/eclipse/leshan/server/cluster/serialization/DownlinkRequestSerDes.java
index 01bc6dd5..0a644349 100644
--- a/leshan-server-cluster/src/main/java/org/eclipse/leshan/server/cluster/serialization/DownlinkRequestSerDes.java
+++ b/leshan-server-cluster/src/main/java/org/eclipse/leshan/server/cluster/serialization/DownlinkRequestSerDes.java
@@ -22,6 +22,7 @@ import org.eclipse.leshan.core.attributes.AttributeSet;
 import org.eclipse.leshan.core.node.LwM2mNode;
 import org.eclipse.leshan.core.node.LwM2mObjectInstance;
 import org.eclipse.leshan.core.node.LwM2mResource;
+import org.eclipse.leshan.core.request.CancelObservationRequest;
 import org.eclipse.leshan.core.request.ContentFormat;
 import org.eclipse.leshan.core.request.CreateRequest;
 import org.eclipse.leshan.core.request.DeleteRequest;
@@ -107,6 +108,11 @@ public class DownlinkRequestSerDes {
                 if (request.getContentFormat() != null)
                     o.add("contentFormat", request.getContentFormat().getCode());
             }
+
+            @Override
+            public void visit(CancelObservationRequest request) {
+                throw new UnsupportedOperationException("not implemented yet");
+            }
         });
         return o;
     }


Test patch:
diff --git a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/ObserveTest.java b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/ObserveTest.java
index 15a1b316..9bc145c7 100644
--- a/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/ObserveTest.java
+++ b/leshan-integration-tests/src/test/java/org/eclipse/leshan/integration/tests/ObserveTest.java
@@ -50,10 +50,12 @@ import org.eclipse.leshan.core.node.TimestampedLwM2mNode;
 import org.eclipse.leshan.core.node.codec.DefaultLwM2mValueConverter;
 import org.eclipse.leshan.core.node.codec.json.LwM2mNodeJsonEncoder;
 import org.eclipse.leshan.core.observation.Observation;
+import org.eclipse.leshan.core.request.CancelObservationRequest;
 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.response.CancelObservationResponse;
 import org.eclipse.leshan.core.response.LwM2mResponse;
 import org.eclipse.leshan.core.response.ObserveResponse;
 import org.eclipse.leshan.core.response.ReadResponse;
@@ -118,6 +120,111 @@ public class ObserveTest {
         assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
     }
 
+    @Test
+    public void can_observe_resource_then_passive_cancel() throws InterruptedException {
+        TestObservationListener listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+
+        // observe device timezone
+        ObserveResponse observeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ObserveRequest(3, 0, 15));
+        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/15", observation.getPath().toString());
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // write device timezone
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+
+        // verify result
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertTrue(listener.receivedNotify().get());
+        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"), listener.getResponse().getContent());
+        assertNotNull(listener.getResponse().getCoapResponse());
+        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+
+        // 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(3, 0, 15, "Europe/London"));
+
+        // 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_active_cancel() throws InterruptedException {
+        TestObservationListener listener = new TestObservationListener();
+        helper.server.getObservationService().addListener(listener);
+
+        // observe device timezone
+        ObserveResponse observeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new ObserveRequest(3, 0, 15));
+        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/15", observation.getPath().toString());
+        assertEquals(helper.getCurrentRegistration().getId(), observation.getRegistrationId());
+        Set<Observation> observations = helper.server.getObservationService()
+                .getObservations(helper.getCurrentRegistration());
+        assertTrue("We should have only on observation", observations.size() == 1);
+        assertTrue("New observation is not there", observations.contains(observation));
+
+        // write device timezone
+        LwM2mResponse writeResponse = helper.server.send(helper.getCurrentRegistration(),
+                new WriteRequest(3, 0, 15, "Europe/Paris"));
+
+        // verify result
+        listener.waitForNotification(2000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertTrue(listener.receivedNotify().get());
+        assertEquals(LwM2mSingleResource.newStringResource(15, "Europe/Paris"), listener.getResponse().getContent());
+        assertNotNull(listener.getResponse().getCoapResponse());
+        assertThat(listener.getResponse().getCoapResponse(), is(instanceOf(Response.class)));
+
+        // cancel observation : active way
+        CancelObservationResponse response = helper.server.send(helper.getCurrentRegistration(),
+                new CancelObservationRequest(observation));
+        assertTrue(response.isSuccess());
+        assertEquals(ResponseCode.CONTENT, response.getCode());
+        assertEquals("Europe/Paris", ((LwM2mSingleResource) 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(3, 0, 15, "Europe/London"));
+
+        // verify result
+        listener.waitForNotification(1000);
+        assertEquals(ResponseCode.CHANGED, writeResponse.getCode());
+        assertFalse("Observation should be cancelled", listener.receivedNotify.get());
+    }
+
     @Test
     public void can_observe_instance() throws InterruptedException {
         TestObservationListener listener = new TestObservationListener();
@@ -391,7 +498,7 @@ public class ObserveTest {
 
     private final class TestObservationListener implements ObservationListener {
 
-        private final CountDownLatch latch = new CountDownLatch(1);
+        private CountDownLatch latch = new CountDownLatch(1);
         private final AtomicBoolean receivedNotify = new AtomicBoolean();
         private ObserveResponse response;
         private Exception error;
@@ -436,5 +543,12 @@ public class ObserveTest {
         public void waitForNotification(long timeout) throws InterruptedException {
             latch.await(timeout, TimeUnit.MILLISECONDS);
         }
+
+        public void reset() {
+            latch = new CountDownLatch(1);
+            receivedNotify.set(false);
+            response = null;
+            error = null;
+        }
     }
 }
diff --git a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/impl/ObservationServiceTest.java b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/impl/ObservationServiceTest.java
index 35a38dc0..cadd619f 100644
--- a/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/impl/ObservationServiceTest.java
+++ b/leshan-server-cf/src/test/java/org/eclipse/leshan/server/californium/impl/ObservationServiceTest.java
@@ -148,7 +148,7 @@ public class ObservationServiceTest {
 
         store.put(coapRequest.getToken(), new org.eclipse.californium.core.observe.Observation(coapRequest, null));
 
-        Observation observation = new Observation(coapRequest.getToken().getBytes(), registrationId, target, null);
+        Observation observation = ObserveUtil.createLwM2mObservation(coapRequest);
         observationService.addObservation(registration, observation);
 
         return observation;