54constexpr const char* version_string =
"1.0.0";
55constexpr const char* default_calling_ae =
"MOVESCU";
56constexpr const char* default_called_ae =
"ANY-SCP";
58constexpr size_t max_ae_title_length = 16;
59constexpr int progress_bar_width = 40;
65enum class query_model {
70enum class query_level {
92 std::string peer_host;
93 uint16_t peer_port{0};
94 std::string calling_ae_title{default_calling_ae};
95 std::string called_ae_title{default_called_ae};
101 std::chrono::seconds dimse_timeout{0};
104 query_model model{query_model::patient_root};
105 query_level level{query_level::study};
108 std::vector<query_key> keys;
109 std::string query_file;
112 std::filesystem::path output_dir{
"./downloads"};
113 uint16_t receive_port{0};
116 bool show_progress{
true};
117 bool ignore_pending{
false};
125 bool show_help{
false};
126 bool show_version{
false};
133struct move_progress {
134 std::atomic<uint16_t> remaining{0};
136 std::atomic<uint16_t>
failed{0};
137 std::atomic<uint16_t>
warning{0};
138 std::chrono::steady_clock::time_point start_time;
145 start_time = std::chrono::steady_clock::now();
148 [[nodiscard]] uint16_t total()
const {
157std::string_view query_model_to_string(query_model model) {
159 case query_model::patient_root:
return "Patient Root";
160 case query_model::study_root:
return "Study Root";
161 default:
return "Unknown";
167 case query_level::patient:
return "PATIENT";
168 case query_level::study:
return "STUDY";
169 case query_level::series:
return "SERIES";
170 case query_level::image:
return "IMAGE";
171 default:
return "UNKNOWN";
175std::string_view get_move_sop_class_uid(query_model model) {
177 case query_model::patient_root:
179 case query_model::study_root:
192 __ __ _____ _ _ _____ ____ ____ _ _
193 | \/ |/ _ \ \| | / / ____| / ___| / ___| | | |
194 | |\/| | | | \ V / | _| \___ \| | | | | |
195 | | | | |_| |\ /| | |___ ___) | |___| |_| |
196 |_| |_|\___/ \_/ |_____| |____/ \____|\___/
198 DICOM C-MOVE Client v)" << version_string << R"(
202void print_usage(
const char* program_name) {
203 std::cout <<
"Usage: " << program_name << R
"( [options] <peer> <port>
206 peer Remote host address (IP or hostname)
207 port Remote port number (typically 104 or 11112)
210 -h, --help Show this help message and exit
211 -v, --verbose Verbose output mode
212 -d, --debug Debug output mode
213 -q, --quiet Quiet mode (minimal output)
214 --version Show version information
217 -aet, --aetitle <aetitle> Calling AE Title (default: MOVESCU)
218 -aec, --call <aetitle> Called AE Title (default: ANY-SCP)
219 -aem, --move-dest <aetitle> Move destination AE Title (REQUIRED)
220 -to, --timeout <seconds> Connection timeout (default: 60)
221 -ta, --acse-timeout <seconds> ACSE timeout (default: 60)
222 -td, --dimse-timeout <seconds> DIMSE timeout (default: 0=infinite)
225 -P, --patient-root Patient Root Query Model (default)
226 -S, --study-root Study Root Query Model
229 -L, --level <level> Retrieve level (PATIENT|STUDY|SERIES|IMAGE)
232 -k, --key <tag=value> Query key for retrieval
233 -f, --query-file <file> Read query keys from file
235Output Options (when receiving locally):
236 -od, --output-dir <dir> Output directory (default: ./downloads)
237 --port <port> Port for receiving files (default: auto)
240 -p, --progress Show progress information (default)
241 --no-progress Disable progress display
242 --ignore-pending Ignore pending status
245 # Move study to third party
246 )" << program_name << R"( -aem WORKSTATION \
248 -k "0020,000D=1.2.840..." \
251 # Move series to self
252 )" << program_name << R"( -aem MOVESCU \
256 -k "0020,000E=1.2.840..." \
259 # Move patient data with progress
260 )" << program_name << R"( -aem ARCHIVE \
263 -k "0010,0020=12345" \
267 0 Success - Move completed
268 1 Partial success - Some sub-operations failed
269 2 Error - Move failed or invalid arguments
273void print_version() {
274 std::cout <<
"move_scu version " << version_string <<
"\n";
275 std::cout <<
"PACS System DICOM Utilities\n";
276 std::cout <<
"Copyright (c) 2024\n";
279void display_progress(
const move_progress& progress,
bool verbose) {
280 auto total = progress.total();
281 if (total == 0)
return;
283 uint16_t
done = progress.completed + progress.failed + progress.warning;
284 float pct =
static_cast<float>(
done) / total;
286 auto elapsed = std::chrono::steady_clock::now() - progress.start_time;
287 auto elapsed_sec = std::chrono::duration<double>(elapsed).count();
290 int filled =
static_cast<int>(pct * progress_bar_width);
291 for (
int i = 0; i < progress_bar_width; ++i) {
292 if (i < filled) std::cout <<
"=";
293 else if (i == filled) std::cout <<
">";
294 else std::cout <<
" ";
298 std::cout << std::fixed << std::setprecision(1) << (pct * 100) <<
"% ";
299 std::cout <<
"(" <<
done <<
"/" << total <<
") ";
302 std::cout << std::setprecision(1) << elapsed_sec <<
"s ";
303 if (progress.failed > 0) {
304 std::cout <<
"[" << progress.failed.load() <<
" failed] ";
308 std::cout << std::flush;
315bool parse_timeout(
const std::string& value, std::chrono::seconds& result,
316 const std::string& option_name) {
318 int seconds = std::stoi(value);
320 std::cerr <<
"Error: " << option_name <<
" must be non-negative\n";
323 result = std::chrono::seconds{seconds};
325 }
catch (
const std::exception&) {
326 std::cerr <<
"Error: Invalid value for " << option_name <<
": '"
332bool validate_ae_title(
const std::string& ae_title,
333 const std::string& option_name) {
334 if (ae_title.empty()) {
335 std::cerr <<
"Error: " << option_name <<
" cannot be empty\n";
338 if (ae_title.length() > max_ae_title_length) {
339 std::cerr <<
"Error: " << option_name <<
" exceeds "
340 << max_ae_title_length <<
" characters\n";
346std::optional<query_level> parse_level(
const std::string& level_str) {
347 std::string upper = level_str;
348 std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper);
350 if (upper ==
"PATIENT")
return query_level::patient;
351 if (upper ==
"STUDY")
return query_level::study;
352 if (upper ==
"SERIES")
return query_level::series;
353 if (upper ==
"IMAGE" || upper ==
"INSTANCE")
return query_level::image;
357bool parse_query_key(
const std::string& key_str, query_key& key) {
358 std::regex key_regex(R
"(\(?([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)?=?(.*))");
361 if (!std::regex_match(key_str, match, key_regex)) {
362 std::cerr <<
"Error: Invalid query key format: '" << key_str <<
"'\n";
366 uint16_t group =
static_cast<uint16_t
>(std::stoul(match[1].str(),
nullptr, 16));
367 uint16_t element =
static_cast<uint16_t
>(std::stoul(match[2].str(),
nullptr, 16));
370 key.value = match[3].str();
375bool load_query_file(
const std::string& filename, std::vector<query_key>& keys) {
376 std::ifstream file(filename);
377 if (!file.is_open()) {
378 std::cerr <<
"Error: Cannot open query file: " << filename <<
"\n";
383 while (std::getline(file, line)) {
384 auto pos =
line.find_first_not_of(
" \t");
385 if (pos == std::string::npos || line[pos] ==
'#') {
390 if (!parse_query_key(line, key)) {
399bool parse_arguments(
int argc,
char* argv[], options& opts) {
400 std::vector<std::string> positional_args;
402 for (
int i = 1; i < argc; ++i) {
403 std::string arg = argv[i];
405 if (arg ==
"-h" || arg ==
"--help") {
406 opts.show_help =
true;
409 if (arg ==
"--version") {
410 opts.show_version =
true;
415 if (arg ==
"-v" || arg ==
"--verbose") {
419 if (arg ==
"-d" || arg ==
"--debug") {
424 if (arg ==
"-q" || arg ==
"--quiet") {
430 if ((arg ==
"-aet" || arg ==
"--aetitle") && i + 1 < argc) {
431 opts.calling_ae_title = argv[++i];
432 if (!validate_ae_title(opts.calling_ae_title,
"Calling AE Title")) {
437 if ((arg ==
"-aec" || arg ==
"--call") && i + 1 < argc) {
438 opts.called_ae_title = argv[++i];
439 if (!validate_ae_title(opts.called_ae_title,
"Called AE Title")) {
444 if ((arg ==
"-aem" || arg ==
"--move-dest") && i + 1 < argc) {
445 opts.move_destination = argv[++i];
446 if (!validate_ae_title(opts.move_destination,
"Move Destination")) {
453 if ((arg ==
"-to" || arg ==
"--timeout") && i + 1 < argc) {
454 if (!parse_timeout(argv[++i], opts.connection_timeout,
"timeout")) {
459 if ((arg ==
"-ta" || arg ==
"--acse-timeout") && i + 1 < argc) {
460 if (!parse_timeout(argv[++i], opts.acse_timeout,
"ACSE timeout")) {
465 if ((arg ==
"-td" || arg ==
"--dimse-timeout") && i + 1 < argc) {
466 if (!parse_timeout(argv[++i], opts.dimse_timeout,
"DIMSE timeout")) {
473 if (arg ==
"-P" || arg ==
"--patient-root") {
474 opts.model = query_model::patient_root;
477 if (arg ==
"-S" || arg ==
"--study-root") {
478 opts.model = query_model::study_root;
483 if ((arg ==
"-L" || arg ==
"--level") && i + 1 < argc) {
484 auto level = parse_level(argv[++i]);
486 std::cerr <<
"Error: Invalid query level: '" << argv[i] <<
"'\n";
494 if ((arg ==
"-k" || arg ==
"--key") && i + 1 < argc) {
496 if (!parse_query_key(argv[++i], key)) {
499 opts.keys.push_back(key);
502 if ((arg ==
"-f" || arg ==
"--query-file") && i + 1 < argc) {
503 opts.query_file = argv[++i];
508 if ((arg ==
"-od" || arg ==
"--output-dir") && i + 1 < argc) {
509 opts.output_dir = argv[++i];
512 if (arg ==
"--port" && i + 1 < argc) {
514 int port = std::stoi(argv[++i]);
515 if (port < 1 || port > 65535) {
516 std::cerr <<
"Error: Port must be between 1 and 65535\n";
519 opts.receive_port =
static_cast<uint16_t
>(port);
521 std::cerr <<
"Error: Invalid port number\n";
528 if (arg ==
"-p" || arg ==
"--progress") {
529 opts.show_progress =
true;
532 if (arg ==
"--no-progress") {
533 opts.show_progress =
false;
536 if (arg ==
"--ignore-pending") {
537 opts.ignore_pending =
true;
541 if (arg.starts_with(
"-")) {
542 std::cerr <<
"Error: Unknown option '" << arg <<
"'\n";
546 positional_args.push_back(arg);
549 if (positional_args.size() != 2) {
550 std::cerr <<
"Error: Expected <peer> <port> arguments\n";
554 opts.peer_host = positional_args[0];
557 int port_int = std::stoi(positional_args[1]);
558 if (port_int < 1 || port_int > 65535) {
559 std::cerr <<
"Error: Port must be between 1 and 65535\n";
562 opts.peer_port =
static_cast<uint16_t
>(port_int);
564 std::cerr <<
"Error: Invalid port number '" << positional_args[1] <<
"'\n";
569 if (opts.move_destination.empty()) {
570 std::cerr <<
"Error: Move destination (-aem) is required\n";
575 if (!opts.query_file.empty()) {
576 if (!load_query_file(opts.query_file, opts.keys)) {
582 if (opts.keys.empty()) {
583 std::cerr <<
"Error: At least one query key (-k) is required\n";
601 ds.set_string(tags::query_retrieve_level, vr_type::CS, level_str);
603 for (
const auto& key : opts.keys) {
604 ds.set_string(key.tag, vr_type::UN, key.value);
616 std::string_view sop_class_uid,
617 std::string_view move_destination) {
621 dimse_message msg{command_field::c_move_rq,
message_id};
622 msg.set_affected_sop_class_uid(sop_class_uid);
623 msg.set_priority(priority_medium);
625 msg.command_set().set_string(
626 tag_move_destination,
628 std::string(move_destination));
633int perform_move(
const options& opts) {
641 std::cout <<
"Requesting Association\n";
643 std::cout <<
" Peer: " << opts.peer_host <<
":"
644 << opts.peer_port <<
"\n";
645 std::cout <<
" Calling AE: " << opts.calling_ae_title <<
"\n";
646 std::cout <<
" Called AE: " << opts.called_ae_title <<
"\n";
647 std::cout <<
" Move Dest: " << opts.move_destination <<
"\n";
648 std::cout <<
" Query Model: " << query_model_to_string(opts.model)
664 std::string(sop_class_uid),
666 "1.2.840.10008.1.2.1",
672 auto start_time = std::chrono::steady_clock::now();
673 auto timeout = std::chrono::duration_cast<std::chrono::milliseconds>(
674 opts.connection_timeout);
675 auto connect_result = association::connect(opts.peer_host, opts.peer_port,
678 if (connect_result.is_err()) {
679 std::cerr <<
"Association Failed: " << connect_result.error().message
684 auto& assoc = connect_result.value();
687 std::cout <<
"Association Accepted\n";
690 if (!assoc.has_accepted_context(sop_class_uid)) {
691 std::cerr <<
"Error: C-MOVE SOP Class not accepted by remote SCP\n";
696 auto context_id_opt = assoc.accepted_context_id(sop_class_uid);
697 if (!context_id_opt) {
698 std::cerr <<
"Error: Could not get presentation context ID\n";
702 uint8_t context_id = *context_id_opt;
704 auto query_ds = build_query_dataset(opts);
706 auto move_rq = make_c_move_rq(1, sop_class_uid, opts.move_destination);
707 move_rq.set_dataset(std::move(query_ds));
710 std::cout <<
"Initiating C-MOVE to " << opts.move_destination <<
"...\n";
713 auto send_result = assoc.send_dimse(context_id, move_rq);
714 if (send_result.is_err()) {
715 std::cerr <<
"Send Failed: " << send_result.error().message <<
"\n";
721 move_progress progress;
724 bool move_complete =
false;
725 uint16_t final_completed = 0;
726 uint16_t final_failed = 0;
727 uint16_t final_warning = 0;
729 auto dimse_timeout = opts.dimse_timeout.count() > 0
730 ? std::chrono::duration_cast<std::chrono::milliseconds>(opts.dimse_timeout)
731 : std::chrono::milliseconds{60000};
733 while (!move_complete) {
734 auto recv_result = assoc.receive_dimse(dimse_timeout);
735 if (recv_result.is_err()) {
736 std::cerr <<
"\nReceive Failed: " << recv_result.error().message
742 auto& [recv_context_id, msg] = recv_result.value();
744 if (msg.command() != command_field::c_move_rsp) {
745 std::cerr <<
"\nError: Unexpected response (expected C-MOVE-RSP)\n";
750 auto status = msg.status();
752 if (
auto remaining = msg.remaining_subops()) {
753 progress.remaining = *remaining;
755 if (
auto completed = msg.completed_subops()) {
759 if (
auto failed = msg.failed_subops()) {
760 progress.failed = *
failed;
763 if (
auto warning = msg.warning_subops()) {
768 if (opts.show_progress && !opts.quiet) {
769 display_progress(progress, opts.verbose);
772 if (status == status_success ||
773 status == status_cancel ||
774 (status & 0xF000) == 0xA000 ||
775 (status & 0xF000) == 0xC000) {
777 move_complete =
true;
779 if (status != status_success && status != status_cancel &&
781 std::cerr <<
"\nC-MOVE failed with status: 0x" << std::hex
782 <<
status << std::dec <<
"\n";
787 if (opts.show_progress && !opts.quiet) {
791 if (!opts.quiet && opts.verbose) {
792 std::cout <<
"Releasing Association\n";
795 auto release_result = assoc.release(timeout);
796 if (release_result.is_err() && opts.verbose) {
797 std::cerr <<
"Warning: Release failed: "
798 << release_result.error().message <<
"\n";
801 auto end_time = std::chrono::steady_clock::now();
802 auto total_duration = std::chrono::duration_cast<std::chrono::milliseconds>(
803 end_time - start_time);
807 std::cout <<
"\n========================================\n";
808 std::cout <<
" Move Summary\n";
809 std::cout <<
"========================================\n";
810 std::cout <<
" Destination: " << opts.move_destination <<
"\n";
813 std::cout <<
" ----------------------------------------\n";
814 std::cout <<
" Completed: " << final_completed <<
"\n";
815 if (final_warning > 0) {
816 std::cout <<
" Warnings: " << final_warning <<
"\n";
818 if (final_failed > 0) {
819 std::cout <<
" Failed: " << final_failed <<
"\n";
821 std::cout <<
" Total Time: " << total_duration.count() <<
" ms\n";
822 std::cout <<
"========================================\n";
825 if (final_failed > 0 && final_completed == 0) {
827 }
else if (final_failed > 0) {
839int main(
int argc,
char* argv[]) {
842 if (!parse_arguments(argc, argv, opts)) {
843 if (!opts.show_help && !opts.show_version) {
844 std::cerr <<
"\nUse --help for usage information.\n";
849 if (opts.show_version) {
854 if (opts.show_help) {
856 print_usage(argv[0]);
864 return perform_move(opts);
DICOM Association management per PS3.8.
DICOM Dataset - ordered collection of Data Elements.
DICOM Part 10 file handling for reading/writing DICOM files.
DICOM Tag representation (Group, Element pairs)
Compile-time constants for commonly used DICOM tags.
DIMSE message encoding and decoding.
@ failed
Job failed with error.
@ completed
Job completed successfully.
@ AE
Application Entity (16 chars max)
constexpr int connection_timeout
auto query_level_to_string(query_level level) -> std::string
Convert query level to string.
std::chrono::milliseconds default_timeout()
Default timeout for test operations (5s normal, 30s CI)
@ done
Job completed successfully.
constexpr std::string_view study_root_move_sop_class_uid
Study Root Query/Retrieve Information Model - MOVE.
constexpr std::string_view patient_root_move_sop_class_uid
Patient Root Query/Retrieve Information Model - MOVE.
@ study_root
Study Root Query/Retrieve Information Model.
@ patient_root
Patient Root Query/Retrieve Information Model.
constexpr int timeout
Lock timeout exceeded.
DICOM Retrieve SCP service (C-MOVE/C-GET handler)
DICOM Storage SCP service (C-STORE handler)
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::string implementation_class_uid
std::string implementation_version_name
std::vector< proposed_presentation_context > proposed_contexts