changeset 5853:4d932683049d get-scu tip

very first implementation of C-Get SCU
author Alain Mazy <am@orthanc.team>
date Tue, 29 Oct 2024 17:25:49 +0100
parents 7aef730c0859
children
files OrthancFramework/Resources/CodeGeneration/ErrorCodes.json OrthancFramework/Sources/DicomNetworking/DicomAssociation.cpp OrthancFramework/Sources/DicomNetworking/DicomAssociation.h OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.cpp OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.h OrthancFramework/Sources/Enumerations.cpp OrthancFramework/Sources/Enumerations.h OrthancFramework/Sources/JobsEngine/JobsEngine.cpp OrthancFramework/Sources/JobsEngine/JobsRegistry.cpp OrthancServer/CMakeLists.txt OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h OrthancServer/Resources/DicomConformanceStatement.txt OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp OrthancServer/Sources/ServerJobs/DicomGetScuJob.cpp OrthancServer/Sources/ServerJobs/DicomGetScuJob.h OrthancServer/Sources/main.cpp TODO
diffstat 17 files changed, 756 insertions(+), 3 deletions(-) [+]
line wrap: on
line diff
--- a/OrthancFramework/Resources/CodeGeneration/ErrorCodes.json	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Resources/CodeGeneration/ErrorCodes.json	Tue Oct 29 17:25:49 2024 +0100
@@ -602,6 +602,11 @@
     "Name": "NoCGetHandler", 
     "Description": "No request handler factory for DICOM C-GET SCP"
   },
+  {
+    "Code": 2045, 
+    "Name": "DicomGetUnavailable", 
+    "Description": "DicomUserConnection: The C-GET command is not supported by the remote SCP"
+  },
 
 
 
--- a/OrthancFramework/Sources/DicomNetworking/DicomAssociation.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/DicomNetworking/DicomAssociation.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -526,6 +526,35 @@
     }
   }
 
+  bool DicomAssociation::GetAssociationParameters(std::string& remoteAet,
+                                                  std::string& remoteIp,
+                                                  std::string& calledAet) const
+  {
+    T_ASC_Association& dcmtkAssoc = GetDcmtkAssociation();
+
+    DIC_AE remoteAet_C;
+    DIC_AE calledAet_C;
+    DIC_AE remoteIp_C;
+    DIC_AE calledIP_C;
+
+    if (
+#if DCMTK_VERSION_NUMBER >= 364
+      ASC_getAPTitles(dcmtkAssoc.params, remoteAet_C, sizeof(remoteAet_C), calledAet_C, sizeof(calledAet_C), NULL, 0).good() &&
+      ASC_getPresentationAddresses(dcmtkAssoc.params, remoteIp_C, sizeof(remoteIp_C), calledIP_C, sizeof(calledIP_C)).good()
+#else
+      ASC_getAPTitles(dcmtkAssoc.params, remoteAet_C, calledAet_C, NULL).good() &&
+      ASC_getPresentationAddresses(dcmtkAssoc.params, remoteIp_C, calledIP_C).good()
+#endif
+      )
+    {
+      remoteIp = std::string(/*OFSTRING_GUARD*/(remoteIp_C));
+      remoteAet = std::string(/*OFSTRING_GUARD*/(remoteAet_C));
+      calledAet = (/*OFSTRING_GUARD*/(calledAet_C));
+      return true;
+    }
+
+    return false;
+  }
     
   T_ASC_Network& DicomAssociation::GetDcmtkNetwork() const
   {
--- a/OrthancFramework/Sources/DicomNetworking/DicomAssociation.h	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/DicomNetworking/DicomAssociation.h	Tue Oct 29 17:25:49 2024 +0100
@@ -122,6 +122,10 @@
 
     T_ASC_Network& GetDcmtkNetwork() const;
 
+    bool GetAssociationParameters(std::string& remoteAet,
+                                  std::string& remoteIp,
+                                  std::string& calledAet) const;
+
     static void CheckCondition(const OFCondition& cond,
                                const DicomAssociationParameters& parameters,
                                const std::string& command);
--- a/OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -234,7 +234,7 @@
 
 
 
-  void DicomControlUserConnection::SetupPresentationContexts()
+  void DicomControlUserConnection::SetupPresentationContexts() // TODO-GET, setup only the presentation contexts that are enabled for that modality
   {
     assert(association_.get() != NULL);
     association_->ProposeGenericPresentationContext(UID_VerificationSOPClass);
@@ -243,6 +243,12 @@
     association_->ProposeGenericPresentationContext(UID_FINDStudyRootQueryRetrieveInformationModel);
     association_->ProposeGenericPresentationContext(UID_MOVEStudyRootQueryRetrieveInformationModel);
     association_->ProposeGenericPresentationContext(UID_FINDModalityWorklistInformationModel);
+    association_->ProposeGenericPresentationContext(UID_GETStudyRootQueryRetrieveInformationModel);
+    association_->ProposeGenericPresentationContext(UID_GETPatientRootQueryRetrieveInformationModel);
+    
+    // for C-GET SCU, in order to receive the C-Store message  TODO-GET: we need to refine this list based on what we know we are going to retrieve
+    association_->ProposeGenericPresentationContext(UID_ComputedRadiographyImageStorage);
+    association_->ProposeGenericPresentationContext(UID_MRImageStorage);
   }
     
 
@@ -445,6 +451,220 @@
   }
     
 
+  void DicomControlUserConnection::Get(const DicomMap& findResult,
+                                       CGetInstanceReceivedCallback instanceReceivedCallback,
+                                       void* callbackContext)
+  {
+    assert(association_.get() != NULL);
+    association_->Open(parameters_);
+
+    // TODO-GET: if findResults is the result of a C-Find, we can use the SopClassUIDs for the negotiation
+
+    std::unique_ptr<ParsedDicomFile> query(
+      ConvertQueryFields(findResult, parameters_.GetRemoteModality().GetManufacturer()));
+    DcmDataset* queryDataset = query->GetDcmtkObject().getDataset();
+
+    std::string remoteAet;
+    std::string remoteIp;
+    std::string calledAet;
+
+    association_->GetAssociationParameters(remoteAet, remoteIp, calledAet);
+
+    const char* sopClass = NULL;
+    const std::string tmp = findResult.GetValue(DICOM_TAG_QUERY_RETRIEVE_LEVEL).GetContent();
+    ResourceType level = StringToResourceType(tmp.c_str());
+    switch (level)
+    {
+      case ResourceType_Patient:
+        sopClass = UID_GETPatientRootQueryRetrieveInformationModel;
+        // DU_putStringDOElement(queryDataset, DCM_QueryRetrieveLevel, ResourceTypeToDicomQueryRetrieveLevel(ResourceType_Patient));  // TODO-GET
+        break;
+      case ResourceType_Study:
+        sopClass = UID_GETStudyRootQueryRetrieveInformationModel;
+        // DU_putStringDOElement(queryDataset, DCM_QueryRetrieveLevel, ResourceTypeToDicomQueryRetrieveLevel(ResourceType_Study));  // TODO-GET
+        break;
+      default:
+        throw OrthancException(ErrorCode_InternalError); // TODO-GET: implement series + instances
+    }
+
+    // Figure out which of the accepted presentation contexts should be used
+    int cgetPresID = ASC_findAcceptedPresentationContextID(&association_->GetDcmtkAssociation(), sopClass);
+    if (cgetPresID == 0)
+    {
+      throw OrthancException(ErrorCode_DicomGetUnavailable,
+                             "Remote AET is " + parameters_.GetRemoteModality().GetApplicationEntityTitle());
+    }
+
+    T_DIMSE_Message msgGetRequest;
+    memset((char*)&msgGetRequest, 0, sizeof(msgGetRequest));
+    msgGetRequest.CommandField = DIMSE_C_GET_RQ;
+
+    T_DIMSE_C_GetRQ* request = &(msgGetRequest.msg.CGetRQ);
+    request->MessageID = association_->GetDcmtkAssociation().nextMsgID++;
+    strncpy(request->AffectedSOPClassUID, sopClass, DIC_UI_LEN);
+    request->Priority = DIMSE_PRIORITY_MEDIUM;
+    request->DataSetType = DIMSE_DATASET_PRESENT;
+
+    {
+      OFString str;
+      CLOG(TRACE, DICOM) << "Sending Get Request:" << std::endl
+                         << DIMSE_dumpMessage(str, *request, DIMSE_OUTGOING, NULL, cgetPresID);
+    }
+    
+    OFCondition cond = DIMSE_sendMessageUsingMemoryData(
+          &(association_->GetDcmtkAssociation()), cgetPresID, &msgGetRequest, NULL /* statusDetail */, queryDataset,
+          NULL /* progress callback TODO-GET */, NULL /* callback context */, NULL /* commandSet */);
+      
+    if (cond.bad())
+    {
+        OFString tempStr;
+        CLOG(TRACE, DICOM) << "Failed sending C-GET request: " << DimseCondition::dump(tempStr, cond);
+        // return cond;
+    }
+
+    // equivalent to handleCGETSession in DCMTK
+    bool continueSession = true;
+
+    // As long we want to continue (usually, as long as we receive more objects,
+    // i.e. the final C-GET response has not arrived yet)
+    while (continueSession)
+    {
+        T_DIMSE_Message rsp;
+        // Make sure everything is zeroed (especially options)
+        memset((char*)&rsp, 0, sizeof(rsp));
+
+        // DcmDataset* statusDetail = NULL;
+        T_ASC_PresentationContextID cmdPresId = 0;
+
+        OFCondition result = DIMSE_receiveCommand(&(association_->GetDcmtkAssociation()),
+                                                  (parameters_.HasTimeout() ? DIMSE_NONBLOCKING : DIMSE_BLOCKING),
+                                                  parameters_.GetTimeout(),
+                                                  &cmdPresId,
+                                                  &rsp,
+                                                  NULL /* statusDetail */,
+                                                  NULL /* not interested in the command set */);
+
+        if (result.bad())
+        {
+          OFString tempStr;
+          CLOG(TRACE, DICOM) << "Failed receiving DIMSE command: " << DimseCondition::dump(tempStr, result);
+          // delete statusDetail;
+          break;  // TODO: return value
+        }
+        // Handle C-GET Response
+        if (rsp.CommandField == DIMSE_C_GET_RSP)
+        {
+          {
+            OFString tempStr;
+            CLOG(TRACE, DICOM) << "Received C-GET Response: " << std::endl
+              << DIMSE_dumpMessage(tempStr, rsp, DIMSE_INCOMING, NULL, cmdPresId);
+          }
+
+          // TODO-GET: for progress handler
+          // OFunique_ptr<RetrieveResponse> getRSP(new RetrieveResponse());
+          // getRSP->m_affectedSOPClassUID     = rsp.msg.CGetRSP.AffectedSOPClassUID;
+          // getRSP->m_messageIDRespondedTo    = rsp.msg.CGetRSP.MessageIDBeingRespondedTo;
+          // getRSP->m_status                  = rsp.msg.CGetRSP.DimseStatus;
+          // getRSP->m_numberOfRemainingSubops = rsp.msg.CGetRSP.NumberOfRemainingSubOperations;
+          // getRSP->m_numberOfCompletedSubops = rsp.msg.CGetRSP.NumberOfCompletedSubOperations;
+          // getRSP->m_numberOfFailedSubops    = rsp.msg.CGetRSP.NumberOfFailedSubOperations;
+          // getRSP->m_numberOfWarningSubops   = rsp.msg.CGetRSP.NumberOfWarningSubOperations;
+          // getRSP->m_statusDetail            = statusDetail;
+
+        }
+        // Handle C-STORE Request
+        else if (rsp.CommandField == DIMSE_C_STORE_RQ)
+        {
+          {
+            OFString tempStr;
+            CLOG(TRACE, DICOM) << "Received C-STORE Request: " << std::endl
+              << DIMSE_dumpMessage(tempStr, rsp, DIMSE_INCOMING, NULL, cmdPresId);
+          }
+
+          T_DIMSE_C_StoreRQ* storeRequest = &(rsp.msg.CStoreRQ);
+
+          // Check if dataset is announced correctly
+          if (rsp.msg.CStoreRQ.DataSetType == DIMSE_DATASET_NULL)
+          {
+            CLOG(WARNING, DICOM) << "C-GET SCU handler: Incoming C-STORE with no dataset";
+          }
+
+          Uint16 desiredCStoreReturnStatus = 0;
+          DcmDataset* dataObject = NULL;
+
+          // Receive dataset
+          result = DIMSE_receiveDataSetInMemory(&(association_->GetDcmtkAssociation()),
+                                                  (parameters_.HasTimeout() ? DIMSE_NONBLOCKING : DIMSE_BLOCKING),
+                                                  parameters_.GetTimeout(),
+                                                  &cmdPresId,
+                                                  &dataObject,
+                                                  NULL /*callback*/, NULL /*callbackData*/);  // TODO-GET
+
+          if (result.bad())
+          {
+            desiredCStoreReturnStatus = STATUS_STORE_Error_CannotUnderstand;
+            // TODO-GET: return ?
+          }
+          else
+          {
+            // callback the OrthancServer with the received data
+            if (instanceReceivedCallback != NULL)
+            {
+              desiredCStoreReturnStatus = instanceReceivedCallback(callbackContext, *dataObject, remoteAet, remoteIp, calledAet);
+            }
+
+            // send the Store response
+            T_DIMSE_Message storeResponse;
+            memset((char*)&storeResponse, 0, sizeof(storeResponse));
+            storeResponse.CommandField         = DIMSE_C_STORE_RSP;
+
+            T_DIMSE_C_StoreRSP& storeRsp       = storeResponse.msg.CStoreRSP;
+            storeRsp.MessageIDBeingRespondedTo = storeRequest->MessageID;
+            storeRsp.DimseStatus               = desiredCStoreReturnStatus;
+            storeRsp.DataSetType               = DIMSE_DATASET_NULL;
+
+            OFStandard::strlcpy(
+                storeRsp.AffectedSOPClassUID, storeRequest->AffectedSOPClassUID, sizeof(storeRsp.AffectedSOPClassUID));
+            OFStandard::strlcpy(
+                storeRsp.AffectedSOPInstanceUID, storeRequest->AffectedSOPInstanceUID, sizeof(storeRsp.AffectedSOPInstanceUID));
+            storeRsp.opts = O_STORE_AFFECTEDSOPCLASSUID | O_STORE_AFFECTEDSOPINSTANCEUID;
+
+            result = DIMSE_sendMessageUsingMemoryData(&(association_->GetDcmtkAssociation()), 
+                                                      cmdPresId, 
+                                                      &storeResponse, NULL /* statusDetail */, NULL /* dataObject */,
+                                                      NULL /* progress callback TODO-GET */, NULL /* callback context */, NULL /* commandSet */);
+            if (result.bad())
+            {
+              continueSession = false;
+            }
+            else
+            {
+              OFString tempStr;
+              CLOG(TRACE, DICOM) << "Sent C-STORE Response: " << std::endl
+                << DIMSE_dumpMessage(tempStr, storeResponse, DIMSE_OUTGOING, NULL, cmdPresId);
+            }
+          }
+        }
+        // Handle other DIMSE command (error since other command than GET/STORE not expected)
+        else
+        {
+          CLOG(WARNING, DICOM) << "Expected C-GET response or C-STORE request but received DIMSE command 0x"
+                               << std::hex << std::setfill('0') << std::setw(4)
+                               << static_cast<unsigned int>(rsp.CommandField);
+          
+          result          = DIMSE_BADCOMMANDTYPE;
+          continueSession = false;
+        }
+
+        // delete statusDetail; // should be NULL if not existing or added to response list
+        // statusDetail = NULL;
+    }
+    /* All responses received or break signal occurred */
+
+    // return result;
+}
+
+
   DicomControlUserConnection::DicomControlUserConnection(const DicomAssociationParameters& params) :
     parameters_(params),
     association_(new DicomAssociation)
--- a/OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.h	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.h	Tue Oct 29 17:25:49 2024 +0100
@@ -37,6 +37,14 @@
 {
   class DicomAssociation;  // Forward declaration for PImpl design pattern
   
+  typedef uint16_t (*CGetInstanceReceivedCallback)(void *callbackContext,
+                                                   DcmDataset& dataset,
+                                                   const std::string& remoteAet,
+                                                   const std::string& remoteIp,
+                                                   const std::string& calledAet
+                                                   );
+
+
   class DicomControlUserConnection : public boost::noncopyable
   {
   private:
@@ -72,6 +80,10 @@
               const DicomMap& originalFields,
               bool normalize);
 
+    void Get(const DicomMap& getQuery,
+             CGetInstanceReceivedCallback instanceReceivedCallback,
+             void* callbackContext);
+
     void Move(const std::string& targetAet,
               ResourceType level,
               const DicomMap& findResult);
--- a/OrthancFramework/Sources/Enumerations.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/Enumerations.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -369,6 +369,9 @@
       case ErrorCode_NoCGetHandler:
         return "No request handler factory for DICOM C-GET SCP";
 
+      case ErrorCode_DicomGetUnavailable:
+        return "DicomUserConnection: The C-GET command is not supported by the remote SCP";
+
       case ErrorCode_UnsupportedMediaType:
         return "Unsupported media type";
 
--- a/OrthancFramework/Sources/Enumerations.h	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/Enumerations.h	Tue Oct 29 17:25:49 2024 +0100
@@ -233,6 +233,7 @@
     ErrorCode_AlreadyExistingTag = 2042    /*!< Cannot override the value of a tag that already exists */,
     ErrorCode_NoStorageCommitmentHandler = 2043    /*!< No request handler factory for DICOM N-ACTION SCP (storage commitment) */,
     ErrorCode_NoCGetHandler = 2044    /*!< No request handler factory for DICOM C-GET SCP */,
+    ErrorCode_DicomGetUnavailable = 2045    /*!< DicomUserConnection: The C-GET command is not supported by the remote SCP */,
     ErrorCode_UnsupportedMediaType = 3000    /*!< Unsupported media type */,
     ErrorCode_START_PLUGINS = 1000000
   };
--- a/OrthancFramework/Sources/JobsEngine/JobsEngine.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/JobsEngine/JobsEngine.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -132,7 +132,10 @@
 
       if (running.IsValid())
       {
-        CLOG(INFO, JOBS) << "Executing job with priority " << running.GetPriority()
+        std::string jobType;
+        running.GetJob().GetJobType(jobType);
+
+        CLOG(INFO, JOBS) << "Executing " << jobType << " job with priority " << running.GetPriority()
                          << " in worker thread " << workerIndex << ": " << running.GetId();
 
         while (engine->IsRunning())
--- a/OrthancFramework/Sources/JobsEngine/JobsRegistry.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancFramework/Sources/JobsEngine/JobsRegistry.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -792,7 +792,10 @@
         }
       }
 
-      LOG(INFO) << "New job submitted with priority " << priority << ": " << id;
+      std::string jobType;
+      handler->GetJob().GetJobType(jobType);
+
+      LOG(INFO) << "New " << jobType << " job submitted with priority " << priority << ": " << id;
 
       if (observer_ != NULL)
       {
--- a/OrthancServer/CMakeLists.txt	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancServer/CMakeLists.txt	Tue Oct 29 17:25:49 2024 +0100
@@ -130,6 +130,7 @@
   ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/ArchiveJob.cpp
   ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/CleaningInstancesJob.cpp
   ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/DicomModalityStoreJob.cpp
+  ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/DicomGetScuJob.cpp
   ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/DicomMoveScuJob.cpp
   ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/LuaJobManager.cpp
   ${CMAKE_SOURCE_DIR}/Sources/ServerJobs/MergeStudyJob.cpp
--- a/OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h	Tue Oct 29 17:25:49 2024 +0100
@@ -323,6 +323,7 @@
     OrthancPluginErrorCode_AlreadyExistingTag = 2042    /*!< Cannot override the value of a tag that already exists */,
     OrthancPluginErrorCode_NoStorageCommitmentHandler = 2043    /*!< No request handler factory for DICOM N-ACTION SCP (storage commitment) */,
     OrthancPluginErrorCode_NoCGetHandler = 2044    /*!< No request handler factory for DICOM C-GET SCP */,
+    OrthancPluginErrorCode_DicomGetUnavailable = 2045    /*!< DicomUserConnection: The C-GET command is not supported by the remote SCP */,
     OrthancPluginErrorCode_UnsupportedMediaType = 3000    /*!< Unsupported media type */,
 
     _OrthancPluginErrorCode_INTERNAL = 0x7fffffff
--- a/OrthancServer/Resources/DicomConformanceStatement.txt	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancServer/Resources/DicomConformanceStatement.txt	Tue Oct 29 17:25:49 2024 +0100
@@ -209,6 +209,16 @@
   MOVEStudyRootQueryRetrieveInformationModel    | 1.2.840.10008.5.1.4.1.2.2.2
 
 
+-------------------
+Get SCU Conformance
+-------------------
+
+Orthanc supports the following SOP Classes as an SCU for C-Get:
+
+  GETPatientRootQueryRetrieveInformationModel    | 1.2.840.10008.5.1.4.1.2.1.3
+  GETStudyRootQueryRetrieveInformationModel      | 1.2.840.10008.5.1.4.1.2.2.3
+
+
 -----------------
 Transfer Syntaxes
 -----------------
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -36,6 +36,7 @@
 #include "../ServerContext.h"
 #include "../ServerJobs/DicomModalityStoreJob.h"
 #include "../ServerJobs/DicomMoveScuJob.h"
+#include "../ServerJobs/DicomGetScuJob.h"
 #include "../ServerJobs/OrthancPeerStoreJob.h"
 #include "../ServerToolbox.h"
 #include "../StorageCommitmentReports.h"
@@ -1623,6 +1624,80 @@
   }
 
 
+  /***************************************************************************
+   * DICOM C-Get SCU
+   ***************************************************************************/
+  
+  static void DicomGet(RestApiPostCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      OrthancRestApi::DocumentSubmitCommandsJob(call);
+      call.GetDocumentation()
+        .SetTag("Networking")
+        .SetSummary("Trigger C-GET SCU")
+        .SetDescription("Start a C-GET SCU command as a job, in order to retrieve DICOM resources "
+                        "from a remote DICOM modality whose identifier is provided in the URL: ")
+                        // "https://orthanc.uclouvain.be/book/users/rest.html#performing-c-move")
+        .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject,
+                         "A query object identifying all the DICOM resources to be retrieved", true)
+        .SetRequestField(KEY_LOCAL_AET, RestApiCallDocumentation::Type_String,
+                         "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
+                         "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
+        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
+                         "Timeout for the C-GET command, in seconds", false)
+        .SetUriArgument("id", "Identifier of the modality of interest");
+      return;
+    }
+
+    ServerContext& context = OrthancRestApi::GetContext(call);
+
+    Json::Value request;
+
+    if (!call.ParseJsonRequest(request) ||
+        request.type() != Json::objectValue ||
+        !request.isMember(KEY_QUERY) ||
+        request[KEY_QUERY].type() != Json::objectValue)
+    {
+      throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON body containing fields " +
+                             std::string(KEY_QUERY));
+    }
+
+    std::string localAet = Toolbox::GetJsonStringField  // TODO-GET: keep this ?
+      (request, KEY_LOCAL_AET, context.GetDefaultLocalApplicationEntityTitle());
+
+    const RemoteModalityParameters source =
+      MyGetModalityUsingSymbolicName(call.GetUriComponent("id", ""));
+
+    std::unique_ptr<DicomGetScuJob> job(new DicomGetScuJob(context));
+
+    job->SetQueryFormat(DicomToJsonFormat_Short);  // TODO-GET: keep this ?
+    job->SetLocalAet(localAet);
+    job->SetRemoteModality(source);
+
+        // TODO-GET: asynchronous
+        // TODO-GET: permissive
+
+
+    if (request[KEY_QUERY].isMember("PatientID"))  // TODO-GET: handle get of multiple resources + series + instances 
+    {
+      job->AddResourceToRetrieve(ResourceType_Patient, request[KEY_QUERY]["PatientID"].asString());
+    }
+    else if (request[KEY_QUERY].isMember("StudyInstanceUID"))  // TODO-GET: handle get of multiple resources + series + instances 
+    {
+      job->AddResourceToRetrieve(ResourceType_Study, request[KEY_QUERY]["StudyInstanceUID"].asString());
+    }
+
+    if (request.isMember(KEY_TIMEOUT))
+    {
+      job->SetTimeout(SerializationToolbox::ReadUnsignedInteger(request, KEY_TIMEOUT));
+    }
+
+    OrthancRestApi::GetApi(call).SubmitCommandsJob
+      (call, job.release(), true /* synchronous by default */, request);
+    return;
+  }
+
 
   /***************************************************************************
    * Orthanc Peers => Store client
@@ -2544,6 +2619,7 @@
     Register("/modalities/{id}/store", DicomStore);
     Register("/modalities/{id}/store-straight", DicomStoreStraight);  // New in 1.6.1
     Register("/modalities/{id}/move", DicomMove);
+    Register("/modalities/{id}/get", DicomGet);
     Register("/modalities/{id}/configuration", GetModalityConfiguration);  // New in 1.8.1
 
     // For Query/Retrieve
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/ServerJobs/DicomGetScuJob.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -0,0 +1,276 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "DicomGetScuJob.h"
+
+#include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
+#include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../ServerContext.h"
+#include <dcmtk/dcmnet/dimse.h>
+
+static const char* const LOCAL_AET = "LocalAet";
+static const char* const QUERY = "Query";
+static const char* const QUERY_FORMAT = "QueryFormat";  // New in 1.9.5
+static const char* const REMOTE = "Remote";
+static const char* const TIMEOUT = "Timeout";
+
+namespace Orthanc
+{
+  class DicomGetScuJob::Command : public SetOfCommandsJob::ICommand
+  {
+  private:
+    DicomGetScuJob&            that_;
+    std::unique_ptr<DicomMap>  findAnswer_;
+
+  public:
+    Command(DicomGetScuJob& that,
+            const DicomMap&  findAnswer) :
+      that_(that),
+      findAnswer_(findAnswer.Clone())
+    {
+    }
+
+    virtual bool Execute(const std::string& jobId) ORTHANC_OVERRIDE
+    {
+      that_.Retrieve(*findAnswer_);
+      return true;
+    }
+
+    virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      findAnswer_->Serialize(target);
+    }
+  };
+
+
+  class DicomGetScuJob::Unserializer :
+    public SetOfCommandsJob::ICommandUnserializer
+  {
+  private:
+    DicomGetScuJob&   that_;
+
+  public:
+    explicit Unserializer(DicomGetScuJob&  that) :
+      that_(that)
+    {
+    }
+
+    virtual ICommand* Unserialize(const Json::Value& source) const ORTHANC_OVERRIDE
+    {
+      DicomMap findAnswer;
+      findAnswer.Unserialize(source);
+      return new Command(that_, findAnswer);
+    }
+  };
+
+
+  static uint16_t InstanceReceivedHandler(void* callbackContext,
+                                          DcmDataset& dataset,
+                                          const std::string& remoteAet,
+                                          const std::string& remoteIp,
+                                          const std::string& calledAet)
+  {
+    // this code is equivalent to OrthancStoreRequestHandler
+    ServerContext* context = reinterpret_cast<ServerContext*>(callbackContext);
+
+    std::unique_ptr<DicomInstanceToStore> toStore(DicomInstanceToStore::CreateFromDcmDataset(dataset));
+    
+    if (toStore->GetBufferSize() > 0)
+    {
+      toStore->SetOrigin(DicomInstanceOrigin::FromDicomProtocol
+                         (remoteIp.c_str(), remoteAet.c_str(), calledAet.c_str()));
+
+      std::string id;
+      ServerContext::StoreResult result = context->Store(id, *toStore, StoreInstanceMode_Default);
+      return result.GetCStoreStatusCode();
+    }
+
+    return STATUS_STORE_Error_CannotUnderstand;
+  }
+
+  void DicomGetScuJob::Retrieve(const DicomMap& findAnswer)
+  {
+    if (connection_.get() == NULL)
+    {
+      connection_.reset(new DicomControlUserConnection(parameters_));
+    }
+    
+    connection_->Get(findAnswer, InstanceReceivedHandler, &context_);
+  }
+
+  void DicomGetScuJob::AddResourceToRetrieve(ResourceType level, const std::string& dicomId)
+  {
+    // TODO-GET: when retrieving a single series, one must provide the StudyInstanceUID too
+    DicomMap item;
+
+    switch (level)
+    {
+    case ResourceType_Patient:
+      item.SetValue(DICOM_TAG_QUERY_RETRIEVE_LEVEL, ResourceTypeToDicomQueryRetrieveLevel(level), false);
+      item.SetValue(DICOM_TAG_PATIENT_ID, dicomId, false);
+      break;
+
+    case ResourceType_Study:
+      item.SetValue(DICOM_TAG_QUERY_RETRIEVE_LEVEL, ResourceTypeToDicomQueryRetrieveLevel(level), false);
+      item.SetValue(DICOM_TAG_STUDY_INSTANCE_UID, dicomId, false);
+      break;
+
+    case ResourceType_Series:
+      item.SetValue(DICOM_TAG_QUERY_RETRIEVE_LEVEL, ResourceTypeToDicomQueryRetrieveLevel(level), false);
+      item.SetValue(DICOM_TAG_SERIES_INSTANCE_UID, dicomId, false);
+      break;
+
+    case ResourceType_Instance:
+      item.SetValue(DICOM_TAG_QUERY_RETRIEVE_LEVEL, ResourceTypeToDicomQueryRetrieveLevel(level), false);
+      item.SetValue(DICOM_TAG_SOP_INSTANCE_UID, dicomId, false);
+      break;
+
+    default:
+      throw OrthancException(ErrorCode_InternalError);
+    }
+    query_.Add(item);
+    
+    AddCommand(new Command(*this, item));
+  }
+
+  void DicomGetScuJob::SetLocalAet(const std::string& aet)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      parameters_.SetLocalApplicationEntityTitle(aet);
+    }
+  }
+
+  
+  void DicomGetScuJob::SetRemoteModality(const RemoteModalityParameters& remote)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      parameters_.SetRemoteModality(remote);
+    }
+  }
+
+
+  void DicomGetScuJob::SetTimeout(uint32_t seconds)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      parameters_.SetTimeout(seconds);
+    }
+  }
+
+  
+  void DicomGetScuJob::Stop(JobStopReason reason)
+  {
+    connection_.reset();
+  }
+  
+
+  void DicomGetScuJob::SetQueryFormat(DicomToJsonFormat format)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      queryFormat_ = format;
+    }
+  }
+
+
+  void DicomGetScuJob::GetPublicContent(Json::Value& value)
+  {
+    SetOfCommandsJob::GetPublicContent(value);
+
+    value[LOCAL_AET] = parameters_.GetLocalApplicationEntityTitle();
+    value["RemoteAet"] = parameters_.GetRemoteModality().GetApplicationEntityTitle();
+
+    value[QUERY] = Json::objectValue;
+    // query_.ToJson(value[QUERY], queryFormat_);
+  }
+
+
+  DicomGetScuJob::DicomGetScuJob(ServerContext& context,
+                                 const Json::Value& serialized) :
+    SetOfCommandsJob(new Unserializer(*this), serialized),
+    context_(context),
+    parameters_(DicomAssociationParameters::UnserializeJob(serialized)),
+    // targetAet_(SerializationToolbox::ReadString(serialized, TARGET_AET)),
+    query_(true),
+    queryFormat_(DicomToJsonFormat_Short)
+  {
+    if (serialized.isMember(QUERY))
+    {
+      const Json::Value& query = serialized[QUERY];
+      if (query.type() == Json::arrayValue)
+      {
+        for (Json::Value::ArrayIndex i = 0; i < query.size(); i++)
+        {
+          DicomMap item;
+          FromDcmtkBridge::FromJson(item, query[i]);
+          // AddToQuery(query_, item);
+        }
+      }
+    }
+
+    if (serialized.isMember(QUERY_FORMAT))
+    {
+      queryFormat_ = StringToDicomToJsonFormat(SerializationToolbox::ReadString(serialized, QUERY_FORMAT));
+    }
+  }
+
+  
+  bool DicomGetScuJob::Serialize(Json::Value& target)
+  {
+    if (!SetOfCommandsJob::Serialize(target))
+    {
+      return false;
+    }
+    else
+    {
+      parameters_.SerializeJob(target);
+      // target[TARGET_AET] = targetAet_;
+
+      // "Short" is for compatibility with Orthanc <= 1.9.4
+      target[QUERY] = Json::objectValue;
+      // query_.ToJson(target[QUERY], DicomToJsonFormat_Short);
+
+      target[QUERY_FORMAT] = EnumerationToString(queryFormat_);
+      
+      return true;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/ServerJobs/DicomGetScuJob.h	Tue Oct 29 17:25:49 2024 +0100
@@ -0,0 +1,100 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "../../../OrthancFramework/Sources/Compatibility.h"
+#include "../../../OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.h"
+#include "../../../OrthancFramework/Sources/JobsEngine/SetOfCommandsJob.h"
+
+#include "../QueryRetrieveHandler.h"
+
+namespace Orthanc
+{
+  class ServerContext;
+  
+  class DicomGetScuJob : public SetOfCommandsJob
+  {
+  private:
+    class Command;
+    class Unserializer;
+    
+    ServerContext&              context_;
+    DicomAssociationParameters  parameters_;
+    DicomFindAnswers            query_;
+    DicomToJsonFormat           queryFormat_;  // New in 1.9.5
+
+    std::unique_ptr<DicomControlUserConnection>  connection_;
+    
+    void Retrieve(const DicomMap& findAnswer);
+    
+  public:
+    explicit DicomGetScuJob(ServerContext& context) :
+      context_(context),
+      query_(false  /* this is not for worklists */),
+      queryFormat_(DicomToJsonFormat_Short)
+    {
+    }
+
+    DicomGetScuJob(ServerContext& context,
+                    const Json::Value& serialized);
+
+    // void AddFindAnswer(const DicomMap& answer);
+    
+    // void AddQuery(const DicomMap& query);
+
+    // void AddFindAnswer(QueryRetrieveHandler& query,
+    //                    size_t i);
+
+    void AddResourceToRetrieve(ResourceType level, const std::string& dicomId);
+
+    const DicomAssociationParameters& GetParameters() const
+    {
+      return parameters_;
+    }
+    
+    void SetLocalAet(const std::string& aet);
+
+    void SetRemoteModality(const RemoteModalityParameters& remote);
+
+    void SetTimeout(uint32_t timeout);
+
+    void SetQueryFormat(DicomToJsonFormat format);
+
+    DicomToJsonFormat GetQueryFormat() const
+    {
+      return queryFormat_;
+    }
+
+    virtual void Stop(JobStopReason reason) ORTHANC_OVERRIDE;
+
+    virtual void GetJobType(std::string& target) ORTHANC_OVERRIDE
+    {
+      target = "DicomGetScu";
+    }
+
+    virtual void GetPublicContent(Json::Value& value) ORTHANC_OVERRIDE;
+
+    virtual bool Serialize(Json::Value& target) ORTHANC_OVERRIDE;
+  };
+}
--- a/OrthancServer/Sources/main.cpp	Wed Oct 16 19:34:42 2024 +0200
+++ b/OrthancServer/Sources/main.cpp	Tue Oct 29 17:25:49 2024 +0100
@@ -886,6 +886,7 @@
     PrintErrorCode(ErrorCode_AlreadyExistingTag, "Cannot override the value of a tag that already exists");
     PrintErrorCode(ErrorCode_NoStorageCommitmentHandler, "No request handler factory for DICOM N-ACTION SCP (storage commitment)");
     PrintErrorCode(ErrorCode_NoCGetHandler, "No request handler factory for DICOM C-GET SCP");
+    PrintErrorCode(ErrorCode_DicomGetUnavailable, "DicomUserConnection: The C-GET command is not supported by the remote SCP");
     PrintErrorCode(ErrorCode_UnsupportedMediaType, "Unsupported media type");
   }
 
--- a/TODO	Wed Oct 16 19:34:42 2024 +0200
+++ b/TODO	Tue Oct 29 17:25:49 2024 +0100
@@ -1,3 +1,11 @@
+current work on C-Get SCU:
+- for the negotiation, limit SOPClassUID to the ones listed in a C-Find response or to a list provided in the Rest API ?
+- SetupPresentationContexts
+- handle progress
+- handle cancellation when the job is cancelled ?
+
+
+
 =======================
 === Orthanc Roadmap ===
 =======================