Repository: edgexfoundry/device-sdk-go (go)

Issue:
Add resource, command, and device tags to reading/event
<!--🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅

Oh hi there! 😄 

To expedite issue processing please search open and closed issues before submitting a new one.
Existing issues often contain information about workarounds, resolution, or progress updates.

Please do not remove sections of this template when submitting your issue.  If the section is not relevant to your issue, leave the section but enter "n/a" for not applicable.

Note some sections are marked "REQUIRED" and must be provided.  Never mark required sections "n/a".

🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅🔅-->


# 🚀 Feature Request

### Relevant Package [**REQUIRED**]
<!-- Can you pin-point one or more EdgeX services the are relevant for this feature request? -->
<!-- ✍️edit: --> device-sdk


### Description [**REQUIRED**]
Based on https://github.com/edgexfoundry/go-mod-core-contracts/issues/767
Device, device resource, and device command now have the `Tags map[string]interface{}` field that allows users to provide additional information. device-sdk should add these tags to reading/event.
### Describe the solution you'd like
Before sending out an event, do the following:
1. Add device resource tags in reading level
2. Add device command tags in event level
3. Add device tags in event level

The device tags should take precedence over the device command tags if the tag name is the same.

### Describe alternatives you've considered
<!-- ✍️--> Have you considered any alternative solutions or workarounds?


Pull request description:
feat: Add resource, command, and device tags to reading/event
fix #1294 

1. Add device resource tags to reading
2. Add device command tags to event
3. Add device tags to event
Note: Device tags take precedence over Device Command tags.

Signed-off-by: Felix Ting <felix@iotechsys.com>

<!-- Expected Commit Message Description (imported automatically by GitHub) -->
<!-- Must conform to [conventional commits guidelines](https://github.com/edgexfoundry/device-sdk-go/blob/main/.github/Contributing.md) -->
<!-- Expected Commit message must contain Closes/Fixes #IssueNumber statement when there is a related issue -->

<!-- Add additional detailed description of need for change if no related issue -->

**If your build fails** due to your commit message not passing the build checks, please review the guidelines here: https://github.com/edgexfoundry/device-sdk-go/blob/main/.github/Contributing.md

## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] I am not introducing a breaking change (if you are, flag in conventional commit message with `BREAKING CHANGE:` describing the break)
- [x] I am not introducing a new dependency (add notes below if you are)
- [x] I have added unit tests for the new feature or bug fix (if not, why?)
- [x] I have fully tested (add details below) this the new feature or bug fix (if not, why?)
- [ ] I have opened a PR for the related docs change (if not, why?)
  <link to docs PR>

## Testing Instructions
<!-- How can the reviewers test your change? -->
1. Run non-secure EdgeX Stack
2. Based on this branch, modify the device configuration of Device Simple to add device tags

simple-device.toml
```
[[DeviceList]]
  Name = "Simple-Device01"
  ProfileName = "Simple-Device"
  Description = "Example of Simple Device"
  Labels = [ "industrial" ]
  [DeviceList.Tags]
    deviceTag = "deviceTag"
  [DeviceList.Protocols]
  [DeviceList.Protocols.other]
    Address = "simple01"
    Port = "300"
  [[DeviceList.AutoEvents]]
    Interval = "10s"
    OnChange = false
    SourceName = "Switch"
  [[DeviceList.AutoEvents]]
    Interval = "30s"
    OnChange = false
    SourceName = "Image"
```

3. Modify the device profile configuration of Device Simple to add resource and command tags

Simple-Driver.yaml
```
apiVersion: "v2"
name: "Simple-Device"
manufacturer: "Simple Corp."
model: "SP-01"
labels:
  - "modbus"
description: "Example of Simple Device"

deviceResources:
  -
    name: "SwitchButton"
    isHidden: false
    description: "Switch On/Off."
    tags:
      resourceTag: "resourceTag"
    properties:
        valueType: "Bool"
        readWrite: "RW"
        defaultValue: "true"
  -
    name: "Image"
    isHidden: false
    description: "Visual representation of Switch state."
    properties:
        valueType: "Binary"
        readWrite: "R"
        mediaType: "image/jpeg"
  -
    name: "Xrotation"
    isHidden: true
    description: "X axis rotation rate"
    properties:
        valueType: "Int32"
        readWrite: "RW"
        units: "rpm"
  -
    name: "Yrotation"
    isHidden: true
    description: "Y axis rotation rate"
    properties:
        valueType: "Int32"
        readWrite: "RW"
        "units": "rpm"
  -
    name: "Zrotation"
    isHidden: true
    description: "Z axis rotation rate"
    properties:
        valueType: "Int32"
        readWrite: "RW"
        "units": "rpm"
  -
    name: "StringArray"
    isHidden: false
    description: "String array"
    properties:
      valueType: "StringArray"
      readWrite: "RW"
  -
    name: "Uint8Array"
    isHidden: false
    description: "Unsigned 8bit array"
    properties:
        valueType: "Uint8Array"
        readWrite: "RW"
  -
    name: "Counter"
    isHidden: false
    description: "Counter data"
    properties:
      valueType: "Object"
      readWrite: "RW"

deviceCommands:
  -
    name: "Switch"
    isHidden: false
    readWrite: "RW"
    tags:
      commandTag: "commandTag"
    resourceOperations:
      - { deviceResource: "SwitchButton", defaultValue: "false" }
  -
    name: "Image"
    isHidden: false
    readWrite: "R"
    resourceOperations:
      - { deviceResource: "Image" }
  -
    name: "Rotation"
    isHidden: false
    readWrite: "RW"
    resourceOperations:
      - { deviceResource: "Xrotation", defaultValue: "0" }
      - { deviceResource: "Yrotation", defaultValue: "0" }
      - { deviceResource: "Zrotation", defaultValue: "0" }

```

4. Run Device Simple
5. `curl http://localhost:59882/api/v2/device/name/Simple-Device01/Switch`
Verify the returned event contains resource, command, and device tags.
```
{
    "apiVersion": "v2",
    "statusCode": 200,
    "event": {
        "apiVersion": "v2",
        "id": "de6e7c7c-5cb3-4af6-b3ac-3d0c460fe211",
        "deviceName": "Simple-Device01",
        "profileName": "Simple-Device",
        "sourceName": "Switch",
        "origin": 1675147339613147000,
        "readings": [
            {
                "id": "98bf94c7-6a9b-45a6-a2a9-f7c976728c8c",
                "origin": 1675147339613147000,
                "deviceName": "Simple-Device01",
                "resourceName": "SwitchButton",
                "profileName": "Simple-Device",
                "valueType": "Bool",
                "tags": {
                    "resourceTag": "resourceTag"
                },
                "value": "false"
            }
        ],
        "tags": {
            "commandTag": "commandTag",
            "deviceTag": "deviceTag"
        }
    }
}
```

## New Dependency Instructions (If applicable)
<!-- Please follow [vetting instructions](https://wiki.edgexfoundry.org/display/FA/Vetting+Process+for+3rd+Party+Dependencies) and place results here -->

Patch:
diff --git a/internal/common/utils.go b/internal/common/utils.go
index 00f50134..4286c70a 100644
--- a/internal/common/utils.go
+++ b/internal/common/utils.go
@@ -12,13 +12,12 @@ import (
 	"fmt"
 	"time"
 
-	bootstrapInterfaces "github.com/edgexfoundry/go-mod-bootstrap/v3/bootstrap/interfaces"
-	"github.com/edgexfoundry/go-mod-bootstrap/v3/config"
-	gometrics "github.com/rcrowley/go-metrics"
-
+	"github.com/edgexfoundry/device-sdk-go/v3/internal/cache"
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/container"
 
 	bootstrapContainer "github.com/edgexfoundry/go-mod-bootstrap/v3/bootstrap/container"
+	bootstrapInterfaces "github.com/edgexfoundry/go-mod-bootstrap/v3/bootstrap/interfaces"
+	"github.com/edgexfoundry/go-mod-bootstrap/v3/config"
 	"github.com/edgexfoundry/go-mod-bootstrap/v3/di"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/clients/interfaces"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/clients/logger"
@@ -26,6 +25,8 @@ import (
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/dtos"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/dtos/requests"
 	"github.com/edgexfoundry/go-mod-messaging/v3/pkg/types"
+
+	gometrics "github.com/rcrowley/go-metrics"
 )
 
 const (
@@ -122,3 +123,33 @@ func registerMetric(metricsManager bootstrapInterfaces.MetricsManager, lc logger
 		lc.Debugf("%s metric has been registered and will be reported (if enabled)", name)
 	}
 }
+
+func AddEventTags(event *dtos.Event) {
+	if event.Tags == nil {
+		event.Tags = make(map[string]interface{})
+	}
+	cmd, cmdExist := cache.Profiles().DeviceCommand(event.ProfileName, event.SourceName)
+	if cmdExist && len(cmd.Tags) > 0 {
+		for k, v := range cmd.Tags {
+			event.Tags[k] = v
+		}
+	}
+	device, deviceExist := cache.Devices().ForName(event.DeviceName)
+	if deviceExist && len(device.Tags) > 0 {
+		for k, v := range device.Tags {
+			event.Tags[k] = v
+		}
+	}
+}
+
+func AddReadingTags(reading *dtos.BaseReading) {
+	dr, drExist := cache.Profiles().DeviceResource(reading.ProfileName, reading.ResourceName)
+	if drExist && len(dr.Tags) > 0 {
+		if reading.Tags == nil {
+			reading.Tags = make(map[string]interface{})
+		}
+		for k, v := range dr.Tags {
+			reading.Tags[k] = v
+		}
+	}
+}
diff --git a/internal/transformer/transform.go b/internal/transformer/transform.go
index 07ee7023..e1cec2fa 100644
--- a/internal/transformer/transform.go
+++ b/internal/transformer/transform.go
@@ -1,5 +1,5 @@
 //
-// Copyright (C) 2021 IOTech Ltd
+// Copyright (C) 2021-2023 IOTech Ltd
 //
 // SPDX-License-Identifier: Apache-2.0
 
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/cache"
+	sdkCommon "github.com/edgexfoundry/device-sdk-go/v3/internal/common"
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/container"
 	"github.com/edgexfoundry/device-sdk-go/v3/pkg/models"
 
@@ -113,6 +114,7 @@ func CommandValuesToEventDTO(cvs []*models.CommandValue, deviceName string, sour
 		if config.Writable.Reading.ReadingUnits {
 			reading.Units = dr.Properties.Units
 		}
+		sdkCommon.AddReadingTags(&reading)
 		readings = append(readings, reading)
 
 		if cv.Type == common.ValueTypeBinary {
@@ -131,6 +133,7 @@ func CommandValuesToEventDTO(cvs []*models.CommandValue, deviceName string, sour
 		eventDTO.Readings = readings
 		eventDTO.Origin = origin
 		eventDTO.Tags = tags
+		sdkCommon.AddEventTags(&eventDTO)
 
 		return &eventDTO, nil
 	} else {


Test patch:
diff --git a/internal/common/utils_test.go b/internal/common/utils_test.go
index 70a0f38d..5f9eefdf 100644
--- a/internal/common/utils_test.go
+++ b/internal/common/utils_test.go
@@ -6,40 +6,57 @@
 package common
 
 import (
+	"context"
 	"errors"
 	"testing"
 
 	bootstrapContainer "github.com/edgexfoundry/go-mod-bootstrap/v3/bootstrap/container"
 	"github.com/edgexfoundry/go-mod-bootstrap/v3/bootstrap/interfaces/mocks"
 	"github.com/edgexfoundry/go-mod-bootstrap/v3/di"
+	clientMocks "github.com/edgexfoundry/go-mod-core-contracts/v3/clients/interfaces/mocks"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/clients/logger"
 	mocks2 "github.com/edgexfoundry/go-mod-core-contracts/v3/clients/logger/mocks"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/common"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/dtos"
 	"github.com/edgexfoundry/go-mod-core-contracts/v3/dtos/requests"
+	"github.com/edgexfoundry/go-mod-core-contracts/v3/dtos/responses"
 	msgMocks "github.com/edgexfoundry/go-mod-messaging/v3/messaging/mocks"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-	"github.com/stretchr/testify/require"
 
+	"github.com/edgexfoundry/device-sdk-go/v3/internal/cache"
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/config"
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/container"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
 )
 
 const (
-	TestDevice         = "testDevice"
-	TestProfile        = "testProfile"
-	TestDeviceResource = "testResource"
-	TestDeviceCommand  = "testCommand"
-	testUUIDString     = "ca93c8fa-9919-4ec5-85d3-f81b2b6a7bc1"
+	TestDeviceService             = "testDeviceService"
+	TestDeviceWithTags            = "testDeviceWithTags"
+	TestDeviceWithoutTags         = "testDeviceWithoutTags"
+	TestProfile                   = "testProfile"
+	TestDeviceResourceWithTags    = "testResourceWithTags"
+	TestDeviceResourceWithoutTags = "testResourceWithoutTags"
+	TestDeviceCommandWithTags     = "testCommandWithTags"
+	TestDeviceCommandWithoutTags  = "testCommandWithoutTags"
+	TestResourceTagName           = "testResourceTagName"
+	TestResourceTagValue          = "testResourceTagValue"
+	TestCommandTagName            = "testCommandTagName"
+	TestCommandTagValue           = "testCommandTagValue"
+	TestDeviceTagName             = "testDeviceTagName"
+	TestDeviceTagValue            = "testDeviceTagValue"
+	TestDuplicateTagName          = "testDuplicateTagName"
+
+	testUUIDString = "ca93c8fa-9919-4ec5-85d3-f81b2b6a7bc1"
 )
 
 var TestProtocols map[string]dtos.ProtocolProperties
 
 func buildEvent() dtos.Event {
-	event := dtos.NewEvent(TestProfile, TestDevice, TestDeviceCommand)
+	event := dtos.NewEvent(TestProfile, TestDeviceWithTags, TestDeviceCommandWithTags)
 	value := string(make([]byte, 1000))
-	_ = event.AddSimpleReading(TestDeviceResource, common.ValueTypeString, value)
+	_ = event.AddSimpleReading(TestDeviceResourceWithTags, common.ValueTypeString, value)
 	event.Id = testUUIDString
 	event.Readings[0].Id = testUUIDString
 	return event
@@ -50,6 +67,59 @@ func NewMockDIC() *di.Container {
 		Device: config.DeviceInfo{MaxCmdOps: 1},
 	}
 
+	devices := responses.MultiDevicesResponse{
+		Devices: []dtos.Device{
+			{
+				Name:        TestDeviceWithTags,
+				ProfileName: TestProfile,
+				Tags: dtos.Tags{
+					TestDeviceTagName:    TestDeviceTagValue,
+					TestDuplicateTagName: TestDeviceTagValue,
+				},
+			},
+			{
+				Name:        TestDeviceWithoutTags,
+				ProfileName: TestProfile,
+			},
+		},
+	}
+	dcMock := &clientMocks.DeviceClient{}
+	dcMock.On("DevicesByServiceName", context.Background(), TestDeviceService, 0, -1).Return(devices, nil)
+
+	profile := responses.DeviceProfileResponse{
+		Profile: dtos.DeviceProfile{
+			DeviceProfileBasicInfo: dtos.DeviceProfileBasicInfo{Name: TestProfile},
+			DeviceResources: []dtos.DeviceResource{
+				{
+					Name: TestDeviceResourceWithTags,
+					Tags: dtos.Tags{
+						TestResourceTagName: TestResourceTagValue,
+					},
+				},
+				{
+					Name: TestDeviceResourceWithoutTags,
+				},
+			},
+			DeviceCommands: []dtos.DeviceCommand{
+				{
+					Name: TestDeviceCommandWithTags,
+					Tags: dtos.Tags{
+						TestCommandTagName:   TestCommandTagValue,
+						TestDuplicateTagName: TestCommandTagValue,
+					},
+				},
+				{
+					Name: TestDeviceCommandWithoutTags,
+				},
+			},
+		},
+	}
+	dpcMock := &clientMocks.DeviceProfileClient{}
+	dpcMock.On("DeviceProfileByName", context.Background(), TestProfile).Return(profile, nil)
+
+	pwcMock := &clientMocks.ProvisionWatcherClient{}
+	pwcMock.On("ProvisionWatchersByServiceName", context.Background(), TestDeviceService, 0, -1).Return(responses.MultiProvisionWatchersResponse{}, nil)
+
 	return di.NewContainer(di.ServiceConstructorMap{
 		bootstrapContainer.LoggingClientInterfaceName: func(get di.Get) interface{} {
 			return logger.NewMockClient()
@@ -57,6 +127,15 @@ func NewMockDIC() *di.Container {
 		container.ConfigurationName: func(get di.Get) interface{} {
 			return configuration
 		},
+		bootstrapContainer.DeviceClientName: func(get di.Get) interface{} {
+			return dcMock
+		},
+		bootstrapContainer.DeviceProfileClientName: func(get di.Get) interface{} {
+			return dpcMock
+		},
+		bootstrapContainer.ProvisionWatcherClientName: func(get di.Get) interface{} {
+			return pwcMock
+		},
 	})
 }
 
@@ -178,3 +257,84 @@ func TestInitializeSentMetrics(t *testing.T) {
 		})
 	}
 }
+
+func TestAddReadingTags(t *testing.T) {
+	dic := NewMockDIC()
+	edgexErr := cache.InitCache(TestDeviceService, dic)
+	require.NoError(t, edgexErr)
+	readingWithTags, err := dtos.NewSimpleReading(TestProfile, TestDeviceWithTags, TestDeviceResourceWithTags, common.ValueTypeString, "")
+	require.NoError(t, err)
+	readingWithoutTags, err := dtos.NewSimpleReading(TestProfile, TestDeviceWithTags, TestDeviceResourceWithoutTags, common.ValueTypeString, "")
+	require.NoError(t, err)
+	readingResourceNotFound, err := dtos.NewSimpleReading(TestProfile, TestDeviceWithTags, "notFound", common.ValueTypeString, "")
+	require.NoError(t, err)
+
+	tests := []struct {
+		Name         string
+		Reading      dtos.BaseReading
+		ExpectedTags dtos.Tags
+	}{
+		{"Happy Path", readingWithTags, dtos.Tags{TestResourceTagName: TestResourceTagValue}},
+		{"No Tags", readingWithoutTags, nil},
+		{"Resource Not Found", readingResourceNotFound, nil},
+	}
+
+	for _, test := range tests {
+		t.Run(test.Name, func(t *testing.T) {
+			AddReadingTags(&test.Reading)
+			if test.ExpectedTags != nil {
+				require.NotEmpty(t, test.Reading.Tags)
+				assert.Equal(t, test.ExpectedTags, test.Reading.Tags)
+			} else {
+				assert.Empty(t, test.Reading.Tags)
+			}
+		})
+	}
+}
+
+func TestAddEventTags(t *testing.T) {
+	dic := NewMockDIC()
+	edgexErr := cache.InitCache(TestDeviceService, dic)
+	require.NoError(t, edgexErr)
+
+	expectedDeviceTags := dtos.Tags{TestDeviceTagName: TestDeviceTagValue, TestDuplicateTagName: TestDeviceTagValue}
+	expectedCommandTags := dtos.Tags{TestCommandTagName: TestCommandTagValue, TestDuplicateTagName: TestCommandTagValue}
+
+	tests := []struct {
+		Name                    string
+		Event                   dtos.Event
+		ExpectToHaveDeviceTags  bool
+		ExpectToHaveCommandTags bool
+	}{
+		{"Happy Path", dtos.NewEvent(TestProfile, TestDeviceWithTags, TestDeviceCommandWithTags), true, true},
+		{"No Tags", dtos.NewEvent(TestProfile, TestDeviceWithoutTags, TestDeviceCommandWithoutTags), false, false},
+		{"No Device Tags", dtos.NewEvent(TestProfile, TestDeviceWithoutTags, TestDeviceCommandWithTags), false, true},
+		{"No Command Tags", dtos.NewEvent(TestProfile, TestDeviceWithTags, TestDeviceCommandWithoutTags), true, false},
+	}
+
+	for _, test := range tests {
+		t.Run(test.Name, func(t *testing.T) {
+			AddEventTags(&test.Event)
+			if test.ExpectToHaveDeviceTags && test.ExpectToHaveCommandTags {
+				expectedCommandTags[TestDuplicateTagName] = TestDeviceTagValue
+			} else {
+				expectedCommandTags[TestDuplicateTagName] = TestCommandTagValue
+			}
+			if !test.ExpectToHaveDeviceTags && !test.ExpectToHaveCommandTags {
+				require.Empty(t, test.Event.Tags)
+			} else {
+				require.NotEmpty(t, test.Event.Tags)
+			}
+			if test.ExpectToHaveDeviceTags {
+				assert.Subset(t, test.Event.Tags, expectedDeviceTags)
+			} else {
+				assert.NotSubset(t, test.Event.Tags, expectedDeviceTags)
+			}
+			if test.ExpectToHaveCommandTags {
+				assert.Subset(t, test.Event.Tags, expectedCommandTags)
+			} else {
+				assert.NotSubset(t, test.Event.Tags, expectedCommandTags)
+			}
+		})
+	}
+}