50std::atomic<kcenon::pacs::network::dicom_server*> g_server{
nullptr};
53std::atomic<bool> g_running{
true};
59void signal_handler(
int signal) {
60 std::cout <<
"\nReceived signal " << signal <<
", shutting down...\n";
63 auto* server = g_server.load();
72void install_signal_handlers() {
73 std::signal(SIGINT, signal_handler);
74 std::signal(SIGTERM, signal_handler);
76 std::signal(SIGHUP, signal_handler);
88void print_usage(
const char* program_name) {
90MPPS SCP - DICOM Modality Performed Procedure Step Server
92Usage: )" << program_name << R"( <port> <ae_title> [options]
95 port Port number to listen on (typically 104 or 11112)
96 ae_title Application Entity Title for this server (max 16 chars)
98Output Options (optional):
99 --output-dir <path> Directory to store MPPS records as JSON files
100 --output-file <path> Single JSON file to append MPPS records
103 --max-assoc <n> Maximum concurrent associations (default: 10)
104 --timeout <sec> Idle timeout in seconds (default: 300)
105 --help Show this help message
108 )" << program_name << R"( 11112 MY_MPPS
109 )" << program_name << R"( 11112 MY_MPPS --output-dir ./mpps_records
110 )" << program_name << R"( 11112 MY_MPPS --output-file ./mpps.json --max-assoc 20
113 - N-CREATE: Modality starts a procedure (status = IN PROGRESS)
114 - N-SET: Modality completes or discontinues a procedure
115 (status = COMPLETED or DISCONTINUED)
118 - Press Ctrl+C to stop the server gracefully
119 - Without output options, MPPS records are logged to console only
120 - Each MPPS instance is identified by its SOP Instance UID
124 1 Error - Failed to start server or invalid arguments
131struct mpps_scp_args {
133 std::string ae_title;
134 std::filesystem::path output_dir;
135 std::filesystem::path output_file;
136 size_t max_associations = 10;
137 uint32_t idle_timeout = 300;
147bool parse_arguments(
int argc,
char* argv[], mpps_scp_args& args) {
153 for (
int i = 1; i < argc; ++i) {
154 if (std::string(argv[i]) ==
"--help" || std::string(argv[i]) ==
"-h") {
161 int port_int = std::stoi(argv[1]);
162 if (port_int < 1 || port_int > 65535) {
163 std::cerr <<
"Error: Port must be between 1 and 65535\n";
166 args.port =
static_cast<uint16_t
>(port_int);
167 }
catch (
const std::exception&) {
168 std::cerr <<
"Error: Invalid port number '" << argv[1] <<
"'\n";
173 args.ae_title = argv[2];
174 if (args.ae_title.length() > 16) {
175 std::cerr <<
"Error: AE title exceeds 16 characters\n";
180 for (
int i = 3; i < argc; ++i) {
181 std::string arg = argv[i];
183 if (arg ==
"--output-dir" && i + 1 < argc) {
184 args.output_dir = argv[++i];
185 }
else if (arg ==
"--output-file" && i + 1 < argc) {
186 args.output_file = argv[++i];
187 }
else if (arg ==
"--max-assoc" && i + 1 < argc) {
189 int val = std::stoi(argv[++i]);
191 std::cerr <<
"Error: max-assoc must be positive\n";
194 args.max_associations =
static_cast<size_t>(val);
195 }
catch (
const std::exception&) {
196 std::cerr <<
"Error: Invalid max-assoc value\n";
199 }
else if (arg ==
"--timeout" && i + 1 < argc) {
201 int val = std::stoi(argv[++i]);
203 std::cerr <<
"Error: timeout cannot be negative\n";
206 args.idle_timeout =
static_cast<uint32_t
>(val);
207 }
catch (
const std::exception&) {
208 std::cerr <<
"Error: Invalid timeout value\n";
212 std::cerr <<
"Error: Unknown option '" << arg <<
"'\n";
228std::string current_timestamp() {
229 auto now = std::chrono::system_clock::now();
230 auto time_t_now = std::chrono::system_clock::to_time_t(now);
231 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
232 now.time_since_epoch()) % 1000;
236 localtime_s(&tm_buf, &time_t_now);
238 localtime_r(&time_t_now, &tm_buf);
241 std::ostringstream oss;
242 oss << std::put_time(&tm_buf,
"%Y-%m-%d %H:%M:%S");
243 oss <<
'.' << std::setfill(
'0') << std::setw(3) << ms.count();
250std::string json_escape(
const std::string& str) {
252 result.reserve(str.size());
255 case '"': result +=
"\\\"";
break;
256 case '\\': result +=
"\\\\";
break;
257 case '\n': result +=
"\\n";
break;
258 case '\r': result +=
"\\r";
break;
259 case '\t': result +=
"\\t";
break;
260 default: result += c;
break;
280 std::string procedure_step_id;
281 std::string start_date;
282 std::string start_time;
283 std::string end_date;
284 std::string end_time;
285 std::string created_at;
286 std::string updated_at;
292std::string to_json(
const mpps_record& record,
bool pretty =
true) {
293 std::ostringstream oss;
294 std::string indent = pretty ?
" " :
"";
295 std::string nl = pretty ?
"\n" :
"";
298 oss << indent <<
"\"sopInstanceUid\": \"" << json_escape(
record.sop_instance_uid) <<
"\"," << nl;
299 oss << indent <<
"\"status\": \"" << json_escape(
record.status) <<
"\"," << nl;
300 oss << indent <<
"\"stationAeTitle\": \"" << json_escape(
record.station_ae) <<
"\"," << nl;
301 oss << indent <<
"\"patientId\": \"" << json_escape(
record.patient_id) <<
"\"," << nl;
302 oss << indent <<
"\"patientName\": \"" << json_escape(
record.patient_name) <<
"\"," << nl;
303 oss << indent <<
"\"modality\": \"" << json_escape(
record.modality) <<
"\"," << nl;
304 oss << indent <<
"\"procedureStepId\": \"" << json_escape(
record.procedure_step_id) <<
"\"," << nl;
305 oss << indent <<
"\"startDate\": \"" << json_escape(
record.start_date) <<
"\"," << nl;
306 oss << indent <<
"\"startTime\": \"" << json_escape(
record.start_time) <<
"\"," << nl;
307 oss << indent <<
"\"endDate\": \"" << json_escape(
record.end_date) <<
"\"," << nl;
308 oss << indent <<
"\"endTime\": \"" << json_escape(
record.end_time) <<
"\"," << nl;
309 oss << indent <<
"\"createdAt\": \"" << json_escape(
record.created_at) <<
"\"," << nl;
310 oss << indent <<
"\"updatedAt\": \"" << json_escape(
record.updated_at) <<
"\"" << nl;
319class mpps_repository {
321 explicit mpps_repository(
const mpps_scp_args& args)
322 : output_dir_(args.output_dir)
323 , output_file_(args.output_file) {
326 if (!output_dir_.empty()) {
328 std::filesystem::create_directories(output_dir_, ec);
330 std::cerr <<
"Warning: Could not create output directory: "
331 << output_dir_ <<
" - " << ec.message() <<
"\n";
342 std::lock_guard<std::mutex> lock(mutex_);
349 record.created_at = current_timestamp();
358 tags::performed_procedure_step_start_date,
"");
360 tags::performed_procedure_step_start_time,
"");
370 std::cout <<
"[" << current_timestamp() <<
"] "
371 <<
"N-CREATE: MPPS instance created\n"
372 <<
" UID: " <<
record.sop_instance_uid <<
"\n"
373 <<
" Status: " <<
record.status <<
"\n"
374 <<
" Station: " <<
record.station_ae <<
"\n"
375 <<
" Patient: " <<
record.patient_id <<
" / " <<
record.patient_name <<
"\n"
376 <<
" Modality: " <<
record.modality <<
"\n";
388 const std::string& sop_instance_uid,
392 std::lock_guard<std::mutex> lock(mutex_);
395 auto it = records_.find(sop_instance_uid);
396 if (it == records_.end()) {
400 record.created_at = current_timestamp();
402 it = records_.find(sop_instance_uid);
405 auto&
record = it->second;
408 if (
record.status ==
"COMPLETED" ||
record.status ==
"DISCONTINUED") {
409 std::cerr <<
"[" << current_timestamp() <<
"] "
410 <<
"Warning: Cannot modify MPPS in final state: "
411 <<
record.sop_instance_uid <<
"\n";
414 "Cannot modify MPPS in final state");
419 record.updated_at = current_timestamp();
428 std::cout <<
"[" << current_timestamp() <<
"] "
429 <<
"N-SET: MPPS instance updated\n"
430 <<
" UID: " <<
record.sop_instance_uid <<
"\n"
431 <<
" New Status: " <<
record.status <<
"\n";
433 if (!
record.end_date.empty() || !
record.end_time.empty()) {
434 std::cout <<
" End Time: " <<
record.end_date <<
" " <<
record.end_time <<
"\n";
446 size_t size()
const {
447 std::lock_guard<std::mutex> lock(mutex_);
448 return records_.size();
454 std::map<std::string, size_t> count_by_status()
const {
455 std::lock_guard<std::mutex> lock(mutex_);
456 std::map<std::string, size_t> counts;
457 for (
const auto& [
uid, record] : records_) {
464 mutable std::mutex mutex_;
465 std::map<std::string, mpps_record> records_;
466 std::filesystem::path output_dir_;
467 std::filesystem::path output_file_;
472 void save_record(
const mpps_record& record) {
474 if (!output_dir_.empty()) {
475 auto filename = output_dir_ / (
record.sop_instance_uid +
".json");
476 std::ofstream file(filename);
478 file << to_json(record,
true);
481 std::cerr <<
"Warning: Could not write to " << filename <<
"\n";
486 if (!output_file_.empty()) {
487 std::ofstream file(output_file_, std::ios::app);
489 file << to_json(record,
false) <<
"\n";
492 std::cerr <<
"Warning: Could not write to " << output_file_ <<
"\n";
507bool run_server(
const mpps_scp_args& args) {
511 std::cout <<
"\nStarting MPPS SCP...\n";
512 std::cout <<
" AE Title: " << args.ae_title <<
"\n";
513 std::cout <<
" Port: " << args.port <<
"\n";
514 if (!args.output_dir.empty()) {
515 std::cout <<
" Output Directory: " << args.output_dir <<
"\n";
517 if (!args.output_file.empty()) {
518 std::cout <<
" Output File: " << args.output_file <<
"\n";
520 std::cout <<
" Max Associations: " << args.max_associations <<
"\n";
521 std::cout <<
" Idle Timeout: " << args.idle_timeout <<
" seconds\n";
525 mpps_repository repository(args);
528 server_config config;
529 config.ae_title = args.ae_title;
530 config.port = args.port;
531 config.max_associations = args.max_associations;
532 config.idle_timeout = std::chrono::seconds{args.idle_timeout};
533 config.implementation_class_uid =
"1.2.826.0.1.3680043.2.1545.3";
534 config.implementation_version_name =
"MPPS_SCP_001";
537 dicom_server server{config};
541 server.register_service(std::make_shared<verification_scp>());
544 auto mpps_service = std::make_shared<mpps_scp>();
547 mpps_service->set_create_handler(
549 return repository.on_create(instance);
553 mpps_service->set_set_handler(
554 [&repository](
const std::string&
uid,
557 return repository.on_set(
uid, mods, status);
560 server.register_service(mpps_service);
563 server.on_association_established([](
const association& assoc) {
564 std::cout <<
"[" << current_timestamp() <<
"] "
565 <<
"Association established from: " << assoc.calling_ae()
566 <<
" -> " << assoc.called_ae() <<
"\n";
569 server.on_association_released([](
const association& assoc) {
570 std::cout <<
"[" << current_timestamp() <<
"] "
571 <<
"Association released: " << assoc.calling_ae() <<
"\n";
574 server.on_error([](
const std::string& error) {
575 std::cerr <<
"[" << current_timestamp() <<
"] "
576 <<
"Error: " <<
error <<
"\n";
580 auto result = server.start();
581 if (result.is_err()) {
582 std::cerr <<
"Failed to start server: " << result.error().message <<
"\n";
587 std::cout <<
"=================================================\n";
588 std::cout <<
" MPPS SCP is running on port " << args.port <<
"\n";
589 std::cout <<
" Waiting for MPPS requests...\n";
590 std::cout <<
" Press Ctrl+C to stop\n";
591 std::cout <<
"=================================================\n\n";
594 server.wait_for_shutdown();
597 auto server_stats = server.get_statistics();
598 auto status_counts = repository.count_by_status();
601 std::cout <<
"=================================================\n";
602 std::cout <<
" Server Statistics\n";
603 std::cout <<
"=================================================\n";
604 std::cout <<
" Total Associations: " << server_stats.total_associations <<
"\n";
605 std::cout <<
" Rejected Associations: " << server_stats.rejected_associations <<
"\n";
606 std::cout <<
" Messages Processed: " << server_stats.messages_processed <<
"\n";
607 std::cout <<
" N-CREATE Processed: " << mpps_service->creates_processed() <<
"\n";
608 std::cout <<
" N-SET Processed: " << mpps_service->sets_processed() <<
"\n";
609 std::cout <<
" MPPS Completed: " << mpps_service->mpps_completed() <<
"\n";
610 std::cout <<
" MPPS Discontinued: " << mpps_service->mpps_discontinued() <<
"\n";
611 std::cout <<
" Total MPPS Records: " << repository.size() <<
"\n";
613 if (!status_counts.empty()) {
614 std::cout <<
" Records by Status:\n";
615 for (
const auto& [status, count] : status_counts) {
616 std::cout <<
" - " <<
status <<
": " << count <<
"\n";
620 std::cout <<
" Uptime: " << server_stats.uptime().count() <<
" seconds\n";
621 std::cout <<
"=================================================\n";
629int main(
int argc,
char* argv[]) {
631 __ __ ____ ____ ____ ____ ____ ____
632 | \/ | _ \| _ \/ ___| / ___| / ___| _ \
633 | |\/| | |_) | |_) \___ \ \___ \| | | |_) |
634 | | | | __/| __/ ___) | ___) | |___| __/
635 |_| |_|_| |_| |____/ |____/ \____|_|
637 DICOM Modality Performed Procedure Step Server
642 if (!parse_arguments(argc, argv, args)) {
643 print_usage(argv[0]);
648 install_signal_handlers();
650 bool success = run_server(args);
652 std::cout <<
"\nMPPS SCP terminated\n";
653 return success ? 0 : 1;
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
DICOM Dataset - ordered collection of Data Elements.
Multi-threaded DICOM server for handling multiple associations.
Compile-time constants for commonly used DICOM tags.
DICOM MPPS (Modality Performed Procedure Step) SCP service.
@ error
Node returned an error.
@ station_ae
(0008,1010) Station Name or calling AE
constexpr int mpps_invalid_state
@ record
RECORD - Treatment record dose.
mpps_status
MPPS status enumeration.
auto to_string(mpps_status status) -> std::string_view
Convert mpps_status to DICOM string representation.
Result< T > pacs_error(int code, const std::string &message, const std::string &details="")
Create a PACS error result with module context.
Result<T> type aliases and helpers for PACS system.
DICOM Server configuration structures.
MPPS instance data structure.
mpps_status status
Current status (always IN PROGRESS for N-CREATE)
std::string sop_instance_uid
SOP Instance UID - unique identifier for this MPPS.
std::string station_ae
Performing station AE Title.
core::dicom_dataset data
Complete MPPS dataset from the request.
DICOM Verification SCP service (C-ECHO handler)