From 450535955eb246efbb2b5bba76cecd37eda41804 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 18:54:44 +0300 Subject: [PATCH 01/21] feat(attributes): add proto fields for unified attribute request GetAttributeRequestMsg: allClientAttributes, allSharedAttributes, separateScopesResponse. GetAttributeResponseMsg: separateScopesResponse; deprecate isMultipleAttributesRequest. GatewayAttributesRequestMsg: clientKeys, sharedKeys, allClientKeys, allSharedKeys. onlyShared left untouched (CoAP attribute-observe initial shared state). --- common/proto/src/main/proto/queue.proto | 6 +++++- common/proto/src/main/proto/transport.proto | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 1f45ab7293..3cadd55095 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -418,15 +418,19 @@ message GetAttributeRequestMsg { repeated string clientAttributeNames = 2; repeated string sharedAttributeNames = 3; bool onlyShared = 4; + bool allClientAttributes = 5; + bool allSharedAttributes = 6; + bool separateScopesResponse = 7; } message GetAttributeResponseMsg { int32 requestId = 1; repeated TsKvProto clientAttributeList = 2; repeated TsKvProto sharedAttributeList = 3; - bool isMultipleAttributesRequest = 4; + bool isMultipleAttributesRequest = 4 [deprecated = true]; string error = 5; bool sharedStateMsg = 6; + bool separateScopesResponse = 7; } message AttributeUpdateNotificationMsg { diff --git a/common/proto/src/main/proto/transport.proto b/common/proto/src/main/proto/transport.proto index 8adebf62b7..e951079e2e 100644 --- a/common/proto/src/main/proto/transport.proto +++ b/common/proto/src/main/proto/transport.proto @@ -98,4 +98,8 @@ message GatewayAttributesRequestMsg { string deviceName = 2; bool client = 3; repeated string keys = 4; + repeated string clientKeys = 5; + repeated string sharedKeys = 6; + bool allClientKeys = 7; + bool allSharedKeys = 8; } From f0575b66df8d5a1f888717135d935d6aebfe335d Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 18:54:44 +0300 Subject: [PATCH 02/21] feat(attributes): per-scope fetch-all + scope-separated gateway response Actor getAttributesKvEntries: explicit per-scope all/specific/none with all-wins; both-absent keeps the 'fetch everything' backward-compat. Echo separateScopesResponse request->response. JsonConverter.getJsonObjectForGateway emits {client,shared} when separateScopesResponse, else legacy value/values. Unit test for the converter. --- .../device/DeviceActorMessageProcessor.java | 33 ++++++--- .../server/common/adaptor/JsonConverter.java | 24 ++++-- .../JsonConverterGatewayResponseTest.java | 74 +++++++++++++++++++ 3 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index d8710eaa2c..299992783d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -518,6 +518,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso systemContext.getDeviceStateService().onDeviceDisconnect(tenantId, deviceId); } + @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response private void handleGetAttributesRequest(SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { int requestId = request.getRequestId(); if (request.getOnlyShared()) { @@ -548,6 +549,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso public void onSuccess(@Nullable List> result) { GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() .setRequestId(requestId) + .setSeparateScopesResponse(request.getSeparateScopesResponse()) .addAllClientAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(0))) .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(1))) .setIsMultipleAttributesRequest( @@ -568,20 +570,33 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } private ListenableFuture>> getAttributesKvEntries(GetAttributeRequestMsg request) { + boolean clientAll = request.getAllClientAttributes(); + boolean sharedAll = request.getAllSharedAttributes(); + boolean clientSpecific = !CollectionUtils.isEmpty(request.getClientAttributeNamesList()); + boolean sharedSpecific = !CollectionUtils.isEmpty(request.getSharedAttributeNamesList()); + + boolean noClientSignal = !clientAll && !clientSpecific; + boolean noSharedSignal = !sharedAll && !sharedSpecific; + ListenableFuture> clientAttributesFuture; ListenableFuture> sharedAttributesFuture; - if (CollectionUtils.isEmpty(request.getClientAttributeNamesList()) && CollectionUtils.isEmpty(request.getSharedAttributeNamesList())) { + + if (noClientSignal && noSharedSignal) { + // backward-compat: empty request => fetch everything from both scopes clientAttributesFuture = findAllAttributesByScope(AttributeScope.CLIENT_SCOPE); sharedAttributesFuture = findAllAttributesByScope(AttributeScope.SHARED_SCOPE); - } else if (!CollectionUtils.isEmpty(request.getClientAttributeNamesList()) && !CollectionUtils.isEmpty(request.getSharedAttributeNamesList())) { - clientAttributesFuture = findAttributesByScope(toSet(request.getClientAttributeNamesList()), AttributeScope.CLIENT_SCOPE); - sharedAttributesFuture = findAttributesByScope(toSet(request.getSharedAttributeNamesList()), AttributeScope.SHARED_SCOPE); - } else if (CollectionUtils.isEmpty(request.getClientAttributeNamesList()) && !CollectionUtils.isEmpty(request.getSharedAttributeNamesList())) { - clientAttributesFuture = Futures.immediateFuture(Collections.emptyList()); - sharedAttributesFuture = findAttributesByScope(toSet(request.getSharedAttributeNamesList()), AttributeScope.SHARED_SCOPE); } else { - sharedAttributesFuture = Futures.immediateFuture(Collections.emptyList()); - clientAttributesFuture = findAttributesByScope(toSet(request.getClientAttributeNamesList()), AttributeScope.CLIENT_SCOPE); + // "all " wins over a specific key list for the same scope + clientAttributesFuture = clientAll + ? findAllAttributesByScope(AttributeScope.CLIENT_SCOPE) + : (clientSpecific + ? findAttributesByScope(toSet(request.getClientAttributeNamesList()), AttributeScope.CLIENT_SCOPE) + : Futures.immediateFuture(Collections.emptyList())); + sharedAttributesFuture = sharedAll + ? findAllAttributesByScope(AttributeScope.SHARED_SCOPE) + : (sharedSpecific + ? findAttributesByScope(toSet(request.getSharedAttributeNamesList()), AttributeScope.SHARED_SCOPE) + : Futures.immediateFuture(Collections.emptyList())); } return Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)); } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index 8a40b1bc76..a7ccead805 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -354,6 +354,7 @@ public class JsonConverter { return result; } + @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response public static JsonObject getJsonObjectForGateway( String deviceName, TransportProtos.GetAttributeResponseMsg responseMsg @@ -361,11 +362,24 @@ public class JsonConverter { JsonObject result = new JsonObject(); result.addProperty("id", responseMsg.getRequestId()); result.addProperty(DEVICE_PROPERTY, deviceName); - if (responseMsg.getClientAttributeListCount() > 0) { - addValues(result, responseMsg.getClientAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); - } - if (responseMsg.getSharedAttributeListCount() > 0) { - addValues(result, responseMsg.getSharedAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); + if (responseMsg.getSeparateScopesResponse()) { + if (responseMsg.getClientAttributeListCount() > 0) { + JsonObject client = new JsonObject(); + responseMsg.getClientAttributeListList().forEach(addToObjectFromProto(client)); + result.add("client", client); + } + if (responseMsg.getSharedAttributeListCount() > 0) { + JsonObject shared = new JsonObject(); + responseMsg.getSharedAttributeListList().forEach(addToObjectFromProto(shared)); + result.add("shared", shared); + } + } else { + if (responseMsg.getClientAttributeListCount() > 0) { + addValues(result, responseMsg.getClientAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); + } + if (responseMsg.getSharedAttributeListCount() > 0) { + addValues(result, responseMsg.getSharedAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); + } } return result; } diff --git a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java new file mode 100644 index 0000000000..5c9af2a679 --- /dev/null +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2025 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.common.adaptor; + +import com.google.gson.JsonObject; +import org.junit.Test; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JsonConverterGatewayResponseTest { + + private TsKvProto kv(String key, long v) { + return TsKvProto.newBuilder().setTs(1L).setKv( + KeyValueProto.newBuilder().setKey(key).setType(KeyValueType.LONG_V).setLongV(v).build()).build(); + } + + @Test + public void separateScopes_keepsScopeLabels_keyNames_andOverlap() { + GetAttributeResponseMsg msg = GetAttributeResponseMsg.newBuilder() + .setRequestId(1) + .setSeparateScopesResponse(true) + .addClientAttributeList(kv("test", 27)) + .addSharedAttributeList(kv("test", 99)) + .build(); + + JsonObject out = JsonConverter.getJsonObjectForGateway("DeviceA", msg); + + assertThat(out.get("id").getAsInt()).isEqualTo(1); + assertThat(out.get("device").getAsString()).isEqualTo("DeviceA"); + assertThat(out.getAsJsonObject("client").get("test").getAsLong()).isEqualTo(27); + assertThat(out.getAsJsonObject("shared").get("test").getAsLong()).isEqualTo(99); + assertThat(out.has("value")).isFalse(); + assertThat(out.has("values")).isFalse(); + } + + @Test + public void separateScopes_omitsEmptyScope() { + GetAttributeResponseMsg msg = GetAttributeResponseMsg.newBuilder() + .setRequestId(2).setSeparateScopesResponse(true) + .addSharedAttributeList(kv("s", 1)).build(); + JsonObject out = JsonConverter.getJsonObjectForGateway("DeviceA", msg); + assertThat(out.has("client")).isFalse(); + assertThat(out.getAsJsonObject("shared").get("s").getAsLong()).isEqualTo(1); + } + + @Test + @SuppressWarnings("deprecation") + public void legacy_singleValue_unchanged() { + GetAttributeResponseMsg msg = GetAttributeResponseMsg.newBuilder() + .setRequestId(3).setSeparateScopesResponse(false) + .setIsMultipleAttributesRequest(false) + .addSharedAttributeList(kv("s", 5)).build(); + JsonObject out = JsonConverter.getJsonObjectForGateway("DeviceA", msg); + assertThat(out.get("value").getAsLong()).isEqualTo(5); + assertThat(out.has("shared")).isFalse(); + } +} From 4f1a3d698dd203d5ea55b6eded88ecc98f8f8256 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 18:54:44 +0300 Subject: [PATCH 03/21] feat(attributes): device MQTT empty-value = all-in-scope clientKeys/sharedKeys: absent => exclude scope; present+empty => all in scope; present+list => those keys. Fixes the previous empty-string dead-end. --- .../mqtt/adaptors/JsonMqttAdaptor.java | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 2516e155fd..0910dbec1e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -38,9 +38,7 @@ import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionConte import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.HashSet; import java.util.Optional; -import java.util.Set; import java.util.UUID; import static org.thingsboard.server.common.data.device.profile.MqttTopics.DEVICE_SOFTWARE_FIRMWARE_RESPONSES_TOPIC_FORMAT; @@ -176,15 +174,9 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); result.setRequestId(getRequestId(topicName, topicBase)); String payload = inbound.payload().toString(UTF8); - JsonElement requestBody = JsonParser.parseString(payload); - Set clientKeys = toStringSet(requestBody, "clientKeys"); - Set sharedKeys = toStringSet(requestBody, "sharedKeys"); - if (clientKeys != null) { - result.addAllClientAttributeNames(clientKeys); - } - if (sharedKeys != null) { - result.addAllSharedAttributeNames(sharedKeys); - } + JsonObject json = JsonParser.parseString(payload).getAsJsonObject(); + parseAttrScope(json, "clientKeys", result::addAllClientAttributeNames, () -> result.setAllClientAttributes(true)); + parseAttrScope(json, "sharedKeys", result::addAllSharedAttributeNames, () -> result.setAllSharedAttributes(true)); return result.build(); } catch (RuntimeException e) { log.debug("Failed to decode get attributes request", e); @@ -248,12 +240,18 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return new MqttPublishMessage(mqttFixedHeader, header, payload); } - private Set toStringSet(JsonElement requestBody, String name) { - JsonElement element = requestBody.getAsJsonObject().get(name); - if (element != null) { - return new HashSet<>(Arrays.asList(element.getAsString().split(","))); + // Three-state per scope: field absent => exclude; present + empty value => all in scope; present + list => those keys. + private static void parseAttrScope(JsonObject json, String field, + java.util.function.Consumer> setNames, + Runnable setAll) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return; + } + String value = json.get(field).getAsString(); + if (value.trim().isEmpty()) { + setAll.run(); } else { - return null; + setNames.accept(Arrays.asList(value.split(","))); } } From f0f6f93535175f6000ea1cd4aad28c360434119c Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 18:54:44 +0300 Subject: [PATCH 04/21] feat(attributes): gateway MQTT unified request (JSON + proto) New clientKeys/sharedKeys format (empty=all) sets separateScopesResponse -> new {client,shared} response; legacy client+key/keys keeps value/values. Neither marker => fetch all with new response (also fixes the omit-keys session crash). --- .../AbstractGatewaySessionHandler.java | 95 +++++++++++++++---- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 8ea6480142..4fced72e85 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -27,7 +27,6 @@ import com.google.gson.JsonNull; import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.google.protobuf.InvalidProtocolBufferException; -import com.google.protobuf.ProtocolStringList; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; @@ -654,30 +653,90 @@ public abstract class AbstractGatewaySessionHandler keys; - if (jsonObj.has("key")) { - keys = Collections.singleton(jsonObj.get("key").getAsString()); - } else { - JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); - keys = new HashSet<>(); - for (JsonElement keyObj : keysArray) { - keys.add(keyObj.getAsString()); + TransportProtos.GetAttributeRequestMsg requestMsg; + if (jsonObj.has("clientKeys") || jsonObj.has("sharedKeys")) { + // new unified format: clientKeys/sharedKeys + empty-value="all"; emit the separated response + TransportProtos.GetAttributeRequestMsg.Builder b = TransportProtos.GetAttributeRequestMsg.newBuilder() + .setRequestId(requestId).setSeparateScopesResponse(true); + parseGatewayScope(jsonObj, "clientKeys", b::addAllClientAttributeNames, () -> b.setAllClientAttributes(true)); + parseGatewayScope(jsonObj, "sharedKeys", b::addAllSharedAttributeNames, () -> b.setAllSharedAttributes(true)); + requestMsg = b.build(); + } else if (jsonObj.has("client")) { + // legacy format: client boolean + key/keys; keep the legacy value/values response + boolean clientScope = jsonObj.get("client").getAsBoolean(); + Set keys; + if (jsonObj.has("key")) { + keys = Collections.singleton(jsonObj.get("key").getAsString()); + } else { + JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); + keys = new HashSet<>(); + for (JsonElement keyObj : keysArray) { + keys.add(keyObj.getAsString()); + } } + requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); + } else { + // neither marker present: fetch everything, separated response + requestMsg = TransportProtos.GetAttributeRequestMsg.newBuilder() + .setRequestId(requestId).setSeparateScopesResponse(true).build(); } - TransportProtos.GetAttributeRequestMsg requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); processGetAttributeRequestMessage(msg, deviceName, requestMsg); } + // Three-state per scope, accepting a comma-string or a JSON array; empty/absent value => all in scope. + private static void parseGatewayScope(JsonObject json, String field, + Consumer> setNames, Runnable setAll) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return; + } + JsonElement el = json.get(field); + List names = new ArrayList<>(); + if (el.isJsonArray()) { + for (JsonElement e : el.getAsJsonArray()) { + names.add(e.getAsString()); + } + } else { + String v = el.getAsString(); + if (v.trim().isEmpty()) { + setAll.run(); + return; + } + names.addAll(java.util.Arrays.asList(v.split(","))); + } + if (names.isEmpty()) { + setAll.run(); + } else { + setNames.accept(names); + } + } + private void onDeviceAttributesRequestProto(MqttPublishMessage mqttMsg) throws AdaptorException { try { - TransportApiProtos.GatewayAttributesRequestMsg gatewayAttributesRequestMsg = TransportApiProtos.GatewayAttributesRequestMsg.parseFrom(getBytes(mqttMsg.payload())); - String deviceName = checkDeviceName(gatewayAttributesRequestMsg.getDeviceName()); - int requestId = gatewayAttributesRequestMsg.getId(); - boolean clientScope = gatewayAttributesRequestMsg.getClient(); - ProtocolStringList keysList = gatewayAttributesRequestMsg.getKeysList(); - Set keys = new HashSet<>(keysList); - TransportProtos.GetAttributeRequestMsg requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); + TransportApiProtos.GatewayAttributesRequestMsg gw = TransportApiProtos.GatewayAttributesRequestMsg.parseFrom(getBytes(mqttMsg.payload())); + String deviceName = checkDeviceName(gw.getDeviceName()); + int requestId = gw.getId(); + boolean newFormat = gw.getAllClientKeys() || gw.getAllSharedKeys() + || gw.getClientKeysCount() > 0 || gw.getSharedKeysCount() > 0; + TransportProtos.GetAttributeRequestMsg requestMsg; + if (newFormat) { + TransportProtos.GetAttributeRequestMsg.Builder b = TransportProtos.GetAttributeRequestMsg.newBuilder() + .setRequestId(requestId).setSeparateScopesResponse(true); + if (gw.getAllClientKeys()) { + b.setAllClientAttributes(true); + } else { + b.addAllClientAttributeNames(gw.getClientKeysList()); + } + if (gw.getAllSharedKeys()) { + b.setAllSharedAttributes(true); + } else { + b.addAllSharedAttributeNames(gw.getSharedKeysList()); + } + requestMsg = b.build(); + } else { + boolean clientScope = gw.getClient(); + Set keys = new HashSet<>(gw.getKeysList()); + requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); + } processGetAttributeRequestMessage(mqttMsg, deviceName, requestMsg); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); From 157e9531208bab27388119b8a73259f65973f956 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 18:54:45 +0300 Subject: [PATCH 05/21] feat(attributes): HTTP allClientKeys/allSharedKeys params Additive boolean params for per-scope fetch-all; existing clientKeys/sharedKeys unchanged; all-wins over specific. --- .../server/transport/http/DeviceApiController.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 73d77e2ddb..b933d97b97 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -144,17 +144,25 @@ public class DeviceApiController implements TbTransportService { @Parameter(description = "Comma separated key names for attribute with client scope", required = true , schema = @Schema(defaultValue = "state")) @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys, @Parameter(description = "Comma separated key names for attribute with shared scope", required = true , schema = @Schema(defaultValue = "configuration")) - @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys) { + @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, + @Parameter(description = "Set to true to return ALL client-scope attributes (ignores clientKeys)") + @RequestParam(value = "allClientKeys", required = false, defaultValue = "false") boolean allClientKeys, + @Parameter(description = "Set to true to return ALL shared-scope attributes (ignores sharedKeys)") + @RequestParam(value = "allSharedKeys", required = false, defaultValue = "false") boolean allSharedKeys) { DeferredResult responseWriter = new DeferredResult<>(); transportContext.getTransportService().process(DeviceTransportType.DEFAULT, ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { GetAttributeRequestMsg.Builder request = GetAttributeRequestMsg.newBuilder().setRequestId(0); List clientKeySet = !StringUtils.isEmpty(clientKeys) ? Arrays.asList(clientKeys.split(",")) : null; List sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? Arrays.asList(sharedKeys.split(",")) : null; - if (clientKeySet != null) { + if (allClientKeys) { + request.setAllClientAttributes(true); + } else if (clientKeySet != null) { request.addAllClientAttributeNames(clientKeySet); } - if (sharedKeySet != null) { + if (allSharedKeys) { + request.setAllSharedAttributes(true); + } else if (sharedKeySet != null) { request.addAllSharedAttributeNames(sharedKeySet); } TransportService transportService = transportContext.getTransportService(); From 7d0b54a75ebeeb0c93b01559f6315546890f6ef1 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 18:54:45 +0300 Subject: [PATCH 06/21] feat(attributes): CoAP allClientKeys/allSharedKeys query params Additive per-scope fetch-all; existing clientKeys/sharedKeys and onlyShared unchanged. --- .../coap/adaptors/CoapAdaptorUtils.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java index 15838564e8..7f6b886f36 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java @@ -31,12 +31,18 @@ public class CoapAdaptorUtils { List queryElements = inbound.getOptions().getUriQuery(); TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); if (queryElements != null && queryElements.size() > 0) { + boolean allClient = "true".equalsIgnoreCase(getQueryValue(queryElements, "allClientKeys")); + boolean allShared = "true".equalsIgnoreCase(getQueryValue(queryElements, "allSharedKeys")); Set clientKeys = toKeys(queryElements, "clientKeys"); Set sharedKeys = toKeys(queryElements, "sharedKeys"); - if (clientKeys != null) { + if (allClient) { + result.setAllClientAttributes(true); + } else if (clientKeys != null) { result.addAllClientAttributeNames(clientKeys); } - if (sharedKeys != null) { + if (allShared) { + result.setAllSharedAttributes(true); + } else if (sharedKeys != null) { result.addAllSharedAttributeNames(sharedKeys); } } @@ -44,6 +50,16 @@ public class CoapAdaptorUtils { return result.build(); } + private static String getQueryValue(List queryElements, String name) { + for (String queryElement : queryElements) { + String[] queryItem = queryElement.split("="); + if (queryItem.length == 2 && queryItem[0].equals(name)) { + return queryItem[1]; + } + } + return null; + } + private static Set toKeys(List queryElements, String attributeName) throws AdaptorException { String keys = null; for (String queryElement : queryElements) { From dfa4d1b286db93a3218201f47fbe4d580b62f802 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 19:25:14 +0300 Subject: [PATCH 07/21] test(attributes): MQTT integration tests for unified request Device: empty-value all-shared/all-client, empty-object all-both. Gateway: new clientKeys/sharedKeys format -> scope-separated {client,shared} response (all-both and all-shared). Existing legacy tests untouched. --- ...AbstractMqttAttributesIntegrationTest.java | 80 ++++++++++++++++++- ...tAttributesRequestJsonIntegrationTest.java | 43 ++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 5f43aa7e00..7e31704436 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -92,10 +92,10 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt " }\n" + "}"; - private static final String CLIENT_ATTRIBUTES_PAYLOAD = "{\"clientStr\":\"value1\",\"clientBool\":true,\"clientDbl\":42.0,\"clientLong\":73," + + protected static final String CLIENT_ATTRIBUTES_PAYLOAD = "{\"clientStr\":\"value1\",\"clientBool\":true,\"clientDbl\":42.0,\"clientLong\":73," + "\"clientJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; - private static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + + protected static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + "\"sharedJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; private static final String SHARED_ATTRIBUTES_DELETED_RESPONSE = "{\"deleted\":[\"sharedJson\"]}"; @@ -369,6 +369,32 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.disconnect(); } + protected void processJsonTestRequestAttributesWithPayload(String attrPubTopic, String attrSubTopic, String attrReqTopicPrefix, + String requestPayload, String expectedResponse) throws Exception { + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(accessToken); + SingleEntityFilter dtf = new SingleEntityFilter(); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); + String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + List keys = new ArrayList<>(); + keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); + keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); + getWsClient().subscribeLatestUpdate(keys, dtf); + getWsClient().registerWaitForUpdate(2); + doPostAsync("/api/plugins/telemetry/DEVICE/" + savedDevice.getId().getId() + "/attributes/SHARED_SCOPE", + SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + client.publishAndWait(attrPubTopic, CLIENT_ATTRIBUTES_PAYLOAD.getBytes()); + client.subscribeAndWait(attrSubTopic, MqttQoS.AT_MOST_ONCE); + String update = getWsClient().waitForUpdate(); + assertThat(update).as("ws update received").isNotBlank(); + MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(attrSubTopic.replace("+", "1")); + client.setCallback(callback); + client.publishAndWait(attrReqTopicPrefix + "1", requestPayload.getBytes()); + validateJsonResponse(callback, expectedResponse); + client.disconnect(); + } + protected void processProtoTestRequestAttributesValuesFromTheServer(String attrPubTopic, String attrSubTopic, String attrReqTopicPrefix) throws Exception { MqttTestClient client = new MqttTestClient(); client.connectAndWait(accessToken); @@ -468,6 +494,56 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.disconnect(); } + protected void processJsonTestGatewayRequestAttributesSeparated(String requestPayloadSuffix, String expectedBody) throws Exception { + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(gatewayAccessToken); + String deviceName = "Gateway Device Request Attributes Separated"; + String postClientAttributes = "{\"" + deviceName + "\":" + CLIENT_ATTRIBUTES_PAYLOAD + "}"; + client.publishAndWait(GATEWAY_ATTRIBUTES_TOPIC, postClientAttributes.getBytes()); + + Device device = doExecuteWithRetriesAndInterval(() -> doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class), + 20, 100); + assertNotNull(device); + + String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + String attributeValuesUrl = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/attributes/CLIENT_SCOPE?keys=" + clientKeysStr; + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .ignoreExceptions() + .until(() -> { + List> attributes = doGetAsyncTyped(attributeValuesUrl, new TypeReference<>() { + }); + return attributes.size() == 5; + }); + + SingleEntityFilter dtf = new SingleEntityFilter(); + dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); + String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + List keys = new ArrayList<>(); + keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); + keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); + EntityDataUpdate initUpdate = getWsClient().subscribeLatestUpdate(keys, dtf); + assertNotNull(initUpdate); + assertFalse(initUpdate.getData().getData().isEmpty()); + getWsClient().registerWaitForUpdate(); + + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + String update = getWsClient().waitForUpdate(); + assertThat(update).as("ws update received").isNotBlank(); + + client.subscribeAndWait(GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, MqttQoS.AT_LEAST_ONCE); + MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(GATEWAY_ATTRIBUTES_RESPONSE_TOPIC); + client.setCallback(callback); + String requestPayloadStr = "{\"id\": 1, \"device\": \"" + deviceName + "\"" + requestPayloadSuffix + "}"; + client.publishAndWait(GATEWAY_ATTRIBUTES_REQUEST_TOPIC, requestPayloadStr.getBytes()); + + assertThat(callback.getSubscribeLatch().await(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)).as("await callback").isTrue(); + assertEquals(MqttQoS.AT_LEAST_ONCE.value(), callback.getMessageArrivedQoS()); + String expected = "{\"id\":1,\"device\":\"" + deviceName + "\"" + expectedBody + "}"; + assertEquals(JacksonUtil.toJsonNode(expected), JacksonUtil.fromBytes(callback.getPayloadBytes())); + client.disconnect(); + } + protected void processProtoTestGatewayRequestAttributesValuesFromTheServer() throws Exception { MqttTestClient client = new MqttTestClient(); client.connectAndWait(gatewayAccessToken); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java index 3786a133ef..77e2df7b5c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java @@ -57,4 +57,47 @@ public class MqttAttributesRequestJsonIntegrationTest extends AbstractMqttAttrib public void testRequestAttributesValuesFromTheServerGateway() throws Exception { processJsonTestGatewayRequestAttributesValuesFromTheServer(); } + + @Test + public void testRequestAllSharedAttributesViaEmptyValue() throws Exception { + // sharedKeys present + empty => all shared; clientKeys absent => no client + processJsonTestRequestAttributesWithPayload( + MqttTopics.DEVICE_ATTRIBUTES_TOPIC, MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC, MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX, + "{\"sharedKeys\":\"\"}", + "{\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD + "}"); + } + + @Test + public void testRequestAllClientAttributesViaEmptyValue() throws Exception { + // clientKeys present + empty => all client; sharedKeys absent => no shared + processJsonTestRequestAttributesWithPayload( + MqttTopics.DEVICE_ATTRIBUTES_TOPIC, MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC, MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX, + "{\"clientKeys\":\"\"}", + "{\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD + "}"); + } + + @Test + public void testRequestAllAttributesViaEmptyObject() throws Exception { + // both fields absent => fetch everything (both scopes) + processJsonTestRequestAttributesWithPayload( + MqttTopics.DEVICE_ATTRIBUTES_TOPIC, MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC, MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX, + "{}", + "{\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD + ",\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD + "}"); + } + + @Test + public void testGatewayRequestAllAttributesSeparated() throws Exception { + // new format: empty clientKeys + empty sharedKeys => all both, scope-separated response + processJsonTestGatewayRequestAttributesSeparated( + ", \"clientKeys\": \"\", \"sharedKeys\": \"\"", + ",\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD + ",\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD); + } + + @Test + public void testGatewayRequestAllSharedSeparated() throws Exception { + // new format: empty sharedKeys only => all shared, no client in the separated response + processJsonTestGatewayRequestAttributesSeparated( + ", \"sharedKeys\": \"\"", + ",\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD); + } } From b053f4233b94b983c8306e96fce460fbe075170e Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 19:25:14 +0300 Subject: [PATCH 08/21] test(attributes): CoAP integration tests for allClientKeys/allSharedKeys --- ...AbstractCoapAttributesIntegrationTest.java | 32 +++++++++++++++++-- ...pAttributesRequestJsonIntegrationTest.java | 10 ++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index de5423abf1..60183ad029 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -89,10 +89,10 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap " }\n" + "}"; - private static final String CLIENT_ATTRIBUTES_PAYLOAD = "{\"clientStr\":\"value1\",\"clientBool\":true,\"clientDbl\":42.0,\"clientLong\":73," + + protected static final String CLIENT_ATTRIBUTES_PAYLOAD = "{\"clientStr\":\"value1\",\"clientBool\":true,\"clientDbl\":42.0,\"clientLong\":73," + "\"clientJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; - private static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + + protected static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + "\"sharedJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; protected static final String SHARED_ATTRIBUTES_PAYLOAD_ON_CURRENT_STATE_NOTIFICATION = "{\"sharedStr\":\"value\",\"sharedBool\":false,\"sharedDbl\":41.0,\"sharedLong\":72," + @@ -198,6 +198,34 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap validateJsonResponse(client.getMethod()); } + protected void processJsonTestRequestAttributesWithQuery(String querySuffix, String expectedResponse) throws Exception { + client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); + SingleEntityFilter dtf = new SingleEntityFilter(); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); + String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + List keys = new ArrayList<>(); + keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); + keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); + getWsClient().subscribeLatestUpdate(keys, dtf); + getWsClient().registerWaitForUpdate(2); + + doPostAsync("/api/plugins/telemetry/DEVICE/" + savedDevice.getId().getId() + "/attributes/SHARED_SCOPE", + SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + + CoapResponse coapResponse = client.postMethod(CLIENT_ATTRIBUTES_PAYLOAD); + assertEquals(CoAP.ResponseCode.CREATED, coapResponse.getCode()); + + String update = getWsClient().waitForUpdate(); + assertThat(update).as("ws update received").isNotBlank(); + + String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + querySuffix; + client.setURI(featureTokenUrl); + CoapResponse response = client.getMethod(); + assertEquals(CoAP.ResponseCode.CONTENT, response.getCode()); + assertEquals(JacksonUtil.toJsonNode(expectedResponse), JacksonUtil.fromBytes(response.getPayload())); + } + protected void processProtoTestRequestAttributesValuesFromTheServer() throws Exception { client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java index e9f75574a4..0449e590ac 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/CoapAttributesRequestJsonIntegrationTest.java @@ -47,4 +47,14 @@ public class CoapAttributesRequestJsonIntegrationTest extends CoapAttributesRequ public void testRequestAttributesValuesFromTheServer() throws Exception { processJsonTestRequestAttributesValuesFromTheServer(); } + + @Test + public void testRequestAllSharedAttributes() throws Exception { + processJsonTestRequestAttributesWithQuery("?allSharedKeys=true", "{\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD + "}"); + } + + @Test + public void testRequestAllClientAttributes() throws Exception { + processJsonTestRequestAttributesWithQuery("?allClientKeys=true", "{\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD + "}"); + } } From 25d74ee098205b49627fea7d38c14fe80c7dd9ec Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 19:25:14 +0300 Subject: [PATCH 09/21] test(attributes): HTTP integration test for allClientKeys param --- .../server/system/BaseHttpDeviceApiTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index dc28913851..b412567fb8 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.system; +import com.fasterxml.jackson.databind.JsonNode; import org.junit.Before; import org.junit.Test; import org.springframework.test.context.TestPropertySource; @@ -74,6 +75,25 @@ public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { doGetAsync("/api/v1/" + deviceCredentials.getCredentialsId() + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isOk()); } + @Test + public void testGetAllClientAttributesViaAllClientKeysParam() throws Exception { + String token = deviceCredentials.getCredentialsId(); + Map clientAttrs = new HashMap<>(); + clientAttrs.put("clientA", "valueA"); + clientAttrs.put("clientB", "valueB"); + mockMvc.perform( + asyncDispatch(doPost("/api/v1/" + token + "/attributes", clientAttrs, new String[]{}).andReturn())) + .andExpect(status().isOk()); + Thread.sleep(2000); + String body = doGetAsync("/api/v1/" + token + "/attributes?allClientKeys=true") + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + JsonNode resp = JacksonUtil.toJsonNode(body); + assertThat(resp.has("client")).isTrue(); + assertThat(resp.get("client").get("clientA").asText()).isEqualTo("valueA"); + assertThat(resp.get("client").get("clientB").asText()).isEqualTo("valueB"); + assertThat(resp.has("shared")).isFalse(); + } + @Test public void testReplyToCommandWithLargeResponse() throws Exception { String errorResponse = doPost("/api/v1/" + deviceCredentials.getCredentialsId() + "/rpc/5", From ce95bdc3227f73c4b21928660489dbbbfd62a0b1 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 24 Jun 2026 19:25:24 +0300 Subject: [PATCH 10/21] test(attributes): black-box tests for unified gateway/device request Gateway: sharedKeys empty -> scope-separated {shared} response. Device: sharedKeys empty -> all shared, no client. Legacy value/values test kept. --- .../msa/connectivity/MqttClientTest.java | 27 +++++++++++++++++++ .../connectivity/MqttGatewayClientTest.java | 25 +++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java index 8a3596e682..9519a2a878 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -234,6 +234,33 @@ public class MqttClientTest extends AbstractContainerTest { assertThat(attributes.getShared().get("sharedAttr")).isEqualTo(sharedAttributeValue); } + @Test + public void requestAllSharedAttributesFromServerViaEmptyValue() throws Exception { + DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = StringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty("sharedAttr", sharedAttributeValue); + testRestClient.postTelemetryAttribute(device.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + + mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE).get(); + TimeUnit.SECONDS.sleep(3 * timeoutMultiplier); + + // empty sharedKeys => all shared; clientKeys absent => no client + JsonObject request = new JsonObject(); + request.addProperty("sharedKeys", ""); + mqttClient.publish("v1/devices/me/attributes/request/" + new Random().nextInt(100), Unpooled.wrappedBuffer(request.toString().getBytes())).get(); + MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class); + + assertThat(attributes.getShared()).hasSize(1); + assertThat(attributes.getShared().get("sharedAttr")).isEqualTo(sharedAttributeValue); + assertThat(attributes.getClient()).isNullOrEmpty(); + } + @Test public void subscribeToAttributeUpdatesFromServer() throws Exception { DeviceCredentials deviceCredentials = testRestClient.getDeviceCredentialsByDeviceId(device.getId()); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index 26235cf663..3e638c6ca6 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -235,6 +235,31 @@ public class MqttGatewayClientTest extends AbstractContainerTest { assertThat(responseData.get("values").getAsJsonObject().entrySet()).hasSize(1); } + @Test + public void gatewayRequestAllSharedReturnsSeparatedResponse() throws Exception { + JsonObject sharedAttributes = new JsonObject(); + sharedAttributes.addProperty("attr1", "value1"); + sharedAttributes.addProperty("attr2", true); + testRestClient.postTelemetryAttribute(createdDevice.getId(), SHARED_SCOPE, mapper.readTree(sharedAttributes.toString())); + + mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get(); + + // new format: empty sharedKeys => all shared; scope-separated response + JsonObject requestData = new JsonObject(); + requestData.addProperty("id", 1); + requestData.addProperty("device", createdDevice.getName()); + requestData.addProperty("sharedKeys", ""); + mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); + + MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + JsonObject responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); + assertThat(responseData.has("shared")).isTrue(); + assertThat(responseData.has("value")).isFalse(); + assertThat(responseData.has("values")).isFalse(); + assertThat(responseData.getAsJsonObject("shared").get("attr1").getAsString()).isEqualTo("value1"); + assertThat(responseData.getAsJsonObject("shared").get("attr2").getAsBoolean()).isTrue(); + } + @Test public void requestAttributeValuesFromServer() throws Exception { WsClient wsClient = subscribeToWebSocket(createdDevice.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS); From 54c7ae0782f7926d64296385475d1cc98381d03e Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 29 Jun 2026 11:35:06 +0300 Subject: [PATCH 11/21] refactor(attributes): address review feedback on unified attribute request - transport.proto: deprecate legacy GatewayAttributesRequestMsg client/keys - scope @SuppressWarnings("deprecation") to legacy-only helpers instead of the dual-mode methods (onDeviceAttributesRequestProto, handleGetAttributesRequest, getJsonObjectForGateway) - dedupe per-scope JSON parsing into JsonMqttAdaptor.parseAttributeScope, reused by the gateway handler; add JsonMqttAdaptorTest covering all branches - CoapAdaptorUtils: route toKeys through getQueryValue - JsonConverter: separated gateway response delegates to toJson() - DeviceActorMessageProcessor: extract resolveScopeFuture helper - tests: proto gateway all-client/all-shared separated coverage via one parameterized helper, hoist shared key-list constants + buildClientAndSharedEntityKeys helper, replace fixed sleep with Awaitility polling derived from the seeded attributes, fix JUnit5 import in JsonConverterGatewayResponseTest --- .../device/DeviceActorMessageProcessor.java | 50 +++++---- .../server/system/BaseHttpDeviceApiTest.java | 19 +++- ...AbstractMqttAttributesIntegrationTest.java | 88 +++++++++++++-- ...AttributesRequestProtoIntegrationTest.java | 20 ++++ .../server/common/adaptor/JsonConverter.java | 30 +++-- common/proto/src/main/proto/transport.proto | 4 +- .../JsonConverterGatewayResponseTest.java | 2 +- .../coap/adaptors/CoapAdaptorUtils.java | 18 ++- .../mqtt/adaptors/JsonMqttAdaptor.java | 40 +++++-- .../AbstractGatewaySessionHandler.java | 42 ++----- .../mqtt/adaptors/JsonMqttAdaptorTest.java | 103 ++++++++++++++++++ 11 files changed, 306 insertions(+), 110 deletions(-) create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 299992783d..f41954c0b5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -518,20 +518,18 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso systemContext.getDeviceStateService().onDeviceDisconnect(tenantId, deviceId); } - @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response private void handleGetAttributesRequest(SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { int requestId = request.getRequestId(); if (request.getOnlyShared()) { Futures.addCallback(findAllAttributesByScope(AttributeScope.SHARED_SCOPE), new FutureCallback<>() { @Override public void onSuccess(@Nullable List result) { - GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + GetAttributeResponseMsg.Builder builder = GetAttributeResponseMsg.newBuilder() .setRequestId(requestId) .setSharedStateMsg(true) - .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result)) - .setIsMultipleAttributesRequest(request.getSharedAttributeNamesCount() > 1) - .build(); - sendToTransport(responseMsg, sessionInfo); + .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result)); + setMultipleAttributesRequest(builder, request.getSharedAttributeNamesCount() > 1); + sendToTransport(builder.build(), sessionInfo); } @Override @@ -547,15 +545,14 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso Futures.addCallback(getAttributesKvEntries(request), new FutureCallback<>() { @Override public void onSuccess(@Nullable List> result) { - GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + GetAttributeResponseMsg.Builder builder = GetAttributeResponseMsg.newBuilder() .setRequestId(requestId) .setSeparateScopesResponse(request.getSeparateScopesResponse()) .addAllClientAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(0))) - .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(1))) - .setIsMultipleAttributesRequest( - request.getSharedAttributeNamesCount() + request.getClientAttributeNamesCount() > 1) - .build(); - sendToTransport(responseMsg, sessionInfo); + .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(1))); + setMultipleAttributesRequest(builder, + request.getSharedAttributeNamesCount() + request.getClientAttributeNamesCount() > 1); + sendToTransport(builder.build(), sessionInfo); } @Override @@ -569,6 +566,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } + @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response + private static void setMultipleAttributesRequest(GetAttributeResponseMsg.Builder builder, boolean multipleAttributesRequest) { + builder.setIsMultipleAttributesRequest(multipleAttributesRequest); + } + private ListenableFuture>> getAttributesKvEntries(GetAttributeRequestMsg request) { boolean clientAll = request.getAllClientAttributes(); boolean sharedAll = request.getAllSharedAttributes(); @@ -586,21 +588,23 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso clientAttributesFuture = findAllAttributesByScope(AttributeScope.CLIENT_SCOPE); sharedAttributesFuture = findAllAttributesByScope(AttributeScope.SHARED_SCOPE); } else { - // "all " wins over a specific key list for the same scope - clientAttributesFuture = clientAll - ? findAllAttributesByScope(AttributeScope.CLIENT_SCOPE) - : (clientSpecific - ? findAttributesByScope(toSet(request.getClientAttributeNamesList()), AttributeScope.CLIENT_SCOPE) - : Futures.immediateFuture(Collections.emptyList())); - sharedAttributesFuture = sharedAll - ? findAllAttributesByScope(AttributeScope.SHARED_SCOPE) - : (sharedSpecific - ? findAttributesByScope(toSet(request.getSharedAttributeNamesList()), AttributeScope.SHARED_SCOPE) - : Futures.immediateFuture(Collections.emptyList())); + clientAttributesFuture = resolveScopeFuture(clientAll, clientSpecific, request.getClientAttributeNamesList(), AttributeScope.CLIENT_SCOPE); + sharedAttributesFuture = resolveScopeFuture(sharedAll, sharedSpecific, request.getSharedAttributeNamesList(), AttributeScope.SHARED_SCOPE); } return Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)); } + // "all " wins over a specific key list for the same scope; no signal => empty result. + private ListenableFuture> resolveScopeFuture(boolean all, boolean specific, List names, AttributeScope scope) { + if (all) { + return findAllAttributesByScope(scope); + } else if (specific) { + return findAttributesByScope(toSet(names), scope); + } else { + return Futures.immediateFuture(Collections.emptyList()); + } + } + private ListenableFuture> findAllAttributesByScope(AttributeScope scope) { return systemContext.getAttributesService().findAll(tenantId, deviceId, scope); } diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index b412567fb8..89825a1e58 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.system; import com.fasterxml.jackson.databind.JsonNode; +import org.awaitility.Awaitility; import org.junit.Before; import org.junit.Test; import org.springframework.test.context.TestPropertySource; @@ -26,9 +27,11 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.controller.AbstractControllerTest; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.Random; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @@ -84,13 +87,21 @@ public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { mockMvc.perform( asyncDispatch(doPost("/api/v1/" + token + "/attributes", clientAttrs, new String[]{}).andReturn())) .andExpect(status().isOk()); - Thread.sleep(2000); - String body = doGetAsync("/api/v1/" + token + "/attributes?allClientKeys=true") + String allClientKeysUrl = "/api/v1/" + token + "/attributes?allClientKeys=true"; + Awaitility.await("client attributes are persisted and returned via allClientKeys=true") + .atMost(30, TimeUnit.SECONDS) + .pollInterval(Duration.ofMillis(100)) + .ignoreExceptions() + .until(() -> { + JsonNode r = JacksonUtil.toJsonNode(doGetAsync(allClientKeysUrl).andReturn().getResponse().getContentAsString()); + return r.has("client") && clientAttrs.keySet().stream().allMatch(r.get("client")::has); + }); + String body = doGetAsync(allClientKeysUrl) .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); JsonNode resp = JacksonUtil.toJsonNode(body); assertThat(resp.has("client")).isTrue(); - assertThat(resp.get("client").get("clientA").asText()).isEqualTo("valueA"); - assertThat(resp.get("client").get("clientB").asText()).isEqualTo("valueB"); + JsonNode client = resp.get("client"); + clientAttrs.forEach((key, value) -> assertThat(client.get(key).asText()).isEqualTo(value)); assertThat(resp.has("shared")).isFalse(); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 7e31704436..23cef0b49d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -98,6 +98,9 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt protected static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + "\"sharedJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; + protected static final String CLIENT_ATTRIBUTE_KEYS = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + protected static final String SHARED_ATTRIBUTE_KEYS = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + private static final String SHARED_ATTRIBUTES_DELETED_RESPONSE = "{\"deleted\":[\"sharedJson\"]}"; private List getTsKvProtoList(String attributePrefix) { @@ -375,11 +378,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.connectAndWait(accessToken); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; - List keys = new ArrayList<>(); - keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); - keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); + List keys = buildClientAndSharedEntityKeys(); getWsClient().subscribeLatestUpdate(keys, dtf); getWsClient().registerWaitForUpdate(2); doPostAsync("/api/plugins/telemetry/DEVICE/" + savedDevice.getId().getId() + "/attributes/SHARED_SCOPE", @@ -505,8 +504,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt 20, 100); assertNotNull(device); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String attributeValuesUrl = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/attributes/CLIENT_SCOPE?keys=" + clientKeysStr; + String attributeValuesUrl = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/attributes/CLIENT_SCOPE?keys=" + CLIENT_ATTRIBUTE_KEYS; Awaitility.await() .atMost(10, TimeUnit.SECONDS) .ignoreExceptions() @@ -518,10 +516,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; - List keys = new ArrayList<>(); - keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); - keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); + List keys = buildClientAndSharedEntityKeys(); EntityDataUpdate initUpdate = getWsClient().subscribeLatestUpdate(keys, dtf); assertNotNull(initUpdate); assertFalse(initUpdate.getData().getData().isEmpty()); @@ -596,10 +591,80 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.disconnect(); } + protected void processProtoTestGatewayRequestAllSharedSeparated() throws Exception { + processProtoTestGatewayRequestAllSeparated(false); + } + + protected void processProtoTestGatewayRequestAllClientSeparated() throws Exception { + processProtoTestGatewayRequestAllSeparated(true); + } + + // allClient => request allClientKeys and expect only client in the separated response; otherwise the mirror for shared. + private void processProtoTestGatewayRequestAllSeparated(boolean allClient) throws Exception { + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(gatewayAccessToken); + + String deviceName = "Gateway Device Request All " + (allClient ? "Client" : "Shared") + " Separated Proto"; + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); + client.publishAndWait(GATEWAY_ATTRIBUTES_TOPIC, getProtoGatewayDeviceClientAttributesPayload(deviceName, clientKeysList)); + + Device device = doExecuteWithRetriesAndInterval(() -> doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class), + 20, 100); + assertNotNull(device); + + SingleEntityFilter dtf = new SingleEntityFilter(); + dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); + List keys = buildClientAndSharedEntityKeys(); + EntityDataUpdate initUpdate = getWsClient().subscribeLatestUpdate(keys, dtf); + assertNotNull(initUpdate); + assertFalse(initUpdate.getData().getData().isEmpty()); + getWsClient().registerWaitForUpdate(); + + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + String update = getWsClient().waitForUpdate(); + assertThat(update).as("ws update received").isNotBlank(); + + client.subscribeAndWait(GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, MqttQoS.AT_LEAST_ONCE); + awaitForDeviceActorToReceiveSubscription(device.getId(), FeatureType.ATTRIBUTES, 1); + + MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(GATEWAY_ATTRIBUTES_RESPONSE_TOPIC); + client.setCallback(callback); + // new proto format: allClientKeys/allSharedKeys => all of that scope, scope-separated response, other scope absent + TransportApiProtos.GatewayAttributesRequestMsg.Builder request = TransportApiProtos.GatewayAttributesRequestMsg.newBuilder() + .setDeviceName(deviceName) + .setId(1); + if (allClient) { + request.setAllClientKeys(true); + } else { + request.setAllSharedKeys(true); + } + client.publishAndWait(GATEWAY_ATTRIBUTES_REQUEST_TOPIC, request.build().toByteArray()); + + TransportApiProtos.GatewayAttributeResponseMsg actual; + if (allClient) { + validateProtoClientResponseGateway(callback, deviceName); + actual = TransportApiProtos.GatewayAttributeResponseMsg.parseFrom(callback.getPayloadBytes()); + assertTrue(actual.getResponseMsg().getSharedAttributeListList().isEmpty()); + } else { + validateProtoSharedResponseGateway(callback, deviceName); + actual = TransportApiProtos.GatewayAttributeResponseMsg.parseFrom(callback.getPayloadBytes()); + assertTrue(actual.getResponseMsg().getClientAttributeListList().isEmpty()); + } + + client.disconnect(); + } + private List getEntityKeys(List keys, EntityKeyType scope) { return keys.stream().map(key -> new EntityKey(scope, key)).collect(Collectors.toList()); } + private List buildClientAndSharedEntityKeys() { + List keys = new ArrayList<>(); + keys.addAll(getEntityKeys(List.of(CLIENT_ATTRIBUTE_KEYS.split(",")), CLIENT_ATTRIBUTE)); + keys.addAll(getEntityKeys(List.of(SHARED_ATTRIBUTE_KEYS.split(",")), SHARED_ATTRIBUTE)); + return keys; + } + private byte[] getAttributesProtoPayloadBytes() { DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); assertTrue(transportConfiguration instanceof MqttDeviceProfileTransportConfiguration); @@ -739,6 +804,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt return gatewayAttributeResponseMsg.build(); } + @SuppressWarnings("deprecation") // exercises the legacy single-scope gateway request (keys + client) private TransportApiProtos.GatewayAttributesRequestMsg getGatewayAttributesRequestMsg(String deviceName, List keysList, boolean client) { return TransportApiProtos.GatewayAttributesRequestMsg.newBuilder() .setDeviceName(deviceName) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java index e368183459..b4be48d2e7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestProtoIntegrationTest.java @@ -74,6 +74,26 @@ public class MqttAttributesRequestProtoIntegrationTest extends AbstractMqttAttri processProtoTestGatewayRequestAttributesValuesFromTheServer(); } + @Test + public void testRequestAllSharedAttributesFromTheServerGatewaySeparated() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .gatewayName("Gateway Test Request all shared attributes from the server proto") + .transportPayloadType(TransportPayloadType.PROTOBUF) + .build(); + processBeforeTest(configProperties); + processProtoTestGatewayRequestAllSharedSeparated(); + } + + @Test + public void testRequestAllClientAttributesFromTheServerGatewaySeparated() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .gatewayName("Gateway Test Request all client attributes from the server proto") + .transportPayloadType(TransportPayloadType.PROTOBUF) + .build(); + processBeforeTest(configProperties); + processProtoTestGatewayRequestAllClientSeparated(); + } + @Test public void testRequestAttributesValuesFromTheServerOnShortJsonTopic() throws Exception { MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index a7ccead805..7284020aee 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -354,7 +354,6 @@ public class JsonConverter { return result; } - @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response public static JsonObject getJsonObjectForGateway( String deviceName, TransportProtos.GetAttributeResponseMsg responseMsg @@ -363,27 +362,24 @@ public class JsonConverter { result.addProperty("id", responseMsg.getRequestId()); result.addProperty(DEVICE_PROPERTY, deviceName); if (responseMsg.getSeparateScopesResponse()) { - if (responseMsg.getClientAttributeListCount() > 0) { - JsonObject client = new JsonObject(); - responseMsg.getClientAttributeListList().forEach(addToObjectFromProto(client)); - result.add("client", client); - } - if (responseMsg.getSharedAttributeListCount() > 0) { - JsonObject shared = new JsonObject(); - responseMsg.getSharedAttributeListList().forEach(addToObjectFromProto(shared)); - result.add("shared", shared); - } + // Reuse the device-side scope-separated assembly so both responses stay identical in shape. + toJson(responseMsg).entrySet().forEach(entry -> result.add(entry.getKey(), entry.getValue())); } else { - if (responseMsg.getClientAttributeListCount() > 0) { - addValues(result, responseMsg.getClientAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); - } - if (responseMsg.getSharedAttributeListCount() > 0) { - addValues(result, responseMsg.getSharedAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); - } + addLegacyGatewayValues(result, responseMsg); } return result; } + @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response + private static void addLegacyGatewayValues(JsonObject result, TransportProtos.GetAttributeResponseMsg responseMsg) { + if (responseMsg.getClientAttributeListCount() > 0) { + addValues(result, responseMsg.getClientAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); + } + if (responseMsg.getSharedAttributeListCount() > 0) { + addValues(result, responseMsg.getSharedAttributeListList(), responseMsg.getIsMultipleAttributesRequest()); + } + } + public static JsonObject getJsonObjectForGateway(String deviceName, AttributeUpdateNotificationMsg notificationMsg) { JsonObject result = new JsonObject(); diff --git a/common/proto/src/main/proto/transport.proto b/common/proto/src/main/proto/transport.proto index e951079e2e..123913eca6 100644 --- a/common/proto/src/main/proto/transport.proto +++ b/common/proto/src/main/proto/transport.proto @@ -96,8 +96,8 @@ message GatewayDeviceRpcRequestMsg { message GatewayAttributesRequestMsg { int32 id = 1; string deviceName = 2; - bool client = 3; - repeated string keys = 4; + bool client = 3 [deprecated = true]; + repeated string keys = 4 [deprecated = true]; repeated string clientKeys = 5; repeated string sharedKeys = 6; bool allClientKeys = 7; diff --git a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java index 5c9af2a679..a62d26f98b 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java @@ -16,7 +16,7 @@ package org.thingsboard.server.common.adaptor; import com.google.gson.JsonObject; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java index 7f6b886f36..2ec44e4976 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java @@ -51,24 +51,20 @@ public class CoapAdaptorUtils { } private static String getQueryValue(List queryElements, String name) { + // Keep the last matching occurrence to preserve the original toKeys() behavior. + String value = null; for (String queryElement : queryElements) { String[] queryItem = queryElement.split("="); if (queryItem.length == 2 && queryItem[0].equals(name)) { - return queryItem[1]; + value = queryItem[1]; } } - return null; + return value; } - private static Set toKeys(List queryElements, String attributeName) throws AdaptorException { - String keys = null; - for (String queryElement : queryElements) { - String[] queryItem = queryElement.split("="); - if (queryItem.length == 2 && queryItem[0].equals(attributeName)) { - keys = queryItem[1]; - } - } - if (keys != null && !StringUtils.isEmpty(keys)) { + private static Set toKeys(List queryElements, String attributeName) { + String keys = getQueryValue(queryElements, attributeName); + if (!StringUtils.isEmpty(keys)) { return new HashSet<>(Arrays.asList(keys.split(","))); } else { return null; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 0910dbec1e..72fd38aef4 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -37,9 +37,12 @@ import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionConte import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import static org.thingsboard.server.common.data.device.profile.MqttTopics.DEVICE_SOFTWARE_FIRMWARE_RESPONSES_TOPIC_FORMAT; @@ -175,8 +178,8 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { result.setRequestId(getRequestId(topicName, topicBase)); String payload = inbound.payload().toString(UTF8); JsonObject json = JsonParser.parseString(payload).getAsJsonObject(); - parseAttrScope(json, "clientKeys", result::addAllClientAttributeNames, () -> result.setAllClientAttributes(true)); - parseAttrScope(json, "sharedKeys", result::addAllSharedAttributeNames, () -> result.setAllSharedAttributes(true)); + parseAttributeScope(json, "clientKeys", result::addAllClientAttributeNames, () -> result.setAllClientAttributes(true)); + parseAttributeScope(json, "sharedKeys", result::addAllSharedAttributeNames, () -> result.setAllSharedAttributes(true)); return result.build(); } catch (RuntimeException e) { log.debug("Failed to decode get attributes request", e); @@ -240,18 +243,37 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return new MqttPublishMessage(mqttFixedHeader, header, payload); } - // Three-state per scope: field absent => exclude; present + empty value => all in scope; present + list => those keys. - private static void parseAttrScope(JsonObject json, String field, - java.util.function.Consumer> setNames, - Runnable setAll) { + /** + * Three-state per-scope attribute selection shared by the device and gateway JSON request parsers: + *
    + *
  • field absent / null => the scope is excluded (neither names nor "all" is set);
  • + *
  • field present + empty value (empty string or empty array) => every key in that scope ({@code setAll});
  • + *
  • field present + a comma-separated string or a JSON array of names => only those keys ({@code setNames}).
  • + *
+ */ + public static void parseAttributeScope(JsonObject json, String field, + Consumer> setNames, Runnable setAll) { if (!json.has(field) || json.get(field).isJsonNull()) { return; } - String value = json.get(field).getAsString(); - if (value.trim().isEmpty()) { + JsonElement element = json.get(field); + List names = new ArrayList<>(); + if (element.isJsonArray()) { + for (JsonElement e : element.getAsJsonArray()) { + names.add(e.getAsString()); + } + } else { + String value = element.getAsString(); + if (value.trim().isEmpty()) { + setAll.run(); + return; + } + names.addAll(Arrays.asList(value.split(","))); + } + if (names.isEmpty()) { setAll.run(); } else { - setNames.accept(Arrays.asList(value.split(","))); + setNames.accept(names); } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 4fced72e85..660eeaf954 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -658,8 +658,8 @@ public abstract class AbstractGatewaySessionHandler b.setAllClientAttributes(true)); - parseGatewayScope(jsonObj, "sharedKeys", b::addAllSharedAttributeNames, () -> b.setAllSharedAttributes(true)); + JsonMqttAdaptor.parseAttributeScope(jsonObj, "clientKeys", b::addAllClientAttributeNames, () -> b.setAllClientAttributes(true)); + JsonMqttAdaptor.parseAttributeScope(jsonObj, "sharedKeys", b::addAllSharedAttributeNames, () -> b.setAllSharedAttributes(true)); requestMsg = b.build(); } else if (jsonObj.has("client")) { // legacy format: client boolean + key/keys; keep the legacy value/values response @@ -683,33 +683,6 @@ public abstract class AbstractGatewaySessionHandler all in scope. - private static void parseGatewayScope(JsonObject json, String field, - Consumer> setNames, Runnable setAll) { - if (!json.has(field) || json.get(field).isJsonNull()) { - return; - } - JsonElement el = json.get(field); - List names = new ArrayList<>(); - if (el.isJsonArray()) { - for (JsonElement e : el.getAsJsonArray()) { - names.add(e.getAsString()); - } - } else { - String v = el.getAsString(); - if (v.trim().isEmpty()) { - setAll.run(); - return; - } - names.addAll(java.util.Arrays.asList(v.split(","))); - } - if (names.isEmpty()) { - setAll.run(); - } else { - setNames.accept(names); - } - } - private void onDeviceAttributesRequestProto(MqttPublishMessage mqttMsg) throws AdaptorException { try { TransportApiProtos.GatewayAttributesRequestMsg gw = TransportApiProtos.GatewayAttributesRequestMsg.parseFrom(getBytes(mqttMsg.payload())); @@ -733,9 +706,7 @@ public abstract class AbstractGatewaySessionHandler keys = new HashSet<>(gw.getKeysList()); - requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); + requestMsg = toLegacyGatewayRequestMsg(requestId, gw); } processGetAttributeRequestMessage(mqttMsg, deviceName, requestMsg); } catch (RuntimeException | InvalidProtocolBufferException e) { @@ -743,6 +714,13 @@ public abstract class AbstractGatewaySessionHandler keys = new HashSet<>(gw.getKeysList()); + return toGetAttributeRequestMsg(requestId, clientScope, keys); + } + private void onDeviceRpcResponseJson(int msgId, ByteBuf payload) throws AdaptorException { JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, payload); validateJsonObject(json); diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java new file mode 100644 index 0000000000..38c1f089f6 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java @@ -0,0 +1,103 @@ +/** + * 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.mqtt.adaptors; + +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class JsonMqttAdaptorTest { + + private final List names = new ArrayList<>(); + private final AtomicBoolean allInvoked = new AtomicBoolean(false); + + private void parseClientKeys(JsonObject json) { + JsonMqttAdaptor.parseAttributeScope(json, "clientKeys", names::addAll, () -> allInvoked.set(true)); + } + + @Test + public void fieldAbsent_doesNothing() { + parseClientKeys(new JsonObject()); + assertTrue(names.isEmpty()); + assertFalse(allInvoked.get()); + } + + @Test + public void fieldNull_doesNothing() { + JsonObject json = new JsonObject(); + json.add("clientKeys", JsonNull.INSTANCE); + parseClientKeys(json); + assertTrue(names.isEmpty()); + assertFalse(allInvoked.get()); + } + + @Test + public void emptyString_setsAll() { + JsonObject json = new JsonObject(); + json.addProperty("clientKeys", ""); + parseClientKeys(json); + assertTrue(allInvoked.get()); + assertTrue(names.isEmpty()); + } + + @Test + public void blankString_setsAll() { + JsonObject json = new JsonObject(); + json.addProperty("clientKeys", " "); + parseClientKeys(json); + assertTrue(allInvoked.get()); + assertTrue(names.isEmpty()); + } + + @Test + public void emptyArray_setsAll() { + JsonObject json = new JsonObject(); + json.add("clientKeys", new JsonArray()); + parseClientKeys(json); + assertTrue(allInvoked.get()); + assertTrue(names.isEmpty()); + } + + @Test + public void commaSeparatedString_setsNames() { + JsonObject json = new JsonObject(); + json.addProperty("clientKeys", "a,b,c"); + parseClientKeys(json); + assertFalse(allInvoked.get()); + assertEquals(List.of("a", "b", "c"), names); + } + + @Test + public void jsonArray_setsNames() { + JsonObject json = new JsonObject(); + JsonArray arr = new JsonArray(); + arr.add("a"); + arr.add("b"); + json.add("clientKeys", arr); + parseClientKeys(json); + assertFalse(allInvoked.get()); + assertEquals(List.of("a", "b"), names); + } +} From 49b6739067d9d80725680d9abc3b838b64d56a9b Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 29 Jun 2026 12:44:17 +0300 Subject: [PATCH 12/21] chore(attributes): bump license header year to 2026 on gateway response test --- .../server/common/adaptor/JsonConverterGatewayResponseTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java index a62d26f98b..7915c10554 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterGatewayResponseTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * 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. From 12d0e7c693c27460d1f87aabfa40da1429d1bb4d Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 29 Jun 2026 18:01:01 +0300 Subject: [PATCH 13/21] test(attributes): read response-topic event in gateway all-shared test Saving the shared attribute also triggers an attribute-update push on v1/gateway/attributes (gateways auto-receive updates for connected devices). That push can reach the listener queue before the request response, so the blind poll occasionally parsed the update message (no 'shared' field) instead of the response, failing on CI. Poll until an event arrives on v1/gateway/attributes/response. --- .../msa/connectivity/MqttGatewayClientTest.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index 3e638c6ca6..588f01aa75 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -251,7 +251,10 @@ public class MqttGatewayClientTest extends AbstractContainerTest { requestData.addProperty("sharedKeys", ""); mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get(); - MqttEvent event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS); + // Saving the shared attribute above also triggers an attribute-update push on + // v1/gateway/attributes, which can land in the queue before the request response. + // Skip those and read the event from the response topic. + MqttEvent event = pollEventForTopic("v1/gateway/attributes/response"); JsonObject responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject(); assertThat(responseData.has("shared")).isTrue(); assertThat(responseData.has("value")).isFalse(); @@ -396,6 +399,16 @@ public class MqttGatewayClientTest extends AbstractContainerTest { this.createdDevice = createDeviceThroughGateway(mqttClient, gatewayDevice); } + private MqttEvent pollEventForTopic(String topic) throws InterruptedException { + MqttEvent event; + while ((event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS)) != null) { + if (topic.equals(event.getTopic())) { + return event; + } + } + return null; + } + private void checkAttribute(boolean client, String expectedValue) throws Exception { JsonObject gatewayAttributesRequest = new JsonObject(); int messageId = new Random().nextInt(100); From 05fd10f2764d9d44842070ca7a6d7e97bdaddbec Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 29 Jun 2026 18:04:55 +0300 Subject: [PATCH 14/21] test(attributes): bound gateway response poll to a hard overall deadline Spend the 10*timeoutMultiplier budget across messages instead of resetting it per message, so a stream of non-matching events can't extend the wait indefinitely. --- .../thingsboard/server/service/install/ProjectInfo.java | 5 +++-- .../server/msa/connectivity/MqttGatewayClientTest.java | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java index 128ca5578a..cb34070a2b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java +++ b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java @@ -28,8 +28,9 @@ public class ProjectInfo { private final Optional buildProperties; public String getProjectVersion() { - return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven")) - .getVersion().replaceAll("[^\\d.]", ""); +// return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven")) +// .getVersion().replaceAll("[^\\d.]", ""); + return "4.2.2.3"; } public String getProductType() { diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java index 588f01aa75..97a5a7322a 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java @@ -400,8 +400,13 @@ public class MqttGatewayClientTest extends AbstractContainerTest { } private MqttEvent pollEventForTopic(String topic) throws InterruptedException { - MqttEvent event; - while ((event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS)) != null) { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(10 * timeoutMultiplier); + long remaining; + while ((remaining = deadline - System.currentTimeMillis()) > 0) { + MqttEvent event = listener.getEvents().poll(remaining, TimeUnit.MILLISECONDS); + if (event == null) { + break; + } if (topic.equals(event.getTopic())) { return event; } From b3e4ff3b6d2dd12fa5f535080e85945471f001d2 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 29 Jun 2026 18:05:49 +0300 Subject: [PATCH 15/21] test(attributes): revert accidental ProjectInfo dev hack swept in by git commit -am Restores getProjectVersion() to read build properties; the hardcoded "4.2.2.3" was a local-only change unrelated to this PR. --- .../org/thingsboard/server/service/install/ProjectInfo.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java index cb34070a2b..128ca5578a 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java +++ b/application/src/main/java/org/thingsboard/server/service/install/ProjectInfo.java @@ -28,9 +28,8 @@ public class ProjectInfo { private final Optional buildProperties; public String getProjectVersion() { -// return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven")) -// .getVersion().replaceAll("[^\\d.]", ""); - return "4.2.2.3"; + return buildProperties.orElseThrow(() -> new IllegalStateException("Build properties are missing. Please rebuild the project with maven")) + .getVersion().replaceAll("[^\\d.]", ""); } public String getProductType() { From 8056989614706ecacb5ffda3086ba5db1236883d Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 30 Jun 2026 07:49:32 +0300 Subject: [PATCH 16/21] refactor(attributes): restrict gateway/device clientKeys & sharedKeys to CSV The unified request mirrors the device MQTT API's comma-separated clientKeys/sharedKeys format (with empty value = all in scope). Drop the extra JSON-array input branch from parseAttributeScope so the accepted format stays exactly the long-standing device contract; remove the two array-specific unit tests accordingly. --- .../mqtt/adaptors/JsonMqttAdaptor.java | 27 +++++-------------- .../mqtt/adaptors/JsonMqttAdaptorTest.java | 22 --------------- 2 files changed, 7 insertions(+), 42 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 72fd38aef4..934acbb2d6 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -37,7 +37,6 @@ import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionConte import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -244,11 +243,12 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { } /** - * Three-state per-scope attribute selection shared by the device and gateway JSON request parsers: + * Three-state per-scope attribute selection shared by the device and gateway JSON request parsers, + * matching the comma-separated {@code clientKeys}/{@code sharedKeys} format of the device MQTT API: *
    *
  • field absent / null => the scope is excluded (neither names nor "all" is set);
  • - *
  • field present + empty value (empty string or empty array) => every key in that scope ({@code setAll});
  • - *
  • field present + a comma-separated string or a JSON array of names => only those keys ({@code setNames}).
  • + *
  • field present + empty (or blank) string => every key in that scope ({@code setAll});
  • + *
  • field present + a comma-separated string of names => only those keys ({@code setNames}).
  • *
*/ public static void parseAttributeScope(JsonObject json, String field, @@ -256,24 +256,11 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { if (!json.has(field) || json.get(field).isJsonNull()) { return; } - JsonElement element = json.get(field); - List names = new ArrayList<>(); - if (element.isJsonArray()) { - for (JsonElement e : element.getAsJsonArray()) { - names.add(e.getAsString()); - } - } else { - String value = element.getAsString(); - if (value.trim().isEmpty()) { - setAll.run(); - return; - } - names.addAll(Arrays.asList(value.split(","))); - } - if (names.isEmpty()) { + String value = json.get(field).getAsString(); + if (value.trim().isEmpty()) { setAll.run(); } else { - setNames.accept(names); + setNames.accept(Arrays.asList(value.split(","))); } } diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java index 38c1f089f6..ca5059fc63 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.transport.mqtt.adaptors; -import com.google.gson.JsonArray; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; @@ -71,15 +70,6 @@ public class JsonMqttAdaptorTest { assertTrue(names.isEmpty()); } - @Test - public void emptyArray_setsAll() { - JsonObject json = new JsonObject(); - json.add("clientKeys", new JsonArray()); - parseClientKeys(json); - assertTrue(allInvoked.get()); - assertTrue(names.isEmpty()); - } - @Test public void commaSeparatedString_setsNames() { JsonObject json = new JsonObject(); @@ -88,16 +78,4 @@ public class JsonMqttAdaptorTest { assertFalse(allInvoked.get()); assertEquals(List.of("a", "b", "c"), names); } - - @Test - public void jsonArray_setsNames() { - JsonObject json = new JsonObject(); - JsonArray arr = new JsonArray(); - arr.add("a"); - arr.add("b"); - json.add("clientKeys", arr); - parseClientKeys(json); - assertFalse(allInvoked.get()); - assertEquals(List.of("a", "b"), names); - } } From 7898cf3fb9461aef06ba5716c87ff6a3d83039b6 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 30 Jun 2026 17:04:44 +0300 Subject: [PATCH 17/21] =?UTF-8?q?refactor(attributes):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20share=20scope=20helpers,=20fix=20param=20docs,?= =?UTF-8?q?=20expand=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - move parseAttributeScope to JsonConverter; add applyClientScope/applySharedScope shared by HTTP/CoAP - CoAP: skip key parsing when allClientKeys/allSharedKeys set (precedence structural) - HTTP: correct @Parameter required flag on clientKeys/sharedKeys - relocate parseAttributeScope unit tests to common/proto; add apply*Scope coverage - gateway JSON tests: all-client-only + specific shared keys; unique device names + subscription await - migrate attribute-key literals to shared constants (MQTT + CoAP test bases) - HTTP: add allSharedKeys integration test --- .../server/system/BaseHttpDeviceApiTest.java | 27 ++++++++++ ...AbstractCoapAttributesIntegrationTest.java | 15 +++--- ...AbstractMqttAttributesIntegrationTest.java | 20 ++++---- ...tAttributesRequestJsonIntegrationTest.java | 20 ++++++++ .../server/common/adaptor/JsonConverter.java | 47 ++++++++++++++++++ .../JsonConverterAttributeRequestTest.java} | 49 +++++++++++++++++-- .../coap/adaptors/CoapAdaptorUtils.java | 17 ++----- .../transport/http/DeviceApiController.java | 16 ++---- .../mqtt/adaptors/JsonMqttAdaptor.java | 29 +---------- .../AbstractGatewaySessionHandler.java | 4 +- 10 files changed, 172 insertions(+), 72 deletions(-) rename common/{transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java => proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java} (53%) diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index 89825a1e58..9dff237eee 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -105,6 +105,33 @@ public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { assertThat(resp.has("shared")).isFalse(); } + @Test + public void testGetAllSharedAttributesViaAllSharedKeysParam() throws Exception { + String token = deviceCredentials.getCredentialsId(); + Map sharedAttrs = new HashMap<>(); + sharedAttrs.put("sharedA", "valueA"); + sharedAttrs.put("sharedB", "valueB"); + mockMvc.perform( + asyncDispatch(doPost("/api/plugins/telemetry/DEVICE/" + device.getId().getId() + "/attributes/SHARED_SCOPE", sharedAttrs).andReturn())) + .andExpect(status().isOk()); + String allSharedKeysUrl = "/api/v1/" + token + "/attributes?allSharedKeys=true"; + Awaitility.await("shared attributes are persisted and returned via allSharedKeys=true") + .atMost(30, TimeUnit.SECONDS) + .pollInterval(Duration.ofMillis(100)) + .ignoreExceptions() + .until(() -> { + JsonNode r = JacksonUtil.toJsonNode(doGetAsync(allSharedKeysUrl).andReturn().getResponse().getContentAsString()); + return r.has("shared") && sharedAttrs.keySet().stream().allMatch(r.get("shared")::has); + }); + String body = doGetAsync(allSharedKeysUrl) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + JsonNode resp = JacksonUtil.toJsonNode(body); + assertThat(resp.has("shared")).isTrue(); + JsonNode shared = resp.get("shared"); + sharedAttrs.forEach((key, value) -> assertThat(shared.get(key).asText()).isEqualTo(value)); + assertThat(resp.has("client")).isFalse(); + } + @Test public void testReplyToCommandWithLargeResponse() throws Exception { String errorResponse = doPost("/api/v1/" + deviceCredentials.getCredentialsId() + "/rpc/5", diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index 60183ad029..f5b04049b2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -95,6 +95,9 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap protected static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + "\"sharedJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; + protected static final String CLIENT_ATTRIBUTE_KEYS = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + protected static final String SHARED_ATTRIBUTE_KEYS = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + protected static final String SHARED_ATTRIBUTES_PAYLOAD_ON_CURRENT_STATE_NOTIFICATION = "{\"sharedStr\":\"value\",\"sharedBool\":false,\"sharedDbl\":41.0,\"sharedLong\":72," + "\"sharedJson\":{\"someNumber\":41,\"someArray\":[],\"someNestedObject\":{\"key\":\"value\"}}}"; @@ -172,8 +175,8 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); @@ -202,8 +205,8 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List keys = new ArrayList<>(); keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); @@ -230,8 +233,8 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 23cef0b49d..8d4cc6a6ae 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -344,8 +344,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.connectAndWait(accessToken); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); @@ -398,8 +398,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt MqttTestClient client = new MqttTestClient(); client.connectAndWait(accessToken); DeviceTypeFilter dtf = new DeviceTypeFilter(List.of(savedDevice.getType()), savedDevice.getName()); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); @@ -439,7 +439,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt 100); assertNotNull(device); - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; String attributeValuesUrl = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/attributes/CLIENT_SCOPE?keys=" + clientKeysStr; @@ -454,7 +454,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); @@ -493,10 +493,9 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.disconnect(); } - protected void processJsonTestGatewayRequestAttributesSeparated(String requestPayloadSuffix, String expectedBody) throws Exception { + protected void processJsonTestGatewayRequestAttributesSeparated(String deviceName, String requestPayloadSuffix, String expectedBody) throws Exception { MqttTestClient client = new MqttTestClient(); client.connectAndWait(gatewayAccessToken); - String deviceName = "Gateway Device Request Attributes Separated"; String postClientAttributes = "{\"" + deviceName + "\":" + CLIENT_ATTRIBUTES_PAYLOAD + "}"; client.publishAndWait(GATEWAY_ATTRIBUTES_TOPIC, postClientAttributes.getBytes()); @@ -527,6 +526,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt assertThat(update).as("ws update received").isNotBlank(); client.subscribeAndWait(GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, MqttQoS.AT_LEAST_ONCE); + awaitForDeviceActorToReceiveSubscription(device.getId(), FeatureType.ATTRIBUTES, 1); MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(GATEWAY_ATTRIBUTES_RESPONSE_TOPIC); client.setCallback(callback); String requestPayloadStr = "{\"id\": 1, \"device\": \"" + deviceName + "\"" + requestPayloadSuffix + "}"; @@ -544,7 +544,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.connectAndWait(gatewayAccessToken); String deviceName = "Gateway Device Request Attributes"; - String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; List clientKeysList = List.of(clientKeysStr.split(",")); client.publishAndWait(GATEWAY_ATTRIBUTES_TOPIC, getProtoGatewayDeviceClientAttributesPayload(deviceName, clientKeysList)); @@ -555,7 +555,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); - String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java index 77e2df7b5c..df5ed3c1ea 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/request/MqttAttributesRequestJsonIntegrationTest.java @@ -89,6 +89,7 @@ public class MqttAttributesRequestJsonIntegrationTest extends AbstractMqttAttrib public void testGatewayRequestAllAttributesSeparated() throws Exception { // new format: empty clientKeys + empty sharedKeys => all both, scope-separated response processJsonTestGatewayRequestAttributesSeparated( + "Gateway Device Request All Both Separated Json", ", \"clientKeys\": \"\", \"sharedKeys\": \"\"", ",\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD + ",\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD); } @@ -97,7 +98,26 @@ public class MqttAttributesRequestJsonIntegrationTest extends AbstractMqttAttrib public void testGatewayRequestAllSharedSeparated() throws Exception { // new format: empty sharedKeys only => all shared, no client in the separated response processJsonTestGatewayRequestAttributesSeparated( + "Gateway Device Request All Shared Separated Json", ", \"sharedKeys\": \"\"", ",\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD); } + + @Test + public void testGatewayRequestAllClientSeparated() throws Exception { + // new format: empty clientKeys only => all client, no shared in the separated response + processJsonTestGatewayRequestAttributesSeparated( + "Gateway Device Request All Client Separated Json", + ", \"clientKeys\": \"\"", + ",\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD); + } + + @Test + public void testGatewayRequestSpecificSharedKeysSeparated() throws Exception { + // new format: specific shared key list => only those shared keys, scope-separated response + processJsonTestGatewayRequestAttributesSeparated( + "Gateway Device Request Specific Shared Keys Json", + ", \"sharedKeys\": \"sharedStr,sharedBool\"", + ",\"shared\":{\"sharedStr\":\"value1\",\"sharedBool\":true}"); + } } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index 7284020aee..47f288c0c9 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -55,6 +55,8 @@ import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509Ce import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -380,6 +382,51 @@ public class JsonConverter { } } + /** + * Three-state per-scope attribute selection for the JSON {@code clientKeys}/{@code sharedKeys} format + * (device and gateway MQTT APIs): + *
    + *
  • field absent / null => the scope is excluded (neither names nor "all" is set);
  • + *
  • field present + empty (or blank) string => every key in that scope ({@code setAll});
  • + *
  • field present + a comma-separated string of names => only those keys ({@code setNames}).
  • + *
+ */ + public static void parseAttributeScope(JsonObject json, String field, + Consumer> setNames, Runnable setAll) { + if (!json.has(field) || json.get(field).isJsonNull()) { + return; + } + String value = json.get(field).getAsString(); + if (value.trim().isEmpty()) { + setAll.run(); + } else { + setNames.accept(Arrays.asList(value.split(","))); + } + } + + /** + * Populate the client scope of an attribute request: "all" wins over a specific key list; a missing/empty + * list leaves the scope unset. Shared by the HTTP and CoAP query-parameter parsers. + */ + public static void applyClientScope(TransportProtos.GetAttributeRequestMsg.Builder builder, boolean all, Collection names) { + if (all) { + builder.setAllClientAttributes(true); + } else if (names != null && !names.isEmpty()) { + builder.addAllClientAttributeNames(names); + } + } + + /** + * Shared-scope counterpart of {@link #applyClientScope}. + */ + public static void applySharedScope(TransportProtos.GetAttributeRequestMsg.Builder builder, boolean all, Collection names) { + if (all) { + builder.setAllSharedAttributes(true); + } else if (names != null && !names.isEmpty()) { + builder.addAllSharedAttributeNames(names); + } + } + public static JsonObject getJsonObjectForGateway(String deviceName, AttributeUpdateNotificationMsg notificationMsg) { JsonObject result = new JsonObject(); diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java similarity index 53% rename from common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java rename to common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java index ca5059fc63..7329a3fc3d 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptorTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java @@ -13,27 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.mqtt.adaptors; +package org.thingsboard.server.common.adaptor; import com.google.gson.JsonNull; import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -public class JsonMqttAdaptorTest { +public class JsonConverterAttributeRequestTest { private final List names = new ArrayList<>(); private final AtomicBoolean allInvoked = new AtomicBoolean(false); private void parseClientKeys(JsonObject json) { - JsonMqttAdaptor.parseAttributeScope(json, "clientKeys", names::addAll, () -> allInvoked.set(true)); + JsonConverter.parseAttributeScope(json, "clientKeys", names::addAll, () -> allInvoked.set(true)); } @Test @@ -78,4 +80,45 @@ public class JsonMqttAdaptorTest { assertFalse(allInvoked.get()); assertEquals(List.of("a", "b", "c"), names); } + + @Test + public void applyClientScope_all_winsOverNames() { + GetAttributeRequestMsg.Builder b = GetAttributeRequestMsg.newBuilder(); + JsonConverter.applyClientScope(b, true, List.of("ignored")); + assertThat(b.getAllClientAttributes()).isTrue(); + assertThat(b.getClientAttributeNamesList()).isEmpty(); + } + + @Test + public void applyClientScope_names_addsNames() { + GetAttributeRequestMsg.Builder b = GetAttributeRequestMsg.newBuilder(); + JsonConverter.applyClientScope(b, false, List.of("a", "b")); + assertThat(b.getAllClientAttributes()).isFalse(); + assertThat(b.getClientAttributeNamesList()).containsExactly("a", "b"); + } + + @Test + public void applyClientScope_nullOrEmpty_setsNothing() { + GetAttributeRequestMsg.Builder b = GetAttributeRequestMsg.newBuilder(); + JsonConverter.applyClientScope(b, false, null); + JsonConverter.applyClientScope(b, false, List.of()); + assertThat(b.getAllClientAttributes()).isFalse(); + assertThat(b.getClientAttributeNamesList()).isEmpty(); + } + + @Test + public void applySharedScope_all_winsOverNames() { + GetAttributeRequestMsg.Builder b = GetAttributeRequestMsg.newBuilder(); + JsonConverter.applySharedScope(b, true, null); + assertThat(b.getAllSharedAttributes()).isTrue(); + assertThat(b.getSharedAttributeNamesList()).isEmpty(); + } + + @Test + public void applySharedScope_names_addsNames() { + GetAttributeRequestMsg.Builder b = GetAttributeRequestMsg.newBuilder(); + JsonConverter.applySharedScope(b, false, List.of("s1")); + assertThat(b.getAllSharedAttributes()).isFalse(); + assertThat(b.getSharedAttributeNamesList()).containsExactly("s1"); + } } diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java index 2ec44e4976..6878b4b29b 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapAdaptorUtils.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.coap.adaptors; import org.eclipse.californium.core.coap.Request; import org.thingsboard.server.common.adaptor.AdaptorException; +import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.gen.transport.TransportProtos; @@ -33,18 +34,10 @@ public class CoapAdaptorUtils { if (queryElements != null && queryElements.size() > 0) { boolean allClient = "true".equalsIgnoreCase(getQueryValue(queryElements, "allClientKeys")); boolean allShared = "true".equalsIgnoreCase(getQueryValue(queryElements, "allSharedKeys")); - Set clientKeys = toKeys(queryElements, "clientKeys"); - Set sharedKeys = toKeys(queryElements, "sharedKeys"); - if (allClient) { - result.setAllClientAttributes(true); - } else if (clientKeys != null) { - result.addAllClientAttributeNames(clientKeys); - } - if (allShared) { - result.setAllSharedAttributes(true); - } else if (sharedKeys != null) { - result.addAllSharedAttributeNames(sharedKeys); - } + Set clientKeys = allClient ? null : toKeys(queryElements, "clientKeys"); + Set sharedKeys = allShared ? null : toKeys(queryElements, "sharedKeys"); + JsonConverter.applyClientScope(result, allClient, clientKeys); + JsonConverter.applySharedScope(result, allShared, sharedKeys); } result.setOnlyShared(false); return result.build(); diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index b933d97b97..c61e7c1d2a 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -141,9 +141,9 @@ public class DeviceApiController implements TbTransportService { public DeferredResult getDeviceAttributes( @Parameter(description = ACCESS_TOKEN_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "YOUR_DEVICE_ACCESS_TOKEN")) @PathVariable("deviceToken") String deviceToken, - @Parameter(description = "Comma separated key names for attribute with client scope", required = true , schema = @Schema(defaultValue = "state")) + @Parameter(description = "Comma separated key names for attribute with client scope", required = false, schema = @Schema(defaultValue = "state")) @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys, - @Parameter(description = "Comma separated key names for attribute with shared scope", required = true , schema = @Schema(defaultValue = "configuration")) + @Parameter(description = "Comma separated key names for attribute with shared scope", required = false, schema = @Schema(defaultValue = "configuration")) @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, @Parameter(description = "Set to true to return ALL client-scope attributes (ignores clientKeys)") @RequestParam(value = "allClientKeys", required = false, defaultValue = "false") boolean allClientKeys, @@ -155,16 +155,8 @@ public class DeviceApiController implements TbTransportService { GetAttributeRequestMsg.Builder request = GetAttributeRequestMsg.newBuilder().setRequestId(0); List clientKeySet = !StringUtils.isEmpty(clientKeys) ? Arrays.asList(clientKeys.split(",")) : null; List sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? Arrays.asList(sharedKeys.split(",")) : null; - if (allClientKeys) { - request.setAllClientAttributes(true); - } else if (clientKeySet != null) { - request.addAllClientAttributeNames(clientKeySet); - } - if (allSharedKeys) { - request.setAllSharedAttributes(true); - } else if (sharedKeySet != null) { - request.addAllSharedAttributeNames(sharedKeySet); - } + JsonConverter.applyClientScope(request, allClientKeys, clientKeySet); + JsonConverter.applySharedScope(request, allSharedKeys, sharedKeySet); TransportService transportService = transportContext.getTransportService(); transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter, transportContext.getTransportService(), sessionInfo), diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 934acbb2d6..42c2632f07 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -37,11 +37,8 @@ import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionConte import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.function.Consumer; import static org.thingsboard.server.common.data.device.profile.MqttTopics.DEVICE_SOFTWARE_FIRMWARE_RESPONSES_TOPIC_FORMAT; @@ -177,8 +174,8 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { result.setRequestId(getRequestId(topicName, topicBase)); String payload = inbound.payload().toString(UTF8); JsonObject json = JsonParser.parseString(payload).getAsJsonObject(); - parseAttributeScope(json, "clientKeys", result::addAllClientAttributeNames, () -> result.setAllClientAttributes(true)); - parseAttributeScope(json, "sharedKeys", result::addAllSharedAttributeNames, () -> result.setAllSharedAttributes(true)); + JsonConverter.parseAttributeScope(json, "clientKeys", result::addAllClientAttributeNames, () -> result.setAllClientAttributes(true)); + JsonConverter.parseAttributeScope(json, "sharedKeys", result::addAllSharedAttributeNames, () -> result.setAllSharedAttributes(true)); return result.build(); } catch (RuntimeException e) { log.debug("Failed to decode get attributes request", e); @@ -242,28 +239,6 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { return new MqttPublishMessage(mqttFixedHeader, header, payload); } - /** - * Three-state per-scope attribute selection shared by the device and gateway JSON request parsers, - * matching the comma-separated {@code clientKeys}/{@code sharedKeys} format of the device MQTT API: - *
    - *
  • field absent / null => the scope is excluded (neither names nor "all" is set);
  • - *
  • field present + empty (or blank) string => every key in that scope ({@code setAll});
  • - *
  • field present + a comma-separated string of names => only those keys ({@code setNames}).
  • - *
- */ - public static void parseAttributeScope(JsonObject json, String field, - Consumer> setNames, Runnable setAll) { - if (!json.has(field) || json.get(field).isJsonNull()) { - return; - } - String value = json.get(field).getAsString(); - if (value.trim().isEmpty()) { - setAll.run(); - } else { - setNames.accept(Arrays.asList(value.split(","))); - } - } - private static String validatePayload(UUID sessionId, ByteBuf payloadData, boolean isEmptyPayloadAllowed) throws AdaptorException { String payload = payloadData.toString(UTF8); if (payload == null) { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 660eeaf954..4f00328873 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -658,8 +658,8 @@ public abstract class AbstractGatewaySessionHandler b.setAllClientAttributes(true)); - JsonMqttAdaptor.parseAttributeScope(jsonObj, "sharedKeys", b::addAllSharedAttributeNames, () -> b.setAllSharedAttributes(true)); + JsonConverter.parseAttributeScope(jsonObj, "clientKeys", b::addAllClientAttributeNames, () -> b.setAllClientAttributes(true)); + JsonConverter.parseAttributeScope(jsonObj, "sharedKeys", b::addAllSharedAttributeNames, () -> b.setAllSharedAttributes(true)); requestMsg = b.build(); } else if (jsonObj.has("client")) { // legacy format: client boolean + key/keys; keep the legacy value/values response From e213adcf30285c5bb2ef1cc704de6477f67e2e3f Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 30 Jun 2026 17:09:50 +0300 Subject: [PATCH 18/21] test(attributes): inline attribute-key constants, drop redundant locals --- ...AbstractCoapAttributesIntegrationTest.java | 22 +++++-------- ...AbstractMqttAttributesIntegrationTest.java | 32 +++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index f5b04049b2..a19718d601 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -175,10 +175,8 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; - List clientKeysList = List.of(clientKeysStr.split(",")); - List sharedKeysList = List.of(sharedKeysStr.split(",")); + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); + List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); List keys = new ArrayList<>(); @@ -196,7 +194,7 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap String update = getWsClient().waitForUpdate(); assertThat(update).as("ws update received").isNotBlank(); - String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + clientKeysStr + "&sharedKeys=" + sharedKeysStr; + String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + CLIENT_ATTRIBUTE_KEYS + "&sharedKeys=" + SHARED_ATTRIBUTE_KEYS; client.setURI(featureTokenUrl); validateJsonResponse(client.getMethod()); } @@ -205,11 +203,9 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; List keys = new ArrayList<>(); - keys.addAll(getEntityKeys(List.of(clientKeysStr.split(",")), CLIENT_ATTRIBUTE)); - keys.addAll(getEntityKeys(List.of(sharedKeysStr.split(",")), SHARED_ATTRIBUTE)); + keys.addAll(getEntityKeys(List.of(CLIENT_ATTRIBUTE_KEYS.split(",")), CLIENT_ATTRIBUTE)); + keys.addAll(getEntityKeys(List.of(SHARED_ATTRIBUTE_KEYS.split(",")), SHARED_ATTRIBUTE)); getWsClient().subscribeLatestUpdate(keys, dtf); getWsClient().registerWaitForUpdate(2); @@ -233,10 +229,8 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; - List clientKeysList = List.of(clientKeysStr.split(",")); - List sharedKeysList = List.of(sharedKeysStr.split(",")); + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); + List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); List keys = new ArrayList<>(); @@ -254,7 +248,7 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap String update = getWsClient().waitForUpdate(); assertThat(update).as("ws update received").isNotBlank(); - String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + clientKeysStr + "&sharedKeys=" + sharedKeysStr; + String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + CLIENT_ATTRIBUTE_KEYS + "&sharedKeys=" + SHARED_ATTRIBUTE_KEYS; client.setURI(featureTokenUrl); validateProtoResponse(client.getMethod()); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 8d4cc6a6ae..4c2ffc8364 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -344,10 +344,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.connectAndWait(accessToken); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; - List clientKeysList = List.of(clientKeysStr.split(",")); - List sharedKeysList = List.of(sharedKeysStr.split(",")); + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); + List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); List keys = new ArrayList<>(); @@ -365,7 +363,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt assertThat(update).as("ws update received").isNotBlank(); MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(attrSubTopic.replace("+", "1")); client.setCallback(callback); - String payloadStr = "{\"clientKeys\":\"" + clientKeysStr + "\", \"sharedKeys\":\"" + sharedKeysStr + "\"}"; + String payloadStr = "{\"clientKeys\":\"" + CLIENT_ATTRIBUTE_KEYS + "\", \"sharedKeys\":\"" + SHARED_ATTRIBUTE_KEYS + "\"}"; client.publishAndWait(attrReqTopicPrefix + "1", payloadStr.getBytes()); String expectedResponse = "{\"client\":" + CLIENT_ATTRIBUTES_PAYLOAD + ",\"shared\":" + SHARED_ATTRIBUTES_PAYLOAD + "}"; validateJsonResponse(callback, expectedResponse); @@ -398,10 +396,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt MqttTestClient client = new MqttTestClient(); client.connectAndWait(accessToken); DeviceTypeFilter dtf = new DeviceTypeFilter(List.of(savedDevice.getType()), savedDevice.getName()); - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; - List clientKeysList = List.of(clientKeysStr.split(",")); - List sharedKeysList = List.of(sharedKeysStr.split(",")); + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); + List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); List keys = new ArrayList<>(); @@ -419,8 +415,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt MqttTestCallback callback = new MqttTestSubscribeOnTopicCallback(attrSubTopic.replace("+", "1")); client.setCallback(callback); TransportApiProtos.AttributesRequest.Builder attributesRequestBuilder = TransportApiProtos.AttributesRequest.newBuilder(); - attributesRequestBuilder.setClientKeys(clientKeysStr); - attributesRequestBuilder.setSharedKeys(sharedKeysStr); + attributesRequestBuilder.setClientKeys(CLIENT_ATTRIBUTE_KEYS); + attributesRequestBuilder.setSharedKeys(SHARED_ATTRIBUTE_KEYS); TransportApiProtos.AttributesRequest attributesRequest = attributesRequestBuilder.build(); client.publishAndWait(attrReqTopicPrefix + "1", attributesRequest.toByteArray()); validateProtoResponse(callback, getExpectedAttributeResponseMsg()); @@ -439,9 +435,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt 100); assertNotNull(device); - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - String attributeValuesUrl = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/attributes/CLIENT_SCOPE?keys=" + clientKeysStr; + String attributeValuesUrl = "/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/attributes/CLIENT_SCOPE?keys=" + CLIENT_ATTRIBUTE_KEYS; Awaitility.await() .atMost(10, TimeUnit.SECONDS) @@ -454,9 +449,8 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; - List clientKeysList = List.of(clientKeysStr.split(",")); - List sharedKeysList = List.of(sharedKeysStr.split(",")); + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); + List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); List keys = new ArrayList<>(); @@ -544,8 +538,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt client.connectAndWait(gatewayAccessToken); String deviceName = "Gateway Device Request Attributes"; - String clientKeysStr = CLIENT_ATTRIBUTE_KEYS; - List clientKeysList = List.of(clientKeysStr.split(",")); + List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); client.publishAndWait(GATEWAY_ATTRIBUTES_TOPIC, getProtoGatewayDeviceClientAttributesPayload(deviceName, clientKeysList)); Device device = doExecuteWithRetriesAndInterval(() -> doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class), @@ -555,8 +548,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); - String sharedKeysStr = SHARED_ATTRIBUTE_KEYS; - List sharedKeysList = List.of(sharedKeysStr.split(",")); + List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); List keys = new ArrayList<>(); From 6a86b70022a9be9870560e12d611acec13ac8295 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 30 Jun 2026 17:41:19 +0300 Subject: [PATCH 19/21] refactor(attributes): address second-round review feedback - collapse applyClientScope/applySharedScope into a shared applyScope helper - hoist CLIENT_ATTRIBUTE_KEYS/SHARED_ATTRIBUTE_KEYS to AbstractTransportIntegrationTest - extract shared CoAP attribute-request setup into postAttributesAndAwaitWsUpdate --- .../AbstractTransportIntegrationTest.java | 3 ++ ...AbstractCoapAttributesIntegrationTest.java | 40 +++++-------------- ...AbstractMqttAttributesIntegrationTest.java | 2 - .../server/common/adaptor/JsonConverter.java | 15 +++---- 4 files changed, 21 insertions(+), 39 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java index c91fea282d..03d8a0e7f4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java @@ -30,6 +30,9 @@ public abstract class AbstractTransportIntegrationTest extends AbstractControlle public static final int DEFAULT_WAIT_TIMEOUT_SECONDS = 30; + protected static final String CLIENT_ATTRIBUTE_KEYS = "clientStr,clientBool,clientDbl,clientLong,clientJson"; + protected static final String SHARED_ATTRIBUTE_KEYS = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; + protected static final AtomicInteger atomicInteger = new AtomicInteger(2); public static final String DEVICE_TELEMETRY_PROTO_SCHEMA = "syntax =\"proto3\";\n" + diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index a19718d601..a47bf82372 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -95,8 +95,6 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap protected static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + "\"sharedJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; - protected static final String CLIENT_ATTRIBUTE_KEYS = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - protected static final String SHARED_ATTRIBUTE_KEYS = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; protected static final String SHARED_ATTRIBUTES_PAYLOAD_ON_CURRENT_STATE_NOTIFICATION = "{\"sharedStr\":\"value\",\"sharedBool\":false,\"sharedDbl\":41.0,\"sharedLong\":72," + "\"sharedJson\":{\"someNumber\":41,\"someArray\":[],\"someNestedObject\":{\"key\":\"value\"}}}"; @@ -172,34 +170,22 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap } protected void processJsonTestRequestAttributesValuesFromTheServer() throws Exception { - client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); - SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); - List clientKeysList = List.of(CLIENT_ATTRIBUTE_KEYS.split(",")); - List sharedKeysList = List.of(SHARED_ATTRIBUTE_KEYS.split(",")); - List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); - List shKeys = getEntityKeys(sharedKeysList, SHARED_ATTRIBUTE); - List keys = new ArrayList<>(); - keys.addAll(csKeys); - keys.addAll(shKeys); - getWsClient().subscribeLatestUpdate(keys, dtf); - getWsClient().registerWaitForUpdate(2); - - doPostAsync("/api/plugins/telemetry/DEVICE/" + savedDevice.getId().getId() + "/attributes/SHARED_SCOPE", - SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); - - CoapResponse coapResponse = client.postMethod(CLIENT_ATTRIBUTES_PAYLOAD); - assertEquals(CoAP.ResponseCode.CREATED, coapResponse.getCode()); - - String update = getWsClient().waitForUpdate(); - assertThat(update).as("ws update received").isNotBlank(); - + postAttributesAndAwaitWsUpdate(); String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + CLIENT_ATTRIBUTE_KEYS + "&sharedKeys=" + SHARED_ATTRIBUTE_KEYS; client.setURI(featureTokenUrl); validateJsonResponse(client.getMethod()); } protected void processJsonTestRequestAttributesWithQuery(String querySuffix, String expectedResponse) throws Exception { + postAttributesAndAwaitWsUpdate(); + String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + querySuffix; + client.setURI(featureTokenUrl); + CoapResponse response = client.getMethod(); + assertEquals(CoAP.ResponseCode.CONTENT, response.getCode()); + assertEquals(JacksonUtil.toJsonNode(expectedResponse), JacksonUtil.fromBytes(response.getPayload())); + } + + private void postAttributesAndAwaitWsUpdate() throws Exception { client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); @@ -217,12 +203,6 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap String update = getWsClient().waitForUpdate(); assertThat(update).as("ws update received").isNotBlank(); - - String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + querySuffix; - client.setURI(featureTokenUrl); - CoapResponse response = client.getMethod(); - assertEquals(CoAP.ResponseCode.CONTENT, response.getCode()); - assertEquals(JacksonUtil.toJsonNode(expectedResponse), JacksonUtil.fromBytes(response.getPayload())); } protected void processProtoTestRequestAttributesValuesFromTheServer() throws Exception { diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 4c2ffc8364..f7c02c0144 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -98,8 +98,6 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt protected static final String SHARED_ATTRIBUTES_PAYLOAD = "{\"sharedStr\":\"value1\",\"sharedBool\":true,\"sharedDbl\":42.0,\"sharedLong\":73," + "\"sharedJson\":{\"someNumber\":42,\"someArray\":[1,2,3],\"someNestedObject\":{\"key\":\"value\"}}}"; - protected static final String CLIENT_ATTRIBUTE_KEYS = "clientStr,clientBool,clientDbl,clientLong,clientJson"; - protected static final String SHARED_ATTRIBUTE_KEYS = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; private static final String SHARED_ATTRIBUTES_DELETED_RESPONSE = "{\"deleted\":[\"sharedJson\"]}"; diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index 47f288c0c9..b5dc5a3663 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -409,21 +409,22 @@ public class JsonConverter { * list leaves the scope unset. Shared by the HTTP and CoAP query-parameter parsers. */ public static void applyClientScope(TransportProtos.GetAttributeRequestMsg.Builder builder, boolean all, Collection names) { - if (all) { - builder.setAllClientAttributes(true); - } else if (names != null && !names.isEmpty()) { - builder.addAllClientAttributeNames(names); - } + applyScope(all, names, () -> builder.setAllClientAttributes(true), builder::addAllClientAttributeNames); } /** * Shared-scope counterpart of {@link #applyClientScope}. */ public static void applySharedScope(TransportProtos.GetAttributeRequestMsg.Builder builder, boolean all, Collection names) { + applyScope(all, names, () -> builder.setAllSharedAttributes(true), builder::addAllSharedAttributeNames); + } + + // "all" wins over a specific key list; a missing/empty list leaves the scope unset. + private static void applyScope(boolean all, Collection names, Runnable setAll, Consumer> addNames) { if (all) { - builder.setAllSharedAttributes(true); + setAll.run(); } else if (names != null && !names.isEmpty()) { - builder.addAllSharedAttributeNames(names); + addNames.accept(names); } } From 852026ff3f2bbfcb5adf4bdda5c8b2e63813b213 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 30 Jun 2026 18:15:29 +0300 Subject: [PATCH 20/21] docs(attributes): drop stale Swagger defaults on HTTP clientKeys/sharedKeys The @Schema(defaultValue="state"/"configuration") advertised default keys that are no longer applied (params now default to empty). Replace with a note pointing to allClientKeys/allSharedKeys for fetching a whole scope. --- .../server/transport/http/DeviceApiController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index c61e7c1d2a..be7c1deff6 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -141,9 +141,9 @@ public class DeviceApiController implements TbTransportService { public DeferredResult getDeviceAttributes( @Parameter(description = ACCESS_TOKEN_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "YOUR_DEVICE_ACCESS_TOKEN")) @PathVariable("deviceToken") String deviceToken, - @Parameter(description = "Comma separated key names for attribute with client scope", required = false, schema = @Schema(defaultValue = "state")) + @Parameter(description = "Comma separated key names for attribute with client scope. Leave empty and set allClientKeys=true to fetch all client-scope attributes", required = false) @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys, - @Parameter(description = "Comma separated key names for attribute with shared scope", required = false, schema = @Schema(defaultValue = "configuration")) + @Parameter(description = "Comma separated key names for attribute with shared scope. Leave empty and set allSharedKeys=true to fetch all shared-scope attributes", required = false) @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, @Parameter(description = "Set to true to return ALL client-scope attributes (ignores clientKeys)") @RequestParam(value = "allClientKeys", required = false, defaultValue = "false") boolean allClientKeys, From 14f9230e5854499f0a8f60dacbc5129106ba12cb Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 1 Jul 2026 11:51:50 +0300 Subject: [PATCH 21/21] refactor(attributes): tidy GetAttribute response handling and parseAttributeScope - Drop the always-false isMultipleAttributesRequest flag from the onlyShared path - Split the GetAttribute response into explicit legacy vs. separate-scopes branches - Return a typed ClientSharedAttributes record from getAttributesKvEntries instead of List>, collapsing the empty-request backward-compat branch into a single fetchAll flag and removing the get(0)/get(1) NPE surface - Rename/reorder parseAttributeScope params (selectAllKeys before selectKeys) to match processing order and the sibling applyScope; clarify its Javadoc --- .../device/DeviceActorMessageProcessor.java | 67 ++++++++----------- .../server/common/adaptor/JsonConverter.java | 23 ++++--- .../JsonConverterAttributeRequestTest.java | 2 +- .../mqtt/adaptors/JsonMqttAdaptor.java | 4 +- .../AbstractGatewaySessionHandler.java | 8 +-- 5 files changed, 47 insertions(+), 57 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index f41954c0b5..6c19ac83e7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -23,7 +23,6 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.collections4.CollectionUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.LinkedHashMapRemoveEldest; import org.thingsboard.server.actors.ActorSystemContext; @@ -97,7 +96,6 @@ import org.thingsboard.server.service.state.DefaultDeviceStateService; import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -524,12 +522,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso Futures.addCallback(findAllAttributesByScope(AttributeScope.SHARED_SCOPE), new FutureCallback<>() { @Override public void onSuccess(@Nullable List result) { - GetAttributeResponseMsg.Builder builder = GetAttributeResponseMsg.newBuilder() + sendToTransport(GetAttributeResponseMsg.newBuilder() .setRequestId(requestId) .setSharedStateMsg(true) - .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result)); - setMultipleAttributesRequest(builder, request.getSharedAttributeNamesCount() > 1); - sendToTransport(builder.build(), sessionInfo); + .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result)) + .build(), sessionInfo); } @Override @@ -544,14 +541,18 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } else { Futures.addCallback(getAttributesKvEntries(request), new FutureCallback<>() { @Override - public void onSuccess(@Nullable List> result) { + public void onSuccess(ClientSharedAttributes result) { GetAttributeResponseMsg.Builder builder = GetAttributeResponseMsg.newBuilder() .setRequestId(requestId) - .setSeparateScopesResponse(request.getSeparateScopesResponse()) - .addAllClientAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(0))) - .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result.get(1))); - setMultipleAttributesRequest(builder, - request.getSharedAttributeNamesCount() + request.getClientAttributeNamesCount() > 1); + .addAllClientAttributeList(KvProtoUtil.attrToTsKvProtos(result.client())) + .addAllSharedAttributeList(KvProtoUtil.attrToTsKvProtos(result.shared())); + // legacy gateway value/values response relies on the deprecated isMultipleAttributesRequest flag + if (!request.getSeparateScopesResponse()) { + builder.setIsMultipleAttributesRequest( + request.getSharedAttributeNamesCount() + request.getClientAttributeNamesCount() > 1); + } else { + builder.setSeparateScopesResponse(true); + } sendToTransport(builder.build(), sessionInfo); } @@ -566,40 +567,32 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - @SuppressWarnings("deprecation") // isMultipleAttributesRequest retained for the legacy gateway value/values response - private static void setMultipleAttributesRequest(GetAttributeResponseMsg.Builder builder, boolean multipleAttributesRequest) { - builder.setIsMultipleAttributesRequest(multipleAttributesRequest); - } + private record ClientSharedAttributes(List client, List shared) {} - private ListenableFuture>> getAttributesKvEntries(GetAttributeRequestMsg request) { + private ListenableFuture getAttributesKvEntries(GetAttributeRequestMsg request) { boolean clientAll = request.getAllClientAttributes(); boolean sharedAll = request.getAllSharedAttributes(); - boolean clientSpecific = !CollectionUtils.isEmpty(request.getClientAttributeNamesList()); - boolean sharedSpecific = !CollectionUtils.isEmpty(request.getSharedAttributeNamesList()); + List clientNames = request.getClientAttributeNamesList(); + List sharedNames = request.getSharedAttributeNamesList(); - boolean noClientSignal = !clientAll && !clientSpecific; - boolean noSharedSignal = !sharedAll && !sharedSpecific; + // backward-compat: an empty request (no scope signalled at all) fetches everything from both scopes + boolean fetchAll = !clientAll && !sharedAll && clientNames.isEmpty() && sharedNames.isEmpty(); - ListenableFuture> clientAttributesFuture; - ListenableFuture> sharedAttributesFuture; + ListenableFuture> clientFuture = resolveScopeFuture(clientAll || fetchAll, clientNames, AttributeScope.CLIENT_SCOPE); + ListenableFuture> sharedFuture = resolveScopeFuture(sharedAll || fetchAll, sharedNames, AttributeScope.SHARED_SCOPE); - if (noClientSignal && noSharedSignal) { - // backward-compat: empty request => fetch everything from both scopes - clientAttributesFuture = findAllAttributesByScope(AttributeScope.CLIENT_SCOPE); - sharedAttributesFuture = findAllAttributesByScope(AttributeScope.SHARED_SCOPE); - } else { - clientAttributesFuture = resolveScopeFuture(clientAll, clientSpecific, request.getClientAttributeNamesList(), AttributeScope.CLIENT_SCOPE); - sharedAttributesFuture = resolveScopeFuture(sharedAll, sharedSpecific, request.getSharedAttributeNamesList(), AttributeScope.SHARED_SCOPE); - } - return Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)); + return Futures.whenAllSucceed(clientFuture, sharedFuture) + .call(() -> new ClientSharedAttributes(Futures.getDone(clientFuture), Futures.getDone(sharedFuture)), + MoreExecutors.directExecutor()); } // "all " wins over a specific key list for the same scope; no signal => empty result. - private ListenableFuture> resolveScopeFuture(boolean all, boolean specific, List names, AttributeScope scope) { + // Same precedence as JsonConverter.applyScope (the request-build side); keep the two in sync. + private ListenableFuture> resolveScopeFuture(boolean all, List names, AttributeScope scope) { if (all) { return findAllAttributesByScope(scope); - } else if (specific) { - return findAttributesByScope(toSet(names), scope); + } else if (!names.isEmpty()) { + return findAttributesByScope(new HashSet<>(names), scope); } else { return Futures.immediateFuture(Collections.emptyList()); } @@ -613,10 +606,6 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso return systemContext.getAttributesService().find(tenantId, deviceId, scope, attributesSet); } - private Set toSet(List strings) { - return new HashSet<>(strings); - } - private SessionType getSessionType(UUID sessionId) { return sessions.containsKey(sessionId) ? SessionType.ASYNC : SessionType.SYNC; } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index b5dc5a3663..2a930b31db 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -384,23 +384,23 @@ public class JsonConverter { /** * Three-state per-scope attribute selection for the JSON {@code clientKeys}/{@code sharedKeys} format - * (device and gateway MQTT APIs): + * (device and gateway MQTT APIs). Depending on the value of {@code keysField}: *
    - *
  • field absent / null => the scope is excluded (neither names nor "all" is set);
  • - *
  • field present + empty (or blank) string => every key in that scope ({@code setAll});
  • - *
  • field present + a comma-separated string of names => only those keys ({@code setNames}).
  • + *
  • absent or null: the scope is excluded (neither callback is invoked);
  • + *
  • present but empty (or blank): every key in that scope is selected ({@code selectAllKeys});
  • + *
  • present with a comma-separated list of names: only those keys are selected ({@code selectKeys}).
  • *
*/ - public static void parseAttributeScope(JsonObject json, String field, - Consumer> setNames, Runnable setAll) { - if (!json.has(field) || json.get(field).isJsonNull()) { + public static void parseAttributeScope(JsonObject json, String keysField, + Runnable selectAllKeys, Consumer> selectKeys) { + if (!json.has(keysField) || json.get(keysField).isJsonNull()) { return; } - String value = json.get(field).getAsString(); - if (value.trim().isEmpty()) { - setAll.run(); + String rawKeys = json.get(keysField).getAsString(); + if (rawKeys.trim().isEmpty()) { + selectAllKeys.run(); } else { - setNames.accept(Arrays.asList(value.split(","))); + selectKeys.accept(Arrays.asList(rawKeys.split(","))); } } @@ -420,6 +420,7 @@ public class JsonConverter { } // "all" wins over a specific key list; a missing/empty list leaves the scope unset. + // Same precedence as DeviceActorMessageProcessor.resolveScopeFuture (the fetch side); keep the two in sync. private static void applyScope(boolean all, Collection names, Runnable setAll, Consumer> addNames) { if (all) { setAll.run(); diff --git a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java index 7329a3fc3d..4c3cd39b44 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterAttributeRequestTest.java @@ -35,7 +35,7 @@ public class JsonConverterAttributeRequestTest { private final AtomicBoolean allInvoked = new AtomicBoolean(false); private void parseClientKeys(JsonObject json) { - JsonConverter.parseAttributeScope(json, "clientKeys", names::addAll, () -> allInvoked.set(true)); + JsonConverter.parseAttributeScope(json, "clientKeys", () -> allInvoked.set(true), names::addAll); } @Test diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java index 42c2632f07..7aa648786e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -174,8 +174,8 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor { result.setRequestId(getRequestId(topicName, topicBase)); String payload = inbound.payload().toString(UTF8); JsonObject json = JsonParser.parseString(payload).getAsJsonObject(); - JsonConverter.parseAttributeScope(json, "clientKeys", result::addAllClientAttributeNames, () -> result.setAllClientAttributes(true)); - JsonConverter.parseAttributeScope(json, "sharedKeys", result::addAllSharedAttributeNames, () -> result.setAllSharedAttributes(true)); + JsonConverter.parseAttributeScope(json, "clientKeys", () -> result.setAllClientAttributes(true), result::addAllClientAttributeNames); + JsonConverter.parseAttributeScope(json, "sharedKeys", () -> result.setAllSharedAttributes(true), result::addAllSharedAttributeNames); return result.build(); } catch (RuntimeException e) { log.debug("Failed to decode get attributes request", e); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 4f00328873..69afa30d80 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -656,11 +656,11 @@ public abstract class AbstractGatewaySessionHandler b.setAllClientAttributes(true)); - JsonConverter.parseAttributeScope(jsonObj, "sharedKeys", b::addAllSharedAttributeNames, () -> b.setAllSharedAttributes(true)); - requestMsg = b.build(); + JsonConverter.parseAttributeScope(jsonObj, "clientKeys", () -> requestBuilder.setAllClientAttributes(true), requestBuilder::addAllClientAttributeNames); + JsonConverter.parseAttributeScope(jsonObj, "sharedKeys", () -> requestBuilder.setAllSharedAttributes(true), requestBuilder::addAllSharedAttributeNames); + requestMsg = requestBuilder.build(); } else if (jsonObj.has("client")) { // legacy format: client boolean + key/keys; keep the legacy value/values response boolean clientScope = jsonObj.get("client").getAsBoolean();