diff --git a/DICOMtools.csproj b/DICOMtools.csproj index 19049f2..dcc33df 100644 --- a/DICOMtools.csproj +++ b/DICOMtools.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 true 1.4.2 Rob Holme diff --git a/module/DicomTools.format.ps1xml b/module/DicomTools.format.ps1xml index 1b7fba5..5a5e8e9 100644 --- a/module/DicomTools.format.ps1xml +++ b/module/DicomTools.format.ps1xml @@ -224,5 +224,113 @@ + + SendCGetResult + + DicomTools.SendCGetResult + + + + + + + + + + + + + + + + + + + + + SOPInstanceUID + + + SOPClassUID + + + FilePath + + + + if ($_.Status -ne "Success") { + "$([char]0x1b)[1;91m$($_.Status)$([char]0x1b)[0m" + } + else { + $_.Status + } + + + + + + + + + + SendCMoveResult + + DicomTools.SendCMoveResult + + + + + + + + + + + + + + + + + + + + + + + + + + + MoveDestination + + + + if ($_.Status -ne "Success") { + "$([char]0x1b)[1;91m$($_.Status)$([char]0x1b)[0m" + } + else { + $_.Status + } + + + + Completed + + + Failed + + + Warning + + + Remaining + + + + + + + diff --git a/module/DicomTools.psd1 b/module/DicomTools.psd1 index 5e8572c..792b37e 100644 --- a/module/DicomTools.psd1 +++ b/module/DicomTools.psd1 @@ -72,7 +72,7 @@ FormatsToProcess = @('DicomTools.format.ps1xml') FunctionsToExport = @() # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = @('Send-CEcho','Get-DicomTag','Send-CFind', 'Send-DMWLQuery') +CmdletsToExport = @('Send-CEcho','Get-DicomTag','Send-CFind', 'Send-DMWLQuery', 'Send-CGet', 'Send-CMove') # Variables to export from this module VariablesToExport = @() diff --git a/publish.cmd b/publish.cmd index b2a60da..b2bd6f6 100644 --- a/publish.cmd +++ b/publish.cmd @@ -1,3 +1,3 @@ del .\DicomTools.sln del .\module\lib\ -dotnet publish --configuration release --framework net6.0 --output .\module\lib\ +dotnet publish --configuration release --framework net9.0 --output .\module\lib\ diff --git a/src/Send-CGet.Result.cs b/src/Send-CGet.Result.cs new file mode 100644 index 0000000..34e40cb --- /dev/null +++ b/src/Send-CGet.Result.cs @@ -0,0 +1,55 @@ +/// +/// An object containing the results to be returned to the pipeline for Send-CGet cmdlet. +/// + +namespace DicomTools { + + public class SendCGetResult { + private string sopInstanceUID; + private string sopClassUID; + private string filePath; + private string status; + + /// + /// The SOP Instance UID of the retrieved image + /// + public string SOPInstanceUID { + get { return this.sopInstanceUID; } + set { this.sopInstanceUID = value; } + } + + /// + /// The SOP Class UID of the retrieved image + /// + public string SOPClassUID { + get { return this.sopClassUID; } + set { this.sopClassUID = value; } + } + + /// + /// The file path where the image was saved + /// + public string FilePath { + get { return this.filePath; } + set { this.filePath = value; } + } + + /// + /// The status of the retrieval + /// + public string Status { + get { return this.status; } + set { this.status = value; } + } + + /// + /// Populate the class members with results from the C-GET + /// + public SendCGetResult(string SOPInstanceUID, string SOPClassUID, string FilePath, string Status) { + this.sopInstanceUID = SOPInstanceUID; + this.sopClassUID = SOPClassUID; + this.filePath = FilePath; + this.status = Status; + } + } +} diff --git a/src/Send-CGet.cs b/src/Send-CGet.cs new file mode 100644 index 0000000..d369118 --- /dev/null +++ b/src/Send-CGet.cs @@ -0,0 +1,308 @@ +/* Filename: SendCGet.cs + * + * Author: Rob Holme (rob@holme.com.au) + * + * Notes: Implements a powershell CmdLet to send a DICOM C-GET to retrieve images from a remote host. + * + */ + + +namespace DicomTools { + using System; + using System.Collections.Generic; + using System.IO; + using System.Management.Automation; + using System.Threading; + using FellowOakDicom; + using FellowOakDicom.Network; + using FellowOakDicom.Network.Client; + + [Cmdlet(VerbsCommunications.Send, "CGet")] + public class SendCGet : PSCmdlet { + + private string dicomRemoteHost; + private int dicomRemoteHostPort; + private string callingDicomAeTitle = "DICOMTOOLS-SCU"; + private string calledDicomAeTitle = "ANY-SCP"; + private string studyInstanceUID = ""; + private string seriesInstanceUID = ""; + private string sopInstanceUID = ""; + private string outputDirectory = "."; + private bool useTls = false; + private int timeoutInSeconds = 60; + + // Hostname or IP Address of DICOM service + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, + Position = 1, + HelpMessage = "Hostname or IP Address of DICOM service" + )] + [Alias("IPAddress")] + public string HostName { + get { return this.dicomRemoteHost; } + set { this.dicomRemoteHost = value; } + } + + // The remote port number of the DICOM service + [Parameter( + Mandatory = true, + Position = 2, + HelpMessage = "Port number of remote DICOM service" + )] + [ValidateRange(1, 65535)] + public int Port { + get { return this.dicomRemoteHostPort; } + set { this.dicomRemoteHostPort = value; } + } + + // The client calling AE title + [Parameter( + Mandatory = false, + Position = 3, + HelpMessage = "The client calling AE title" + )] + [Alias("CallingAETitle")] + public string LocalAETitle { + get { return this.callingDicomAeTitle; } + set { this.callingDicomAeTitle = value; } + } + + // The server called AE title + [Parameter( + Mandatory = false, + Position = 4, + HelpMessage = "The server called AE title" + )] + [Alias("CalledAETitle")] + public string RemoteAETitle { + get { return this.calledDicomAeTitle; } + set { this.calledDicomAeTitle = value; } + } + + // Study Instance UID to retrieve + [Parameter( + Mandatory = true, + Position = 5, + HelpMessage = "The Study Instance UID to retrieve", + ParameterSetName = "Study" + )] + public string StudyInstanceUID { + get { return this.studyInstanceUID; } + set { this.studyInstanceUID = value; } + } + + // Series Instance UID to retrieve + [Parameter( + Mandatory = true, + Position = 5, + HelpMessage = "The Series Instance UID to retrieve", + ParameterSetName = "Series" + )] + public string SeriesInstanceUID { + get { return this.seriesInstanceUID; } + set { this.seriesInstanceUID = value; } + } + + // SOP Instance UID to retrieve a single image + [Parameter( + Mandatory = true, + Position = 5, + HelpMessage = "The SOP Instance UID to retrieve", + ParameterSetName = "Image" + )] + public string SOPInstanceUID { + get { return this.sopInstanceUID; } + set { this.sopInstanceUID = value; } + } + + // Output directory to save retrieved files + [Parameter( + Mandatory = false, + Position = 6, + HelpMessage = "The output directory to save retrieved DICOM files" + )] + [Alias("OutputPath")] + public string OutputDirectory { + get { return this.outputDirectory; } + set { this.outputDirectory = value; } + } + + // Use TLS for the connection + [Parameter( + Mandatory = false, + Position = 7, + HelpMessage = "Use TLS to secure the connection" + )] + public SwitchParameter UseTLS { + get { return this.useTls; } + set { this.useTls = value; } + } + + // timeout waiting for a response from the server. + [Parameter( + Mandatory = false, + Position = 8, + HelpMessage = "The timeout in seconds to wait for a response" + )] + [ValidateRange(1, 600)] + public int Timeout { + get { return this.timeoutInSeconds; } + set { this.timeoutInSeconds = value; } + } + + /// + /// Process all C-GET requests + /// + protected override void ProcessRecord() { + + // resolve the output directory path + string resolvedOutputDirectory; + try { + var resolvedPaths = SessionState.Path.GetResolvedPSPathFromPSPath(outputDirectory); + resolvedOutputDirectory = resolvedPaths[0].Path; + } + catch { + // if path doesn't exist yet, use GetUnresolvedProviderPathFromPSPath + resolvedOutputDirectory = SessionState.Path.GetUnresolvedProviderPathFromPSPath(outputDirectory); + } + + // create the output directory if it doesn't exist + if (!Directory.Exists(resolvedOutputDirectory)) { + try { + Directory.CreateDirectory(resolvedOutputDirectory); + WriteVerbose($"Created output directory: {resolvedOutputDirectory}"); + } + catch (Exception e) { + WriteWarning($"Unable to create output directory '{resolvedOutputDirectory}': {e.Message}"); + return; + } + } + + // write connection details if -Verbose switch supplied + WriteVerbose($"Hostname: {dicomRemoteHost}"); + WriteVerbose($"Port: {dicomRemoteHostPort}"); + WriteVerbose($"Calling AE Title: {callingDicomAeTitle}"); + WriteVerbose($"Called AE Title: {calledDicomAeTitle}"); + WriteVerbose($"Use TLS: {useTls}"); + WriteVerbose($"Timeout: {timeoutInSeconds}"); + WriteVerbose($"Output Directory: {resolvedOutputDirectory}"); + + var verboseList = new List(); + + try { + // cancel token to cancel the request after a timeout + CancellationTokenSource sourceCancelToken = new CancellationTokenSource(); + CancellationToken cancelToken = sourceCancelToken.Token; + + // create new DICOM client + var client = DicomClientFactory.Create(dicomRemoteHost, dicomRemoteHostPort, useTls, callingDicomAeTitle, calledDicomAeTitle); + client.ServiceOptions.LogDimseDatasets = false; + client.ServiceOptions.LogDataPDUs = false; + client.ServiceOptions.RequestTimeout = new TimeSpan(0, 0, timeoutInSeconds); + client.NegotiateAsyncOps(); + + // determine the query retrieve level and create the C-GET request + DicomCGetRequest cGetRequest; + if (sopInstanceUID.Length > 0) { + cGetRequest = new DicomCGetRequest(studyInstanceUID, seriesInstanceUID, sopInstanceUID); + WriteVerbose($"C-GET Level: IMAGE, SOP Instance UID: {sopInstanceUID}"); + } + else if (seriesInstanceUID.Length > 0) { + cGetRequest = new DicomCGetRequest(studyInstanceUID, seriesInstanceUID); + WriteVerbose($"C-GET Level: SERIES, Series Instance UID: {seriesInstanceUID}"); + } + else { + cGetRequest = new DicomCGetRequest(studyInstanceUID); + WriteVerbose($"C-GET Level: STUDY, Study Instance UID: {studyInstanceUID}"); + } + + // list to store the results returned + var cGetResultList = new List(); + int filesRetrieved = 0; + int filesFailed = 0; + string cGetStatus = ""; + + // event handler - C-GET response received + cGetRequest.OnResponseReceived += (request, response) => { + verboseList.Add($"C-GET Response: {response.Status} (Remaining: {response.Remaining}, Completed: {response.Completed}, Failed: {response.Failures})"); + if (response.Status == DicomStatus.Success || response.Status == DicomStatus.Pending) { + cGetStatus = response.Status.ToString(); + filesRetrieved = (int)response.Completed; + filesFailed = (int)response.Failures; + } + else { + cGetStatus = response.Status.ToString(); + } + }; + + // handler for incoming C-STORE sub-operations (server sends images back via C-STORE on the same association) + client.OnCStoreRequest += (request) => { + var sopClassUID = request.SOPClassUID.ToString(); + var instanceUID = request.SOPInstanceUID.UID; + var fileName = $"{instanceUID}.dcm"; + var filePath = Path.Combine(resolvedOutputDirectory, fileName); + + try { + request.File.Save(filePath); + verboseList.Add($"Saved: {fileName}"); + cGetResultList.Add(new SendCGetResult(instanceUID, sopClassUID, filePath, "Success")); + } + catch (Exception e) { + verboseList.Add($"Failed to save {fileName}: {e.Message}"); + cGetResultList.Add(new SendCGetResult(instanceUID, sopClassUID, filePath, $"Failed: {e.Message}")); + return System.Threading.Tasks.Task.FromResult(new DicomCStoreResponse(request, DicomStatus.ProcessingFailure)); + } + + return System.Threading.Tasks.Task.FromResult(new DicomCStoreResponse(request, DicomStatus.Success)); + }; + + // event handler - client association rejected by server + client.AssociationRejected += (sender, eventArgs) => { + verboseList.Add($"Association was rejected. Reason:{eventArgs.Reason}"); + }; + + // event handler - client association accepted by server + client.AssociationAccepted += (sender, eventArgs) => { + verboseList.Add($"Association was accepted by:{eventArgs.Association.RemoteHost}"); + }; + + // add the C-GET request to the client + client.AddRequestAsync(cGetRequest); + + // send an async request, wait for response. + // cancel after period specified by -Timeout parameter + sourceCancelToken.CancelAfter(timeoutInSeconds * 1000); + var task = client.SendAsync(cancelToken); + task.Wait(); + + // write verbose logging from the async event handlers (cant write to pwsh host from another thread) + verboseList.Reverse(); + foreach (string verboseString in verboseList) { + WriteVerbose(verboseString); + } + + // check to see if the task timed out, otherwise return results. + if (cancelToken.IsCancellationRequested) { + WriteWarning($"The C-GET request timed out (timeout set to {timeoutInSeconds} seconds). Use -Timeout to increase duration."); + } + else { + // write the C-GET results to the pipeline + if (cGetResultList.Count == 0) { + WriteWarning($"No images were retrieved. Server status: {cGetStatus}"); + } + else { + WriteObject(cGetResultList); + } + } + } + catch (Exception e) { + // typically network connection errors will trigger exceptions (remote host unreachable, TLS not supported, etc) + WriteWarning($"An Issue occurred: {e.InnerException.Message}"); + WriteWarning("Use -Debug switch for full exception message."); + WriteDebug($"Exception: -> {e}"); + } + } + } +} diff --git a/src/Send-CMove.Result.cs b/src/Send-CMove.Result.cs new file mode 100644 index 0000000..f78119d --- /dev/null +++ b/src/Send-CMove.Result.cs @@ -0,0 +1,75 @@ +/// +/// An object containing the results to be returned to the pipeline for Send-CMove cmdlet. +/// + +namespace DicomTools { + + public class SendCMoveResult { + private string status; + private int completed; + private int failed; + private int remaining; + private int warning; + private string moveDestination; + + /// + /// The status of the C-MOVE operation + /// + public string Status { + get { return this.status; } + set { this.status = value; } + } + + /// + /// The number of completed sub-operations + /// + public int Completed { + get { return this.completed; } + set { this.completed = value; } + } + + /// + /// The number of failed sub-operations + /// + public int Failed { + get { return this.failed; } + set { this.failed = value; } + } + + /// + /// The number of remaining sub-operations + /// + public int Remaining { + get { return this.remaining; } + set { this.remaining = value; } + } + + /// + /// The number of warning sub-operations + /// + public int Warning { + get { return this.warning; } + set { this.warning = value; } + } + + /// + /// The AE title of the move destination + /// + public string MoveDestination { + get { return this.moveDestination; } + set { this.moveDestination = value; } + } + + /// + /// Populate the class members with results from the C-MOVE + /// + public SendCMoveResult(string Status, int Completed, int Failed, int Remaining, int Warning, string MoveDestination) { + this.status = Status; + this.completed = Completed; + this.failed = Failed; + this.remaining = Remaining; + this.warning = Warning; + this.moveDestination = MoveDestination; + } + } +} diff --git a/src/Send-CMove.cs b/src/Send-CMove.cs new file mode 100644 index 0000000..c0ce750 --- /dev/null +++ b/src/Send-CMove.cs @@ -0,0 +1,259 @@ +/* Filename: SendCMove.cs + * + * Author: Rob Holme (rob@holme.com.au) + * + * Notes: Implements a powershell CmdLet to send a DICOM C-MOVE to instruct a remote host + * to send images to a specified move destination AE title. + * + */ + + +namespace DicomTools { + using System; + using System.Collections.Generic; + using System.Management.Automation; + using System.Threading; + using FellowOakDicom; + using FellowOakDicom.Network; + using FellowOakDicom.Network.Client; + + [Cmdlet(VerbsCommunications.Send, "CMove")] + public class SendCMove : PSCmdlet { + + private string dicomRemoteHost; + private int dicomRemoteHostPort; + private string callingDicomAeTitle = "DICOMTOOLS-SCU"; + private string calledDicomAeTitle = "ANY-SCP"; + private string moveDestinationAeTitle; + private string studyInstanceUID = ""; + private string seriesInstanceUID = ""; + private string sopInstanceUID = ""; + private bool useTls = false; + private int timeoutInSeconds = 60; + + // Hostname or IP Address of DICOM service + [Parameter( + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true, + Position = 1, + HelpMessage = "Hostname or IP Address of DICOM service" + )] + [Alias("IPAddress")] + public string HostName { + get { return this.dicomRemoteHost; } + set { this.dicomRemoteHost = value; } + } + + // The remote port number of the DICOM service + [Parameter( + Mandatory = true, + Position = 2, + HelpMessage = "Port number of remote DICOM service" + )] + [ValidateRange(1, 65535)] + public int Port { + get { return this.dicomRemoteHostPort; } + set { this.dicomRemoteHostPort = value; } + } + + // The client calling AE title + [Parameter( + Mandatory = false, + Position = 3, + HelpMessage = "The client calling AE title" + )] + [Alias("CallingAETitle")] + public string LocalAETitle { + get { return this.callingDicomAeTitle; } + set { this.callingDicomAeTitle = value; } + } + + // The server called AE title + [Parameter( + Mandatory = false, + Position = 4, + HelpMessage = "The server called AE title" + )] + [Alias("CalledAETitle")] + public string RemoteAETitle { + get { return this.calledDicomAeTitle; } + set { this.calledDicomAeTitle = value; } + } + + // The move destination AE title (where images will be sent) + [Parameter( + Mandatory = true, + Position = 5, + HelpMessage = "The AE title of the destination to send images to" + )] + public string MoveDestination { + get { return this.moveDestinationAeTitle; } + set { this.moveDestinationAeTitle = value; } + } + + // Study Instance UID to retrieve + [Parameter( + Mandatory = true, + Position = 6, + HelpMessage = "The Study Instance UID to retrieve", + ParameterSetName = "Study" + )] + public string StudyInstanceUID { + get { return this.studyInstanceUID; } + set { this.studyInstanceUID = value; } + } + + // Series Instance UID to retrieve + [Parameter( + Mandatory = true, + Position = 6, + HelpMessage = "The Series Instance UID to retrieve", + ParameterSetName = "Series" + )] + public string SeriesInstanceUID { + get { return this.seriesInstanceUID; } + set { this.seriesInstanceUID = value; } + } + + // SOP Instance UID to retrieve a single image + [Parameter( + Mandatory = true, + Position = 6, + HelpMessage = "The SOP Instance UID to retrieve", + ParameterSetName = "Image" + )] + public string SOPInstanceUID { + get { return this.sopInstanceUID; } + set { this.sopInstanceUID = value; } + } + + // Use TLS for the connection + [Parameter( + Mandatory = false, + Position = 7, + HelpMessage = "Use TLS to secure the connection" + )] + public SwitchParameter UseTLS { + get { return this.useTls; } + set { this.useTls = value; } + } + + // timeout waiting for a response from the server. + [Parameter( + Mandatory = false, + Position = 8, + HelpMessage = "The timeout in seconds to wait for a response" + )] + [ValidateRange(1, 600)] + public int Timeout { + get { return this.timeoutInSeconds; } + set { this.timeoutInSeconds = value; } + } + + /// + /// Process all C-MOVE requests + /// + protected override void ProcessRecord() { + + // write connection details if -Verbose switch supplied + WriteVerbose($"Hostname: {dicomRemoteHost}"); + WriteVerbose($"Port: {dicomRemoteHostPort}"); + WriteVerbose($"Calling AE Title: {callingDicomAeTitle}"); + WriteVerbose($"Called AE Title: {calledDicomAeTitle}"); + WriteVerbose($"Move Destination: {moveDestinationAeTitle}"); + WriteVerbose($"Use TLS: {useTls}"); + WriteVerbose($"Timeout: {timeoutInSeconds}"); + + var verboseList = new List(); + + try { + // cancel token to cancel the request after a timeout + CancellationTokenSource sourceCancelToken = new CancellationTokenSource(); + CancellationToken cancelToken = sourceCancelToken.Token; + + // create new DICOM client + var client = DicomClientFactory.Create(dicomRemoteHost, dicomRemoteHostPort, useTls, callingDicomAeTitle, calledDicomAeTitle); + client.ServiceOptions.LogDimseDatasets = false; + client.ServiceOptions.LogDataPDUs = false; + client.ServiceOptions.RequestTimeout = new TimeSpan(0, 0, timeoutInSeconds); + client.NegotiateAsyncOps(); + + // determine the query retrieve level and create the C-MOVE request + DicomCMoveRequest cMoveRequest; + if (sopInstanceUID.Length > 0) { + cMoveRequest = new DicomCMoveRequest(moveDestinationAeTitle, studyInstanceUID, seriesInstanceUID, sopInstanceUID); + WriteVerbose($"C-MOVE Level: IMAGE, SOP Instance UID: {sopInstanceUID}"); + } + else if (seriesInstanceUID.Length > 0) { + cMoveRequest = new DicomCMoveRequest(moveDestinationAeTitle, studyInstanceUID, seriesInstanceUID); + WriteVerbose($"C-MOVE Level: SERIES, Series Instance UID: {seriesInstanceUID}"); + } + else { + cMoveRequest = new DicomCMoveRequest(moveDestinationAeTitle, studyInstanceUID); + WriteVerbose($"C-MOVE Level: STUDY, Study Instance UID: {studyInstanceUID}"); + } + + // store the final result + SendCMoveResult cMoveResult = null; + + // event handler - C-MOVE response received + cMoveRequest.OnResponseReceived += (request, response) => { + verboseList.Add($"C-MOVE Response: {response.Status} (Remaining: {response.Remaining}, Completed: {response.Completed}, Failed: {response.Failures}, Warning: {response.Warnings})"); + cMoveResult = new SendCMoveResult( + response.Status.ToString(), + (int)response.Completed, + (int)response.Failures, + (int)response.Remaining, + (int)response.Warnings, + moveDestinationAeTitle + ); + }; + + // event handler - client association rejected by server + client.AssociationRejected += (sender, eventArgs) => { + verboseList.Add($"Association was rejected. Reason:{eventArgs.Reason}"); + }; + + // event handler - client association accepted by server + client.AssociationAccepted += (sender, eventArgs) => { + verboseList.Add($"Association was accepted by:{eventArgs.Association.RemoteHost}"); + }; + + // add the C-MOVE request to the client + client.AddRequestAsync(cMoveRequest); + + // send an async request, wait for response. + // cancel after period specified by -Timeout parameter + sourceCancelToken.CancelAfter(timeoutInSeconds * 1000); + var task = client.SendAsync(cancelToken); + task.Wait(); + + // write verbose logging from the async event handlers (cant write to pwsh host from another thread) + verboseList.Reverse(); + foreach (string verboseString in verboseList) { + WriteVerbose(verboseString); + } + + // check to see if the task timed out, otherwise return results. + if (cancelToken.IsCancellationRequested) { + WriteWarning($"The C-MOVE request timed out (timeout set to {timeoutInSeconds} seconds). Use -Timeout to increase duration."); + } + else { + if (cMoveResult != null) { + WriteObject(cMoveResult); + } + else { + WriteWarning("No response was received from the server."); + } + } + } + catch (Exception e) { + // typically network connection errors will trigger exceptions (remote host unreachable, TLS not supported, etc) + WriteWarning($"An Issue occurred: {e.InnerException.Message}"); + WriteWarning("Use -Debug switch for full exception message."); + WriteDebug($"Exception: -> {e}"); + } + } + } +}