diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java index 191bc97579..7d4c4b75e5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java @@ -160,7 +160,13 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl if (!arguments.isEmpty()) withArguments = " with arguments " + arguments; log.info("Execute on Device resource /{}/{}/{} {}", getModel().id, getId(), resourceId, withArguments); - return ExecuteResponse.success(); + switch (resourceId) { + case 4: + case 5: + return ExecuteResponse.success(); + default: + return super.execute(identity, resourceId, arguments); + } } @Override diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java index f5b4538c1c..92e25a793e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java @@ -29,6 +29,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INST import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_3; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_4; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_5; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_8; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; @@ -90,26 +91,101 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT /** - * execute_resource_with_parameters (execute reboot after 60 seconds on device) - * Execute {"id":"3/0/4","value":60} + * execute_resource_with_parameters (execute reboot if digit = 5 on device) + * Execute {"id":"3/0/4","value":5} * {"result":"CHANGED"} */ @Test - public void testExecuteResourceWithParametersById_Result_CHANGED() throws Exception { + public void testExecuteResourceWithParametersSingleDigitValueById_Result_Ok() throws Exception { String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_4; - Object expectedValue = 60; + Object expectedValue = 5; String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); } + /** + * execute_resource_with_parameters (execute Factory Reset if digit = 2 -> after 60 seconds on device) + * Execute {"id":"3/0/5","value":"2='60'"} + + */ + @Test + public void testExecuteResourceWithParametersArgumentIdAndValueById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "2='60'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + /** + * execute_resource_with_parameters (execute Factory Reset with two arguments: + * digit 2 without a value and digit 0 with the link value on device) + * Execute {"id":"3/0/5","value":"2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsIncludingLinkById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + /** + * execute_resource_with_parameters (execute Factory Reset with multiple arguments without values) + * According to the OMA LwM2M execute arguments format, this represents ten arguments (digits 0-9), all without values. + * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "0,1,2,3,4,5,6,7,8,9"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + + /** + * execute_resource_with_parameters (execute Factory Reset after 60 seconds on device) + * Execute {"id":"3/0/5","value":"'60'"} + */ + @Test + public void testExecuteResourceWithParametersSingleDigitValueInvalidById_Result_BAD_REQUEST_Error_IntegerBetween_0_And_9_Expected() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "'60'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText()); + String expected = "Unable to parse Arguments [" + expectedValue + "] : Invalid digit ['] (an integer between 0 and 9 is expected)"; + String actual = rpcActualResult.get("error").asText(); + assertTrue(actual.contains(expected)); + } + + /** + * execute_resource_with_parameters (execute Bad with Unable to parse Arguments) + * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9,60"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsById_Result_BAD_REQUEST_Error_UnableParseArguments() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "0,1,2,3,4,5,6,7,8,9,60"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);; + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText()); + String expected = "Unable to parse Arguments [" + expectedValue + "] : [,] separator expected at index 21 after [0,1,2,3,4,5,6,7,8,9,6]"; + String actual = rpcActualResult.get("error").asText(); + assertTrue(actual.contains(expected)); + } + /** * Bootstrap-Request Trigger * Execute {"id":"1/0/9"} * {"result":"BAD_REQUEST","error":"probably no bootstrap server configured"} */ @Test - public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServerConfigured() throws Exception { + public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServer() throws Exception { String expectedPath = objectInstanceIdVer_1 + "/" + RESOURCE_ID_9; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -125,7 +201,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Resource with /5_1.0/0/3 is not executable."} */ @Test - public void testExecuteResourceWithOperationNotExecuteById_Result_METHOD_NOT_ALLOWED() throws Exception { + public void testExecuteResourceWithOperationNotExecuteById_Result_BAD_REQUEST_Error_Is_Not_Executable() throws Exception { String expectedPath = objectInstanceIdVer_5 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -141,7 +217,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Specified object id 50 absent in the list supported objects of the client or is security object!"} */ @Test - public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST() throws Exception { + public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST_Error_Specified_Object_Absent_List_Supported() throws Exception { String expectedPath = OBJECT_ID_VER_50 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -159,7 +235,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Specified object id 0 absent in the list supported objects of the client or is security object!"} */ @Test - public void testExecuteSecurityObjectById_Result_NOT_FOUND() throws Exception { + public void testExecuteSecurityObjectById_Result_BAD_REQUEST_Error_SpecifiedObjectAbsent() throws Exception { String expectedPath = objectIdVer_0 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -178,8 +254,26 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT } private String sendRPCExecuteWithValueById(String path, Object value) throws Exception { - String setRpcRequest = "{\"method\": \"Execute\", \"params\": {\"id\": \"" + path + "\", \"value\": " + value + " }}"; - return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); + ObjectNode params = JacksonUtil.newObjectNode(); + params.put("id", path); + + // Jackson сам вирішить: ставити лапки (рядок) чи ні (число/boolean/null) + if (value instanceof String) { + params.put("value", (String) value); + } else if (value instanceof Integer) { + params.put("value", (Integer) value); + } else if (value instanceof Boolean) { + params.put("value", (Boolean) value); + } else { + params.set("value", JacksonUtil.valueToTree(value)); + } + + ObjectNode setRpcRequest = JacksonUtil.newObjectNode(); + setRpcRequest.put("method", "Execute"); + setRpcRequest.set("params", params); + + return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), + JacksonUtil.toString(setRpcRequest), String.class, status().isOk()); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java index 8f638cfa2a..c5b8779a3f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java @@ -50,6 +50,8 @@ import org.eclipse.leshan.core.request.SimpleDownlinkRequest; 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.argument.Arguments; +import org.eclipse.leshan.core.request.argument.InvalidArgumentException; import org.eclipse.leshan.core.request.exception.ClientSleepingException; import org.eclipse.leshan.core.request.exception.InvalidRequestException; import org.eclipse.leshan.core.request.exception.TimeoutException; @@ -116,6 +118,7 @@ import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; import static org.thingsboard.server.common.transport.util.JsonUtils.isBase64; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.createModelsDefault; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.equalsResourceTypeGetSimpleName; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.getVerFromPathIdVerOrId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.validateVersionedId; @@ -268,29 +271,34 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im validateVersionedId(client, request); LwM2mPath pathIds = new LwM2mPath(fromVersionedIdToObjectId(request.getVersionedId())); ResourceModel resourceModelExecute = client.getResourceModel(request.getVersionedId(), modelProvider); - if (resourceModelExecute == null) { - LwM2mModel model = createModelsDefault(); - if (pathIds.isResource()) { - resourceModelExecute = model.getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); - } + if (resourceModelExecute == null && pathIds.isResource()) { + resourceModelExecute = createModelsDefault().getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); } if (resourceModelExecute == null) { - callback.onValidationError(request.toString(), "ResourceModel with " + request.getVersionedId() + - " is absent in system. Need ddd Lwm2m Model with id=" + pathIds.getObjectId() + " ver=" + - getVerFromPathIdVerOrId(request.getVersionedId()) + " to profile."); - } else if (resourceModelExecute.operations.isExecutable()) { - ExecuteRequest downlink; - if (request.getParams() != null && !resourceModelExecute.multiple) { - downlink = new ExecuteRequest(request.getObjectId(), (String) this.converter.convertValue(request.getParams(), - resourceModelExecute.type, ResourceModel.Type.STRING, new LwM2mPath(request.getObjectId()))); - } else { - downlink = new ExecuteRequest(request.getObjectId()); + throw new InvalidArgumentException(String.format("ResourceModel with %s is absent in the system. Need to add Model with id= %s ver=%s to profile.", + request.getVersionedId(), pathIds.getObjectId(), getVerFromPathIdVerOrId(request.getVersionedId()))); + } + if (!resourceModelExecute.operations.isExecutable()) { + throw new InvalidArgumentException(String.format("Resource with %s is not executable.", request.getVersionedId())); + } + + ExecuteRequest downlink; + Object params = request.getParams(); + // 4. Handle parameters if they exist and the resource is not a multiple-instance resource + if (params != null && !resourceModelExecute.multiple) { + ResourceModel.Type resourceModelType = equalsResourceTypeGetSimpleName(params); + if (resourceModelType == null) { + throw new InvalidArgumentException(String.format("Unsupported parameter type: %s. Only simple types (String, Integer, Boolean, etc.) are allowed for Execute arguments.", + params.getClass().getSimpleName())); } - sendSimpleRequest(client, downlink, request.getTimeout(), callback); + String args = (String) this.converter.convertValue(params, resourceModelType, ResourceModel.Type.STRING, pathIds); + downlink = new ExecuteRequest(request.getObjectId(), args); } else { - callback.onValidationError(request.toString(), "Resource with " + request.getVersionedId() + " is not executable."); + downlink = new ExecuteRequest(request.getObjectId()); } - } catch (InvalidRequestException e) { + sendSimpleRequest(client, downlink, request.getTimeout(), callback); + } catch (Exception e) { + log.error("[{}] Validation failed for Execute request: {}", client.getEndpoint(), e.getMessage()); callback.onValidationError(request.toString(), e.getMessage()); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java index 6980fb6341..a0cbd4f598 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java @@ -241,7 +241,13 @@ public class DefaultLwM2MRpcRequestHandler implements LwM2MRpcRequestHandler { } private void sendExecuteRequest(LwM2mClient client, TransportProtos.ToDeviceRpcRequestMsg requestMsg, String versionedId) { - TbLwM2MExecuteRequest downlink = TbLwM2MExecuteRequest.builder().versionedId(versionedId).timeout(clientContext.getRequestTimeout(client)).build(); + RpcExecuteRequest requestBody = JacksonUtil.fromString(requestMsg.getParams(), RpcExecuteRequest.class); + Object value = requestBody != null ? requestBody.getValue() : null; + TbLwM2MExecuteRequest downlink = TbLwM2MExecuteRequest.builder() + .versionedId(versionedId) + .params(value) + .timeout(clientContext.getRequestTimeout(client)) + .build(); var mainCallback = new TbLwM2MExecuteCallback(logService, client, versionedId); var rpcCallback = new RpcEmptyResponseCallback<>(transportService, client, requestMsg, mainCallback); downlinkHandler.sendExecuteRequest(client, downlink, rpcCallback); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java new file mode 100644 index 0000000000..e0360c6f71 --- /dev/null +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server.rpc; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class RpcExecuteRequest extends LwM2MRpcRequestHeader { + + private Object value; + +}