Repository: edgexfoundry/device-sdk-go
Pull request #1594 (2024-07-05 09:45:38)

feat: Add /profilescan API
<!-- 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?)
- [ ] 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? -->

## 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 -->

cloudxxx8: this readme should also be updated
https://github.com/edgexfoundry/device-sdk-go/blob/main/example/README.md

Patch:
diff --git a/example/README.md b/example/README.md
index 5c9ad4ba..e1175e3c 100644
--- a/example/README.md
+++ b/example/README.md
@@ -4,16 +4,15 @@ The included device-simple example device service demonstrates basic usage of de
 
 ## Protocol Driver
 
-To make a functional Device Service, developers must implement the [ProtocolDriver](../pkg/models/protocoldriver.go) interface. 
+To make a functional Device Service, developers must implement the [ProtocolDriver](../pkg/interfaces/protocoldriver.go) interface. 
 `ProtocolDriver` interface provides abstraction logic of how to interact with Device through specific protocol. See [simpledriver.go](driver/simpledriver.go) for example.
 
-## Protocol Discovery
+## Device Discovery
 
 Some device protocols allow for devices to be discovered automatically.
 A Device Service may include a capability for discovering devices and creating the corresponding Device objects within EdgeX.  
 
-To enable device discovery, developers need to implement the [ProtocolDiscovery](../pkg/models/protocoldiscovery.go) interface.
-The `ProtocolDiscovery` interface defines a single `Discover` method which is used to trigger protocol-specific device discovery.
+To enable device discovery, developers need to implement the `Discover` method which is used to trigger protocol-specific device discovery.
 Any devices found as a result of discovery being triggered are returned to the SDK via a go channel, passed to the implementation as a parameter during Initialization.
 New discovery attempts may be started as soon as a slice of devices is submitted, so in oreder to avoid the service being congested by concurrent discovery.
   
@@ -36,5 +35,24 @@ Dynamic Device Discovery is triggered either by internal timer(see `Device/Disco
 
 The following steps show how to trigger discovery on device-simple:
 1. Set `Device/Discovery/Enabled` to true in [configuration file](cmd/device-simple/res/configuration.yaml)
-2. Trigger discovery by sending POST request to DS endpoint: http://edgex-device-simple:59999/api/v2/discovery
-3. `Simple-Device02` will be discovered and added to EdgeX.
\ No newline at end of file
+2. Trigger discovery by sending POST request to DS endpoint: http://edgex-device-simple:59999/api/v3/discovery
+3. `Simple-Device02` will be discovered and added to EdgeX.
+
+## ProfileScan
+Some device protocols allow for devices to discover profiles automatically.
+A Device Service may include a capability for discovering device profiles and creating the associated Device Profile objects for devices within EdgeX.
+
+To enable profile scan, developers need to implement the [ProfileScan](../pkg/interfaces/protocolprofile.go) interface.
+The `ProfileScan` interface defines a single `ProfileScan` method which is used to trigger device-specific profile scan.
+The device profile found as a result of profile scan is returned to the SDK, and the SDK will then create the device profile and update the device to use the profile.
+
+The following steps show how to trigger profile scan on device-simple:
+1. A pre-define device `ProfileScan-Simple-Device` is created without an associated device profile.
+2. Trigger profile scan by sending POST request to DS endpoint: http://edgex-device-simple:59999/api/v3/profilescan with payload:
+   ```json
+   {
+     "deviceName": "ProfileScan-Simple-Device",
+     "profileName": "ProfileScan-Test-Profile"
+   }
+   ```
+3. Device Profile `ProfileScan-Test-Profile` will be added to EdgeX and `ProfileScan-Simple-Device` will be updated to use the device profile.
diff --git a/example/cmd/device-simple/res/configuration.yaml b/example/cmd/device-simple/res/configuration.yaml
index 3d429779..28c8f8a0 100644
--- a/example/cmd/device-simple/res/configuration.yaml
+++ b/example/cmd/device-simple/res/configuration.yaml
@@ -23,6 +23,9 @@ Device:
   DevicesDir: ./res/devices
   # Only needed if device service implements auto provisioning
   ProvisionWatchersDir: ./res/provisionwatchers
+  Discovery:
+    Enabled: false
+    Interval: "30s"
 # Example structured custom configuration
 SimpleCustom:
   OnImageLocation: ./res/on.png
diff --git a/example/cmd/device-simple/res/devices/simple-device.yml b/example/cmd/device-simple/res/devices/simple-device.yml
index 75ba733e..ba45f9fe 100644
--- a/example/cmd/device-simple/res/devices/simple-device.yml
+++ b/example/cmd/device-simple/res/devices/simple-device.yml
@@ -15,3 +15,8 @@ deviceList:
       - interval: 30s
         onChange: false
         sourceName: Image
+  - name: ProfileScan-Simple-Device
+    protocols:
+      other:
+        Address: simple01
+        Port: 300
diff --git a/example/driver/simpledriver.go b/example/driver/simpledriver.go
index d31824bf..77d24aae 100644
--- a/example/driver/simpledriver.go
+++ b/example/driver/simpledriver.go
@@ -1,7 +1,7 @@
 // -*- Mode: Go; indent-tabs-mode: t -*-
 //
 // Copyright (C) 2018 Canonical Ltd
-// Copyright (C) 2018-2023 IOTech Ltd
+// Copyright (C) 2018-2024 IOTech Ltd
 //
 // SPDX-License-Identifier: Apache-2.0
 
@@ -349,3 +349,7 @@ func (s *SimpleDriver) ValidateDevice(device models.Device) error {
 
 	return nil
 }
+
+func (s *SimpleDriver) ProfileScan(payload sdkModels.ProfileScanRequest) (models.DeviceProfile, error) {
+	return models.DeviceProfile{Name: payload.ProfileName}, nil
+}
diff --git a/go.mod b/go.mod
index de191e0b..775e89c1 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.21
 require (
 	github.com/OneOfOne/xxhash v1.2.8
 	github.com/edgexfoundry/go-mod-bootstrap/v3 v3.2.0-dev.40
-	github.com/edgexfoundry/go-mod-core-contracts/v3 v3.2.0-dev.26
+	github.com/edgexfoundry/go-mod-core-contracts/v3 v3.2.0-dev.30
 	github.com/edgexfoundry/go-mod-messaging/v3 v3.2.0-dev.28
 	github.com/google/uuid v1.6.0
 	github.com/hashicorp/go-multierror v1.1.1
@@ -125,14 +125,14 @@ require (
 	go.opentelemetry.io/otel v1.27.0 // indirect
 	go.opentelemetry.io/otel/metric v1.27.0 // indirect
 	go.opentelemetry.io/otel/trace v1.27.0 // indirect
-	golang.org/x/crypto v0.24.0 // indirect
+	golang.org/x/crypto v0.25.0 // indirect
 	golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
 	golang.org/x/mod v0.17.0 // indirect
-	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/net v0.27.0 // indirect
 	golang.org/x/oauth2 v0.20.0 // indirect
 	golang.org/x/sync v0.7.0 // indirect
-	golang.org/x/sys v0.21.0 // indirect
-	golang.org/x/term v0.21.0 // indirect
+	golang.org/x/sys v0.22.0 // indirect
+	golang.org/x/term v0.22.0 // indirect
 	golang.org/x/text v0.16.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
 	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
diff --git a/go.sum b/go.sum
index c7cfa2eb..b6047751 100644
--- a/go.sum
+++ b/go.sum
@@ -90,8 +90,8 @@ github.com/edgexfoundry/go-mod-bootstrap/v3 v3.2.0-dev.40 h1:8+MADrgY4iWqqlC05G0
 github.com/edgexfoundry/go-mod-bootstrap/v3 v3.2.0-dev.40/go.mod h1:AMCtBr/Ya3lwZ4cCFWynWulQPbxRwrbkDglb/LA3XZg=
 github.com/edgexfoundry/go-mod-configuration/v3 v3.2.0-dev.9 h1:CTqf1S7vT2ciwhryWvNFfSqiElfJ98XG/p0uzjdJxWM=
 github.com/edgexfoundry/go-mod-configuration/v3 v3.2.0-dev.9/go.mod h1:t3LhBR4JUZQprOYjfy8wQaML1nXl7Ou1cXMRFlx5Bi8=
-github.com/edgexfoundry/go-mod-core-contracts/v3 v3.2.0-dev.26 h1:7b7jMJcF/EEV8yf203q8WjrM5VMPY/DLxWwrKWnasQk=
-github.com/edgexfoundry/go-mod-core-contracts/v3 v3.2.0-dev.26/go.mod h1:DXNOFlESZek+NiNTSAsXTAOj/DEhpe6jMIbQ5RpnElo=
+github.com/edgexfoundry/go-mod-core-contracts/v3 v3.2.0-dev.30 h1:85GeokwX7dRl5egWGWvoLuAkUc8O4AmYVm8suKh0wqI=
+github.com/edgexfoundry/go-mod-core-contracts/v3 v3.2.0-dev.30/go.mod h1:d/FCa9Djq/pb7RYGEEhrR7fnKo+JK5IQ2YGW4LIHAqE=
 github.com/edgexfoundry/go-mod-messaging/v3 v3.2.0-dev.28 h1:FzvB/oA6kXV/Wg800+Vlc8l4d4ti6Tp/6LFhgfDZ4qY=
 github.com/edgexfoundry/go-mod-messaging/v3 v3.2.0-dev.28/go.mod h1:K2sKQWgW6Hs7BQK1iNZWLNo9bxvBYltWfNRcjKya/G4=
 github.com/edgexfoundry/go-mod-registry/v3 v3.2.0-dev.10 h1:FFUscEUSc8H+aWzD+set6rSrW65j/blvVhi22vDGsRE=
@@ -619,8 +619,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
-golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -711,8 +711,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
-golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -817,8 +817,8 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
-golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -826,8 +826,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
+golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/internal/application/profilescan.go b/internal/application/profilescan.go
new file mode 100644
index 00000000..30feec89
--- /dev/null
+++ b/internal/application/profilescan.go
@@ -0,0 +1,68 @@
+//
+// Copyright (C) 2024 IOTech Ltd
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package application
+
+import (
+	"context"
+	"sync"
+
+	"github.com/google/uuid"
+
+	"github.com/edgexfoundry/device-sdk-go/v3/pkg/interfaces"
+	sdkModels "github.com/edgexfoundry/device-sdk-go/v3/pkg/models"
+	bootstrapContainer "github.com/edgexfoundry/go-mod-bootstrap/v3/bootstrap/container"
+	"github.com/edgexfoundry/go-mod-bootstrap/v3/di"
+	"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"
+)
+
+type profileScanLocker struct {
+	mux     sync.Mutex
+	busyMap map[string]bool
+}
+
+var locker = profileScanLocker{busyMap: make(map[string]bool)}
+
+func ProfileScanWrapper(busy chan bool, ps interfaces.ProfileScan, req sdkModels.ProfileScanRequest, dic *di.Container) {
+	locker.mux.Lock()
+	b := locker.busyMap[req.DeviceName]
+	busy <- b
+	if b {
+		locker.mux.Unlock()
+		return
+	}
+	locker.busyMap[req.DeviceName] = true
+	locker.mux.Unlock()
+
+	lc := bootstrapContainer.LoggingClientFrom(dic.Get)
+	dpc := bootstrapContainer.DeviceProfileClientFrom(dic.Get)
+	dc := bootstrapContainer.DeviceClientFrom(dic.Get)
+	ctx := context.WithValue(context.Background(), common.CorrelationHeader, uuid.NewString()) //nolint: staticcheck
+
+	lc.Debugf("profile scan triggered with device name '%s' and profile name '%s'", req.DeviceName, req.ProfileName)
+	profile, err := ps.ProfileScan(req)
+	if err != nil {
+		lc.Errorf("failed to trigger profile scan: %v", err.Error())
+	}
+	// Add profile to metadata
+	profileReq := requests.NewDeviceProfileRequest(dtos.FromDeviceProfileModelToDTO(profile))
+	_, err = dpc.Add(ctx, []requests.DeviceProfileRequest{profileReq})
+	if err != nil {
+		lc.Errorf("failed to add device profile '%s': %v", profile.Name, err)
+	}
+	// Update device
+	deviceReq := requests.NewUpdateDeviceRequest(dtos.UpdateDevice{Name: &req.DeviceName, ProfileName: &profile.Name})
+	_, err = dc.Update(ctx, []requests.UpdateDeviceRequest{deviceReq})
+	if err != nil {
+		lc.Errorf("failed to update device '%s' with profile '%s': %v", req.DeviceName, profile.Name, err)
+	}
+
+	// ReleaseLock
+	locker.mux.Lock()
+	locker.busyMap[req.DeviceName] = false
+	locker.mux.Unlock()
+}
diff --git a/internal/autodiscovery/autodiscovery.go b/internal/autodiscovery/autodiscovery.go
index 0cca4bdb..a03ad01f 100644
--- a/internal/autodiscovery/autodiscovery.go
+++ b/internal/autodiscovery/autodiscovery.go
@@ -33,8 +33,11 @@ func BootstrapHandler(
 		runDiscovery = false
 	}
 	duration, err := time.ParseDuration(configuration.Device.Discovery.Interval)
-	if err != nil || duration <= 0 {
-		lc.Info("AutoDiscovery stopped: interval error in configuration")
+	if err != nil {
+		lc.Errorf("AutoDiscovery stopped: interval %s error in configuration: %v", configuration.Device.Discovery.Interval, err)
+		runDiscovery = false
+	} else if duration <= 0 {
+		lc.Info("AutoDiscovery schedule is not started: interval <= 0")
 		runDiscovery = false
 	}
 
diff --git a/internal/container/deviceservice.go b/internal/container/deviceservice.go
index 8c9065e6..8df0dd74 100644
--- a/internal/container/deviceservice.go
+++ b/internal/container/deviceservice.go
@@ -22,6 +22,9 @@ var ProtocolDriverName = di.TypeInstanceToName((*interfaces.ProtocolDriver)(nil)
 // AutoEventManagerName contains the name of autoevent manager implementation in the DIC
 var AutoEventManagerName = di.TypeInstanceToName((*interfaces.AutoEventManager)(nil))
 
+// ProfileScanName contains the name of profile scan implementation in the DIC.
+var ProfileScanName = di.TypeInstanceToName((*interfaces.ProfileScan)(nil))
+
 // DeviceServiceFrom helper function queries the DIC and returns device service struct.
 func DeviceServiceFrom(get di.Get) *models.DeviceService {
 	return get(DeviceServiceName).(*models.DeviceService)
@@ -36,3 +39,12 @@ func ProtocolDriverFrom(get di.Get) interfaces.ProtocolDriver {
 func AutoEventManagerFrom(get di.Get) interfaces.AutoEventManager {
 	return get(AutoEventManagerName).(interfaces.AutoEventManager)
 }
+
+// ProfileScanFrom helper function queries the DIC and returns profile scan implementation.
+func ProfileScanFrom(get di.Get) interfaces.ProfileScan {
+	casted, ok := get(ProfileScanName).(interfaces.ProfileScan)
+	if ok {
+		return casted
+	}
+	return nil
+}
diff --git a/internal/controller/http/discovery.go b/internal/controller/http/discovery.go
index 16a14062..6907ab6e 100644
--- a/internal/controller/http/discovery.go
+++ b/internal/controller/http/discovery.go
@@ -1,22 +1,29 @@
 // -*- Mode: Go; indent-tabs-mode: t -*-
 //
-// Copyright (C) 2020-2023 IOTech Ltd
+// Copyright (C) 2020-2024 IOTech Ltd
 //
 // SPDX-License-Identifier: Apache-2.0
 
 package http
 
 import (
+	"fmt"
+	"io"
 	"net/http"
+	"time"
 
-	"github.com/edgexfoundry/go-mod-core-contracts/v3/common"
-	"github.com/edgexfoundry/go-mod-core-contracts/v3/errors"
-	"github.com/edgexfoundry/go-mod-core-contracts/v3/models"
+	"github.com/labstack/echo/v4"
 
+	"github.com/edgexfoundry/device-sdk-go/v3/internal/application"
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/autodiscovery"
+	"github.com/edgexfoundry/device-sdk-go/v3/internal/cache"
 	"github.com/edgexfoundry/device-sdk-go/v3/internal/container"
-
-	"github.com/labstack/echo/v4"
+	sdkModels "github.com/edgexfoundry/device-sdk-go/v3/pkg/models"
+	"github.com/edgexfoundry/go-mod-bootstrap/v3/di"
+	"github.com/edgexfoundry/go-mod-core-contracts/v3/common"
+	"github.com/edgexfoundry/go-mod-core-contracts/v3/dtos/requests"
+	"github.com/edgexfoundry/go-mod-core-contracts/v3/errors"
+	"github.com/edgexfoundry/go-mod-core-contracts/v3/models"
 )
 
 func (c *RestController) Discovery(e echo.Context) error {
@@ -39,3 +46,77 @@ func (c *RestController) Discovery(e echo.Context) error {
 	go autodiscovery.DiscoveryWrapper(driver, c.lc)
 	return c.sendResponse(writer, request, common.ApiDiscoveryRoute, nil, http.StatusAccepted)
 }
+
+func (c *RestController) ProfileScan(e echo.Context) error {
+	request := e.Request()
+	writer := e.Response()
+	if request.Body != nil {
+		defer func() { _ = request.Body.Close() }()
+	}
+
+	body, err := io.ReadAll(request.Body)
+	if err != nil {
+		edgexErr := errors.NewCommonEdgeX(errors.KindServerError, "Failed to read request body", err)
+		return c.sendEdgexError(writer, request, edgexErr, common.ApiProfileScan)
+	}
+
+	ps := container.ProfileScanFrom(c.dic.Get)
+	if ps == nil {
+		edgexErr := errors.NewCommonEdgeX(errors.KindNotImplemented, "Profile scan is not implemented", nil)
+		return c.sendEdgexError(writer, request, edgexErr, common.ApiProfileScan)
+	}
+
+	req, edgexErr := profileScanValidation(body, c.dic)
+	if edgexErr != nil {
+		return c.sendEdgexError(writer, request, edgexErr, common.ApiProfileScan)
+	}
+
+	busy := make(chan bool)
+	go application.ProfileScanWrapper(busy, ps, req, c.dic)
+	b := <-busy
+	if b {
+		edgexErr := errors.NewCommonEdgeX(errors.KindStatusConflict, fmt.Sprintf("Another profile scan process for %s is currently running", req.DeviceName), nil)
+		return c.sendEdgexError(writer, request, edgexErr, common.ApiProfileScan)
+	}
+	return c.sendResponse(writer, request, common.ApiProfileScan, nil, http.StatusAccepted)
+}
+
+func profileScanValidation(request []byte, dic *di.Container) (sdkModels.ProfileScanRequest, errors.EdgeX) {
+	var r sdkModels.ProfileScanRequest
+	// check device service AdminState
+	ds := container.DeviceServiceFrom(dic.Get)
+	if ds.AdminState == models.Locked {
+		return r, errors.NewCommonEdgeX(errors.KindServiceLocked, "service locked", nil)
+	}
+
+	// parse request payload
+	var req requests.ProfileScanRequest
+	err := req.UnmarshalJSON(request)
+	if err != nil {
+		return r, errors.NewCommonEdgeX(errors.KindContractInvalid, "failed to parse request body", err)
+	}
+
+	// check requested device exists
+	device, ok := cache.Devices().ForName(req.DeviceName)
+	if !ok {
+		return r, errors.NewCommonEdgeX(errors.KindEntityDoesNotExist, fmt.Sprintf("device %s not found", req.DeviceName), nil)
+	}
+
+	// check profile should not exist
+	if req.ProfileName != "" {
+		if _, exist := cache.Profiles().ForName(req.ProfileName); exist {
+			return r, errors.NewCommonEdgeX(errors.KindStatusConflict, fmt.Sprintf("profile name %s is duplicated", req.ProfileName), nil)
+		}
+	} else {
+		req.ProfileName = fmt.Sprintf("%s_profile_%d", req.DeviceName, time.Now().UnixNano())
+	}
+
+	r = sdkModels.ProfileScanRequest{
+		DeviceName:  req.DeviceName,
+		ProfileName: req.ProfileName,
+		Options:     req.Options,
+		Protocols:   device.Protocols,
+	}
+
+	return r, nil
+}
diff --git a/internal/controller/http/restrouter.go b/internal/controller/http/restrouter.go
index 988a7d68..27d3c142 100644
--- a/internal/controller/http/restrouter.go
+++ b/internal/controller/http/restrouter.go
@@ -60,6 +60,7 @@ func (c *RestController) InitRestRoutes() {
 
 	// discovery
 	c.addReservedRoute(common.ApiDiscoveryRoute, c.Discovery, http.MethodPost, authenticationHook)
+	c.addReservedRoute(common.ApiProfileScan, c.ProfileScan, http.MethodPost, authenticationHook)
 	// device command
 	c.addReservedRoute(common.ApiDeviceNameCommandNameEchoRoute, c.GetCommand, http.MethodGet, authenticationHook)
 	c.addReservedRoute(common.ApiDeviceNameCommandNameEchoRoute, c.SetCommand, http.MethodPut, authenticationHook)
diff --git a/openapi/v3/device-sdk.yaml b/openapi/v3/device-sdk.yaml
index 204b4bf0..a7a14b33 100644
--- a/openapi/v3/device-sdk.yaml
+++ b/openapi/v3/device-sdk.yaml
@@ -40,6 +40,8 @@ components:
           description: "A version number shows the API version in DTOs."
           type: string
           example: "v3"
+      required:
+        - apiVersion
     BaseResponse:
       description: "Defines basic properties which all use-case specific response DTO instances should support"
       type: object
@@ -325,6 +327,30 @@ components:
         serviceName:
           description: "Outputs the name of the service the response is from"
           type: string
+    ProfileScanRequest:
+      allOf:
+        - $ref: '#/components/schemas/BaseRequest'
+      description: Defines the information for the profile scan
+      type: object
+      properties:
+        deviceName:
+          description: Specifies the device to generate the profile
+          type: string
+          example: "Test-Device"
+        profileName:
+          description: Specifies the unique profile name. Default format is {deviceName}_profile_{timestamp}
+          type: string
+          example: "Test-Device_profile_1720169510"
+        options:
+          type: object
+          example: {
+            "DiscoverMode": "All",
+            "DiscoverProperties": [ 103, 117 ],
+            "DiscoverObjects": [ 1, 2, 3, 4, 5]
+          }
+      required:
+        - deviceName
+        - options
 
   parameters:
     correlatedRequestHeader:
@@ -578,6 +604,114 @@ paths:
               schema:
                 $ref: '#/components/schemas/ErrorResponse'
 
+  /profilescan:
+    parameters:
+      - $ref: '#/components/parameters/correlatedRequestHeader'
+    post:
+      description: Run the discovery request to generate a associated device profile for a specified device.
+      requestBody:
+        content:
+          application/json:
+            schema:
+              allOf:
+                - $ref: '#/components/schemas/ProfileScanRequest'
+        required: true
+      responses:
+        '202':
+          description: "Request has been accepted"
+          headers:
+            X-Correlation-ID:
+              $ref: '#/components/headers/correlatedResponseHeader'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/BaseResponse'
+              example:
+                apiVersion: "v3"
+                requestId: "e6e8a2f4-eb14-4649-9e2b-175247911369"
+                statusCode: 202
+        '400':
+          description: "Request is in an invalid state"
+          headers:
+            X-Correlation-ID:
+              $ref: '#/components/headers/correlatedResponseHeader'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+              example:
+                value:
+                  apiVersion: "v3"
+                  requestId: "73f0932c-0148-11eb-adc1-0242ac120002"
+                  statusCode: 400
+                  message: "Bad Request"
+        '404':
+          description: "The requested resource does not exist."
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+              example:
+                value:
+                  apiVersion: "v3"
+                  requestId: "84c9489c-0148-11eb-adc1-0242ac120002"
+                  statusCode: 404
+                  message: "Not Found"
+        '409':
+          description: "Conflict detected. Profile name must be universally unique or previous request is still processing."
+          headers:
+            X-Correlation-ID:
+              $ref: '#/components/headers/correlatedResponseHeader'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+              example:
+                value:
+                  apiVersion: "v3"
+                  requestId: "8a41b3f4-0148-11eb-adc1-0242ac120002"
+                  statusCode: 409
+                  message: "Data Duplicate"
+        '423':
+          description: The device service is locked (admin state).
+          headers:
+            X-Correlation-ID:
+              $ref: '#/components/headers/correlatedResponseHeader'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+              example:
+                value:
+                  apiVersion: "v3"
+                  requestId: "8a41b3f4-0148-11eb-adc1-0242ac120002"
+                  statusCode: 423
+                  message: "Locked"
+        '500':
+          description: An unexpected error occurred on the server
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+              example:
+                value:
+                  apiVersion: "v3"
+                  requestId: "e6e8a2f4-eb14-4649-9e2b-175247911369"
+                  statusCode: 500
+                  message: "Internal Server Error"
+        '501':
+          description: Request is not supported
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorResponse'
+              example:
+                value:
+                  apiVersion: "v3"
+                  requestId: "e6e8a2f4-eb14-4649-9e2b-175247911369"
+                  statusCode: 501
+                  message: "Not implemented"
+
   /config:
     get:
       summary: "Returns the current configuration of the service."
diff --git a/pkg/interfaces/protocolprofile.go b/pkg/interfaces/protocolprofile.go
new file mode 100644
index 00000000..f408db87
--- /dev/null
+++ b/pkg/interfaces/protocolprofile.go
@@ -0,0 +1,15 @@
+// Copyright (C) 2024 IOTech Ltd
+
+package interfaces
+
+import (
+	sdkModels "github.com/edgexfoundry/device-sdk-go/v3/pkg/models"
+	model "github.com/edgexfoundry/go-mod-core-contracts/v3/models"
+)
+
+// ProfileScan is a low-level device-specific interface implemented
+// by device services that support dynamic profile scan.
+type ProfileScan interface {
+	// ProfileScan triggers specific device to discover device profile.
+	ProfileScan(req sdkModels.ProfileScanRequest) (model.DeviceProfile, error)
+}
diff --git a/pkg/models/profilescanrequest.go b/pkg/models/profilescanrequest.go
new file mode 100644
index 00000000..68e4e4fa
--- /dev/null
+++ b/pkg/models/profilescanrequest.go
@@ -0,0 +1,16 @@
+//
+// Copyright (C) 2024 IOTech Ltd
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package models
+
+import "github.com/edgexfoundry/go-mod-core-contracts/v3/models"
+
+// ProfileScanRequest is the struct for requesting a profile for a specified device.
+type ProfileScanRequest struct {
+	DeviceName  string
+	ProfileName string
+	Options     any
+	Protocols   map[string]models.ProtocolProperties
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index c477e18e..55806601 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -1,7 +1,7 @@
 // -*- Mode: Go; indent-tabs-mode: t -*-
 //
 // Copyright (C) 2017-2018 Canonical Ltd
-// Copyright (C) 2018-2023 IOTech Ltd
+// Copyright (C) 2018-2024 IOTech Ltd
 // Copyright (C) 2019,2023 Intel Corporation
 //
 // SPDX-License-Identifier: Apache-2.0
@@ -59,6 +59,7 @@ type deviceService struct {
 	baseServiceName    string
 	lc                 logger.LoggingClient
 	driver             interfaces.ProtocolDriver
+	profileScan        interfaces.ProfileScan
 	autoEventManager   interfaces.AutoEventManager
 	commonController   *controller.CommonController
 	controller         *restController.RestController
@@ -89,6 +90,12 @@ func NewDeviceService(serviceKey string, serviceVersion string, driver interface
 
 	service.driver = driver
 
+	if ps, ok := driver.(interfaces.ProfileScan); ok {
+		service.profileScan = ps
+	} else {
+		service.profileScan = nil
+	}
+
 	service.config = &config.ConfigurationStruct{}
 	return interfaces.DeviceServiceSDK(&service), nil
 }
@@ -110,15 +117,16 @@ func (s *deviceService) Run() error {
 	s.deviceServiceModel = &models.DeviceService{Name: s.serviceKey}
 
 	s.dic = di.NewContainer(di.ServiceConstructorMap{
-		container.ConfigurationName: func(get di.Get) interface{} {
+		container.ConfigurationName: func(get di.Get) any {
 			return s.config
 		},
-		container.DeviceServiceName: func(get di.Get) interface{} {
+		container.DeviceServiceName: func(get di.Get) any {
 			return s.deviceServiceModel
 		},
-		container.ProtocolDriverName: func(get di.Get) interface{} {
+		container.ProtocolDriverName: func(get di.Get) any {
 			return s.driver
 		},
+		container.ProfileScanName: func(get di.Get) any { return s.profileScan },
 	})
 
 	// set poolSize to config.Device.AsyncBufferSize


Test patch:
diff --git a/internal/provision/devices_test.go b/internal/provision/devices_test.go
index 17eecfcf..b0bf11de 100644
--- a/internal/provision/devices_test.go
+++ b/internal/provision/devices_test.go
@@ -24,7 +24,7 @@ func Test_processDevices(t *testing.T) {
 		secretProvider     interfaces.SecretProvider
 		expectedNumDevices int
 	}{
-		{"valid load device from file", path.Join("..", "..", "example", "cmd", "device-simple", "res", "devices", "simple-device.yml"), nil, 1},
+		{"valid load device from file", path.Join("..", "..", "example", "cmd", "device-simple", "res", "devices", "simple-device.yml"), nil, 2},
 		{"valid load devices from uri", "https://raw.githubusercontent.com/edgexfoundry/device-virtual-go/main/cmd/res/devices/devices.yaml", nil, 5},
 		{"invalid load device empty path", "", nil, 0},
 		{"invalid load device from file", path.Join("..", "..", "example", "cmd", "device-simple", "res", "devices", "bogus.yml"), nil, 0},