From c9c4399c2737b8164843e76841a1c346d358d346 Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Sat, 6 Apr 2024 14:42:59 +1300 Subject: [PATCH 01/14] include re-running poetry install after updating environment variable --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3e20c22..b65892d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ If the required packages are not shown (e.g. pydicom, pynetdicom), you may have Try: ```console export VIRTUAL_ENV=$(pyenv virtualenv-prefix)/envs/$(pyenv version | cut -f1 -d ' ') +poetry install ``` From 952c172aadeb51085f23899bcad6489b5c717e1d Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Sat, 6 Apr 2024 15:17:00 +1300 Subject: [PATCH 02/14] renamed neventscp to nevent_receiver and neventscu to nevent_sender --- .../TDWII_PPVS_subscriber/nevent_receiver.py | 109 +++++ .../nevent_receiver_handlers.py | 156 +++++++ tdwii_plus_examples/nevent_receiver.py | 381 ++++++++++++++++++ .../nevent_receiver_default.ini | 31 ++ .../nevent_receiver_handlers.py | 156 +++++++ 5 files changed, 833 insertions(+) create mode 100644 tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py create mode 100644 tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver_handlers.py create mode 100755 tdwii_plus_examples/nevent_receiver.py create mode 100644 tdwii_plus_examples/nevent_receiver_default.ini create mode 100644 tdwii_plus_examples/nevent_receiver_handlers.py diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py new file mode 100644 index 0000000..d4276a4 --- /dev/null +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py @@ -0,0 +1,109 @@ +import logging +import os +import sys +from argparse import Namespace +from configparser import ConfigParser +from datetime import datetime +from typing import Tuple +from time import sleep +import pydicom.config +from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import handle_echo, handle_nevent +from pynetdicom import ( + AE, + ALL_TRANSFER_SYNTAXES, + UnifiedProcedurePresentationContexts, + _config, + _handlers, + evt, +) +from pynetdicom.apps.common import setup_logging +from pynetdicom.sop_class import Verification +from pynetdicom.utils import set_ae + +from basescp import BaseSCP +from echoscp import EchoSCP + +def nevent_cb(**kwargs): + logger = None + if "logger" in kwargs.keys(): + logger = kwargs["logger"] + if logger: + logger.info("nevent_cb invoked") + event_type_id = 0 # not a valid type ID + if logger: + logger.info( + "TODO: Invoke application response appropriate to content of N-EVENT-REPORT-RQ" + ) + if "type_id" in kwargs.keys(): + event_type_id = kwargs["type_id"] + if logger: + logger.info(f"Event Type ID is: {event_type_id}") + if "information_ds" in kwargs.keys(): + information_ds = kwargs["information_ds"] + if logger: + logger.info("Dataset in N-EVENT-REPORT-RQ: ") + logger.info(f"{information_ds}") + # TODO: replace if/elif with dict of {event_type_id,application_response_functions} + if event_type_id == 1: + if logger: + logger.info("UPS State Report") + logger.info("Probably time to do a C-FIND-RQ") + elif event_type_id == 2: + if logger: + logger.info("UPS Cancel Request") + elif event_type_id == 3: + if logger: + logger.info("UPS Progress Report") + logger.info( + "Probably time to see if the Beam (number) changed, or if adaptation is taking or took place" + ) + elif event_type_id == 4: + if logger: + logger.info("SCP Status Change") + logger.info( + "Probably a good time to check if this is a Cold Start and then re-subscribe \ + for specific UPS instances if this application has/had instance specific subscriptions" + ) + elif event_type_id == 5: + if logger: + logger.info("UPS Assigned") + logger.info( + "Not too interesting for TDW-II, UPS are typically assigned at the time of scheduling, \ + but a matching class of machines might make for a different approach" + ) + else: + if logger: + logger.warning(f"Unknown Event Type ID: {event_type_id}") + +class NEventReceiver(EchoSCP): + def __init__(self, + nevent_callback=None, + ae_title:str="NEVENT_SCP", + port:int=11115, + logger=None, + bind_address:str="" + ): + if nevent_callback is None: + self.nevent_callback = nevent_cb # fallback to something that just logs incoming events + else: + self.nevent_callback = nevent_callback + EchoSCP.__init__(self,ae_title=ae_title,port=port,logger=logger,bind_address=bind_address) + + def _add_contexts(self): + EchoSCP._add_contexts(self) + for cx in UnifiedProcedurePresentationContexts: + self.ae.add_supported_context( + cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False + ) + + def _add_handlers(self): + EchoSCP._add_handlers(self) + self.handlers.append((evt.EVT_N_EVENT_REPORT, handle_nevent, [self.nevent_callback, None, self.logger])) + + def run(self): + BaseSCP.run(self) + +if __name__ == '__main__': + my_scp = NEventReceiver() + my_scp.run() + while True: sleep(100) # sleep forever \ No newline at end of file diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver_handlers.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver_handlers.py new file mode 100644 index 0000000..cfc824e --- /dev/null +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver_handlers.py @@ -0,0 +1,156 @@ +"""Event handlers for nevent_receiver.py""" + +from pydicom import dcmread + + +def procedure_step_state_matches(query, ups): + is_match = True # until it's false? + requested_step_status = get_procedure_step_state_from_ups(query) + ups_step_status = get_procedure_step_state_from_ups(ups) + if requested_step_status is not None and len(requested_step_status) > 0: + if requested_step_status != ups_step_status: + is_match = False + return is_match + + +def machine_name_matches(query, ups): + requested_machine_name = get_machine_name_from_ups(query) + scheduled_machine_name = get_machine_name_from_ups(ups) + if requested_machine_name is not None and len(requested_machine_name) > 0: + if scheduled_machine_name != requested_machine_name: + return False + return True + + +def get_machine_name_from_ups(query): + seq = query.ScheduledStationNameCodeSequence + if seq is not None: + for item_index in range(len(seq)): + machine_name = seq[item_index].CodeValue + return machine_name + + +def get_procedure_step_state_from_ups(query): + step_status = query.ProcedureStepState + return step_status + + +def handle_echo(event, cli_config, logger): + """Handler for evt.EVT_C_ECHO. + + Parameters + ---------- + event : events.Event + The corresponding event. + cli_config : dict + A :class:`dict` containing configuration settings passed via CLI. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of the C-ECHO operation, always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") + addr, port = requestor.address, requestor.port + logger.info(f"Received C-ECHO request from {addr}:{port} at {timestamp}") + + return 0x0000 + + +def handle_nevent(event, event_response_cb, cli_config, logger): + """Handler for evt.EVT_N_EVENT. + + Parameters + ---------- + event : pynetdicom.events.Event + The N-EVENT request :class:`~pynetdicom.events.Event`. + event_response_cb : function + The function to call when receiving an Event (that is a UPS Event). + cli_config : dict + A :class:`dict` containing configuration settings passed via CLI. + logger : logging.Logger + The application's logger. + + Yields + ------ + int + The number of sub-operations required to complete the request. + int or pydicom.dataset.Dataset, pydicom.dataset.Dataset or None + The C-GET response's *Status* and if the *Status* is pending then + the dataset to be sent, otherwise ``None``. + """ + + nevent_primitive = event.request + r"""Represents a N-EVENT-REPORT primitive. + + +------------------------------------------+---------+----------+ + | Parameter | Req/ind | Rsp/conf | + +==========================================+=========+==========+ + | Message ID | M | \- | + +------------------------------------------+---------+----------+ + | Message ID Being Responded To | \- | M | + +------------------------------------------+---------+----------+ + | Affected SOP Class UID | M | U(=) | + +------------------------------------------+---------+----------+ + | Affected SOP Instance UID | M | U(=) | + +------------------------------------------+---------+----------+ + | Event Type ID | M | C(=) | + +------------------------------------------+---------+----------+ + | Event Information | U | \- | + +------------------------------------------+---------+----------+ + | Event Reply | \- | C | + +------------------------------------------+---------+----------+ + | Status | \- | M | + +------------------------------------------+---------+----------+ + + | (=) - The value of the parameter is equal to the value of the parameter + in the column to the left + | C - The parameter is conditional. + | M - Mandatory + | MF - Mandatory with a fixed value + | U - The use of this parameter is a DIMSE service user option + | UF - User option with a fixed value + + Attributes + ---------- + MessageID : int + Identifies the operation and is used to distinguish this + operation from other notifications or operations that may be in + progress. No two identical values for the Message ID shall be used for + outstanding operations. + MessageIDBeingRespondedTo : int + The Message ID of the operation request/indication to which this + response/confirmation applies. + AffectedSOPClassUID : pydicom.uid.UID, bytes or str + For the request/indication this specifies the SOP Class for + storage. If included in the response/confirmation, it shall be equal + to the value in the request/indication + Status : int + The error or success notification of the operation. + """ + requestor = event.assoc.requestor + timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") + addr, port = requestor.address, requestor.port + logger.info(f"Received N-EVENT request from {addr}:{port} at {timestamp}") + + model = event.request.AffectedSOPClassUID + nevent_type_id = nevent_primitive.EventTypeID + nevent_information = dcmread(nevent_primitive.EventInformation, force=True) + nevent_rsp_primitive = nevent_primitive + nevent_rsp_primitive.Status = 0x0000 + + logger.info(f"Event Information: {nevent_information}") + + if model.keyword in ["UnifiedProcedureStepPush"]: + event_response_cb(type_id=nevent_type_id, information_ds=nevent_information, logger=logger) + else: + logger.warning(f"Received model.keyword = {model.keyword} with AffectedSOPClassUID = {model}") + logger.warning("Not a UPS Event") + + logger.info("Finished Processing N-EVENT-REPORT-RQ") + yield 0 # Number of suboperations remaining + # If a rsp dataset of None is provided below, the underlying handler and dimse primitive in pynetdicom raises an error + yield 0 # Status diff --git a/tdwii_plus_examples/nevent_receiver.py b/tdwii_plus_examples/nevent_receiver.py new file mode 100755 index 0000000..07f99fe --- /dev/null +++ b/tdwii_plus_examples/nevent_receiver.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python +"""A Verification SCP and N_EVENT_REPORT receiver application.""" + +import argparse +import os +import sys +from configparser import ConfigParser + +import pydicom.config +from nevent_receiver_handlers import handle_echo, handle_nevent +from pynetdicom import ( + AE, + ALL_TRANSFER_SYNTAXES, + UnifiedProcedurePresentationContexts, + _config, + _handlers, + evt, +) +from pynetdicom.apps.common import setup_logging +from pynetdicom.sop_class import Verification +from pynetdicom.utils import set_ae + + +# Use `None` for empty values +pydicom.config.use_none_as_empty_text_VR_value = True +# Don't log identifiers +_config.LOG_RESPONSE_IDENTIFIERS = False + + +# Override the standard logging handlers +def _dont_log(event): + pass + + +_handlers._send_c_find_rsp = _dont_log +_handlers._send_c_get_rsp = _dont_log +_handlers._send_c_move_rsp = _dont_log +_handlers._send_c_store_rq = _dont_log +_handlers._recv_c_store_rsp = _dont_log + + +__version__ = "0.1.0" + + +def _log_config(config, logger): + """Log the configuration settings. + + Parameters + ---------- + logger : logging.Logger + The application's logger. + """ + logger.debug("Configuration settings") + app = config["DEFAULT"] + aet, port, pdu = app["ae_title"], app["port"], app["max_pdu"] + logger.debug(f" AE title: {aet}, Port: {port}, Max. PDU: {pdu}") + logger.debug(" Timeouts:") + acse, dimse = app["acse_timeout"], app["dimse_timeout"] + network = app["network_timeout"] + logger.debug(f" ACSE: {acse}, DIMSE: {dimse}, Network: {network}") + logger.debug(f" Storage directory: {app['instance_location']}") + logger.debug(f" Database location: {app['database_location']}") + + if config.sections(): + logger.debug(" Move destinations: ") + else: + logger.debug(" Move destinations: none") + + for ae_title in config.sections(): + addr = config[ae_title]["address"] + port = config[ae_title]["port"] + logger.debug(f" {ae_title}: ({addr}, {port})") + + logger.debug("") + + +def _setup_argparser(): + """Setup the command line arguments""" + # Description + parser = argparse.ArgumentParser( + description=( + "The nevent_receiver application implements a Service Class Provider (SCP) " + "for the Verification, Storage and Query/Retrieve (QR) Service " + "Classes." + ), + usage="nevent_receiver [options]", + ) + + # General Options + gen_opts = parser.add_argument_group("General Options") + gen_opts.add_argument( + "--version", help="print version information and exit", action="store_true" + ) + output = gen_opts.add_mutually_exclusive_group() + output.add_argument( + "-q", + "--quiet", + help="quiet mode, print no warnings and errors", + action="store_const", + dest="log_type", + const="q", + ) + output.add_argument( + "-v", + "--verbose", + help="verbose mode, print processing details", + action="store_const", + dest="log_type", + const="v", + ) + output.add_argument( + "-d", + "--debug", + help="debug mode, print debug information", + action="store_const", + dest="log_type", + const="d", + ) + gen_opts.add_argument( + "-ll", + "--log-level", + metavar="[l]", + help=("use level l for the logger (critical, error, warn, info, debug)"), + type=str, + choices=["critical", "error", "warn", "info", "debug"], + ) + fdir = os.path.abspath(os.path.dirname(__file__)) + fpath = os.path.join(fdir, "nevent_receiver_default.ini") + gen_opts.add_argument( + "-c", + "--config", + metavar="[f]ilename", + help="use configuration file f", + default=fpath, + ) + + net_opts = parser.add_argument_group("Networking Options") + net_opts.add_argument( + "--port", + help="override the configured TCP/IP listen port number", + ) + net_opts.add_argument( + "-aet", + "--ae-title", + metavar="[a]etitle", + help="override the configured AE title", + ) + net_opts.add_argument( + "-ta", + "--acse-timeout", + metavar="[s]econds", + help="override the configured timeout for ACSE messages", + ) + net_opts.add_argument( + "-td", + "--dimse-timeout", + metavar="[s]econds", + help="override the configured timeout for DIMSE messages", + ) + net_opts.add_argument( + "-tn", + "--network-timeout", + metavar="[s]econds", + help="override the configured timeout for the network", + ) + net_opts.add_argument( + "-pdu", + "--max-pdu", + metavar="[n]umber of bytes", + help="override the configured max receive pdu to n bytes", + ) + net_opts.add_argument( + "-ba", + "--bind-address", + metavar="[a]ddress", + help="override the configured address of the network interface to listen on", + ) + + db_opts = parser.add_argument_group("Database Options") + db_opts.add_argument( + "--database-location", + metavar="[f]ile", + help="override the location of the database using file f", + type=str, + ) + db_opts.add_argument( + "--instance-location", + metavar="[d]irectory", + help=("override the configured instance storage location to directory d"), + type=str, + ) + db_opts.add_argument( + "--clean", + help=( + "remove all entries from the database and delete the " + "corresponding stored instances" + ), + action="store_true", + ) + + return parser.parse_args() + + +def nevent_cb(**kwargs): + logger = None + if "logger" in kwargs.keys(): + logger = kwargs["logger"] + if logger: + logger.info("nevent_cb invoked") + event_type_id = 0 # not a valid type ID + if logger: + logger.info( + "TODO: Invoke application response appropriate to content of N-EVENT-REPORT-RQ" + ) + if "type_id" in kwargs.keys(): + event_type_id = kwargs["type_id"] + if logger: + logger.info(f"Event Type ID is: {event_type_id}") + if "information_ds" in kwargs.keys(): + information_ds = kwargs["information_ds"] + if logger: + logger.info("Dataset in N-EVENT-REPORT-RQ: ") + logger.info(f"{information_ds}") + # TODO: replace if/elif with dict of {event_type_id,application_response_functions} + if event_type_id == 1: + if logger: + logger.info("UPS State Report") + logger.info("Probably time to do a C-FIND-RQ") + elif event_type_id == 2: + if logger: + logger.info("UPS Cancel Request") + elif event_type_id == 3: + if logger: + logger.info("UPS Progress Report") + logger.info( + "Probably time to see if the Beam (number) changed, or if adaptation is taking or took place" + ) + elif event_type_id == 4: + if logger: + logger.info("SCP Status Change") + logger.info( + "Probably a good time to check if this is a Cold Start and then re-subscribe \ + for specific UPS instances if this application has/had instance specific subscriptions" + ) + elif event_type_id == 5: + if logger: + logger.info("UPS Assigned") + logger.info( + "Not too interesting for TDW-II, UPS are typically assigned at the time of scheduling, \ + but a matching class of machines might make for a different approach" + ) + else: + if logger: + logger.warning(f"Unkown Event Type ID: {event_type_id}") + + +def main(args=None): + """Run the application.""" + if args is not None: + sys.argv = args + + args = _setup_argparser() + + if args.version: + print(f"nevent_receiver.py v{__version__}") + sys.exit() + + APP_LOGGER = setup_logging(args, "nevent_receiver") + APP_LOGGER.debug(f"nevent_receiver.py v{__version__}") + APP_LOGGER.debug("") + + APP_LOGGER.debug("Using configuration from:") + APP_LOGGER.debug(f" {args.config}") + APP_LOGGER.debug("") + config = ConfigParser() + config.read(args.config) + + if args.ae_title: + config["DEFAULT"]["ae_title"] = args.ae_title + if args.port: + config["DEFAULT"]["port"] = args.port + if args.max_pdu: + config["DEFAULT"]["max_pdu"] = args.max_pdu + if args.acse_timeout: + config["DEFAULT"]["acse_timeout"] = args.acse_timeout + if args.dimse_timeout: + config["DEFAULT"]["dimse_timeout"] = args.dimse_timeout + if args.network_timeout: + config["DEFAULT"]["network_timeout"] = args.network_timeout + if args.bind_address: + config["DEFAULT"]["bind_address"] = args.bind_address + if args.database_location: + config["DEFAULT"]["database_location"] = args.database_location + if args.instance_location: + config["DEFAULT"]["instance_location"] = args.instance_location + + # Log configuration settings + _log_config(config, APP_LOGGER) + app_config = config["DEFAULT"] + + dests = {} + for ae_title in config.sections(): + dest = config[ae_title] + # Convert to bytes and validate the AE title + ae_title = set_ae(ae_title, "ae_title", False, False) + dests[ae_title] = (dest["address"], dest.getint("port")) + + # Use default or specified configuration file + current_dir = os.path.abspath(os.path.dirname(__file__)) + instance_dir = os.path.join(current_dir, app_config["instance_location"]) + # db_path = os.path.join(current_dir, app_config["database_location"]) + + # Clean up the database and storage directory + if args.clean: + response = input( + "This will delete all instances from both the storage directory " + "and the database. Are you sure you wish to continue? [yes/no]: " + ) + if response != "yes": + sys.exit() + + # if clean(db_path, instance_dir, APP_LOGGER): + # sys.exit() + # else: + # sys.exit(1) + + # Try to create the instance storage directory + os.makedirs(instance_dir, exist_ok=True) + + ae = AE(app_config["ae_title"]) + ae.maximum_pdu_size = app_config.getint("max_pdu") + ae.acse_timeout = app_config.getfloat("acse_timeout") + ae.dimse_timeout = app_config.getfloat("dimse_timeout") + ae.network_timeout = app_config.getfloat("network_timeout") + + # Add supported presentation contexts + # Verification SCP + ae.add_supported_context(Verification, ALL_TRANSFER_SYNTAXES) + + # # Storage SCP - support all transfer syntaxes + # for cx in AllStoragePresentationContexts: + # ae.add_supported_context( + # cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False + # ) + + # # Query/Retrieve SCP + # ae.add_supported_context(PatientRootQueryRetrieveInformationModelFind) + # ae.add_supported_context(PatientRootQueryRetrieveInformationModelMove) + # ae.add_supported_context(PatientRootQueryRetrieveInformationModelGet) + # ae.add_supported_context(StudyRootQueryRetrieveInformationModelFind) + # ae.add_supported_context(StudyRootQueryRetrieveInformationModelMove) + # ae.add_supported_context(StudyRootQueryRetrieveInformationModelGet) + + # Unified Procedure Step SCP + for cx in UnifiedProcedurePresentationContexts: + ae.add_supported_context( + cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False + ) + + APP_LOGGER.info(f"Configured for instance_dir = {instance_dir}") + # Set our handler bindings + handlers = [ + (evt.EVT_C_ECHO, handle_echo, [args, APP_LOGGER]), + # (evt.EVT_C_FIND, handle_find, [instance_dir, args, APP_LOGGER]), + # (evt.EVT_C_GET, handle_get, [db_path, args, APP_LOGGER]), + # (evt.EVT_C_MOVE, handle_move, [dests, db_path, args, APP_LOGGER]), + # (evt.EVT_C_STORE, handle_store, [instance_dir, db_path, args, APP_LOGGER]), + # (evt.EVT_N_GET, handle_nget, [db_path, args, APP_LOGGER]), + # (evt.EVT_N_ACTION, handle_naction, [db_path, args, APP_LOGGER]), + (evt.EVT_N_EVENT_REPORT, handle_nevent, [nevent_cb, args, APP_LOGGER]), + # (evt.EVT_N_SET, handle_nset, [db_path, args, APP_LOGGER]), + ] + + # Listen for incoming association requests + ae.start_server( + (app_config["bind_address"], app_config.getint("port")), evt_handlers=handlers + ) + + +if __name__ == "__main__": + main() diff --git a/tdwii_plus_examples/nevent_receiver_default.ini b/tdwii_plus_examples/nevent_receiver_default.ini new file mode 100644 index 0000000..451d852 --- /dev/null +++ b/tdwii_plus_examples/nevent_receiver_default.ini @@ -0,0 +1,31 @@ +# Default configuration file for upsscp.py + +## Application settings +[DEFAULT] + # Our AE Title + ae_title: NEVENT_RECEIVER + # Our listen port + port: 11115 + # Our maximum PDU size; 0 for unlimited + max_pdu: 16382 + # The ACSE, DIMSE and network timeouts (in seconds) + acse_timeout: 30 + dimse_timeout: 30 + network_timeout: 30 + # The address of the network interface to listen on + # If unset, listen on all interfaces + bind_address: + # Directory where SOP Instances received from Storage SCUs will be stored + # This directory contains the QR service's managed SOP Instances + instance_location: nevent_instances + # Location of sqlite3 database for the QR service's managed SOP Instances + database_location: nevent_instances.sqlite + # Log C-FIND, C-GET and C-MOVE Identifier datasets + log_identifier: True + + +## Move Destinations +# The AE title of the move destination, as ASCII +[STORESCP] + address: 127.0.0.1 + port: 11113 diff --git a/tdwii_plus_examples/nevent_receiver_handlers.py b/tdwii_plus_examples/nevent_receiver_handlers.py new file mode 100644 index 0000000..cfc824e --- /dev/null +++ b/tdwii_plus_examples/nevent_receiver_handlers.py @@ -0,0 +1,156 @@ +"""Event handlers for nevent_receiver.py""" + +from pydicom import dcmread + + +def procedure_step_state_matches(query, ups): + is_match = True # until it's false? + requested_step_status = get_procedure_step_state_from_ups(query) + ups_step_status = get_procedure_step_state_from_ups(ups) + if requested_step_status is not None and len(requested_step_status) > 0: + if requested_step_status != ups_step_status: + is_match = False + return is_match + + +def machine_name_matches(query, ups): + requested_machine_name = get_machine_name_from_ups(query) + scheduled_machine_name = get_machine_name_from_ups(ups) + if requested_machine_name is not None and len(requested_machine_name) > 0: + if scheduled_machine_name != requested_machine_name: + return False + return True + + +def get_machine_name_from_ups(query): + seq = query.ScheduledStationNameCodeSequence + if seq is not None: + for item_index in range(len(seq)): + machine_name = seq[item_index].CodeValue + return machine_name + + +def get_procedure_step_state_from_ups(query): + step_status = query.ProcedureStepState + return step_status + + +def handle_echo(event, cli_config, logger): + """Handler for evt.EVT_C_ECHO. + + Parameters + ---------- + event : events.Event + The corresponding event. + cli_config : dict + A :class:`dict` containing configuration settings passed via CLI. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of the C-ECHO operation, always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") + addr, port = requestor.address, requestor.port + logger.info(f"Received C-ECHO request from {addr}:{port} at {timestamp}") + + return 0x0000 + + +def handle_nevent(event, event_response_cb, cli_config, logger): + """Handler for evt.EVT_N_EVENT. + + Parameters + ---------- + event : pynetdicom.events.Event + The N-EVENT request :class:`~pynetdicom.events.Event`. + event_response_cb : function + The function to call when receiving an Event (that is a UPS Event). + cli_config : dict + A :class:`dict` containing configuration settings passed via CLI. + logger : logging.Logger + The application's logger. + + Yields + ------ + int + The number of sub-operations required to complete the request. + int or pydicom.dataset.Dataset, pydicom.dataset.Dataset or None + The C-GET response's *Status* and if the *Status* is pending then + the dataset to be sent, otherwise ``None``. + """ + + nevent_primitive = event.request + r"""Represents a N-EVENT-REPORT primitive. + + +------------------------------------------+---------+----------+ + | Parameter | Req/ind | Rsp/conf | + +==========================================+=========+==========+ + | Message ID | M | \- | + +------------------------------------------+---------+----------+ + | Message ID Being Responded To | \- | M | + +------------------------------------------+---------+----------+ + | Affected SOP Class UID | M | U(=) | + +------------------------------------------+---------+----------+ + | Affected SOP Instance UID | M | U(=) | + +------------------------------------------+---------+----------+ + | Event Type ID | M | C(=) | + +------------------------------------------+---------+----------+ + | Event Information | U | \- | + +------------------------------------------+---------+----------+ + | Event Reply | \- | C | + +------------------------------------------+---------+----------+ + | Status | \- | M | + +------------------------------------------+---------+----------+ + + | (=) - The value of the parameter is equal to the value of the parameter + in the column to the left + | C - The parameter is conditional. + | M - Mandatory + | MF - Mandatory with a fixed value + | U - The use of this parameter is a DIMSE service user option + | UF - User option with a fixed value + + Attributes + ---------- + MessageID : int + Identifies the operation and is used to distinguish this + operation from other notifications or operations that may be in + progress. No two identical values for the Message ID shall be used for + outstanding operations. + MessageIDBeingRespondedTo : int + The Message ID of the operation request/indication to which this + response/confirmation applies. + AffectedSOPClassUID : pydicom.uid.UID, bytes or str + For the request/indication this specifies the SOP Class for + storage. If included in the response/confirmation, it shall be equal + to the value in the request/indication + Status : int + The error or success notification of the operation. + """ + requestor = event.assoc.requestor + timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") + addr, port = requestor.address, requestor.port + logger.info(f"Received N-EVENT request from {addr}:{port} at {timestamp}") + + model = event.request.AffectedSOPClassUID + nevent_type_id = nevent_primitive.EventTypeID + nevent_information = dcmread(nevent_primitive.EventInformation, force=True) + nevent_rsp_primitive = nevent_primitive + nevent_rsp_primitive.Status = 0x0000 + + logger.info(f"Event Information: {nevent_information}") + + if model.keyword in ["UnifiedProcedureStepPush"]: + event_response_cb(type_id=nevent_type_id, information_ds=nevent_information, logger=logger) + else: + logger.warning(f"Received model.keyword = {model.keyword} with AffectedSOPClassUID = {model}") + logger.warning("Not a UPS Event") + + logger.info("Finished Processing N-EVENT-REPORT-RQ") + yield 0 # Number of suboperations remaining + # If a rsp dataset of None is provided below, the underlying handler and dimse primitive in pynetdicom raises an error + yield 0 # Status From 44637c3f6a611bfe164ee20d140bdc2e9a88409f Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Sat, 6 Apr 2024 15:29:10 +1300 Subject: [PATCH 03/14] renamed neventscp to nevent_receiver, renamed neventscu to nevent_sender --- README.md | 8 +- .../TDWII_PPVS_subscriber/basescp.py | 2 +- .../TDWII_PPVS_subscriber/echoscp.py | 2 +- .../TDWII_PPVS_subscriber/neventscp.py | 109 ----- .../neventscp_handlers.py | 156 ------- .../TDWII_PPVS_subscriber/ppvsscp.py | 10 +- .../{neventscu.py => nevent_sender.py} | 14 +- tdwii_plus_examples/neventscp.py | 382 ------------------ tdwii_plus_examples/neventscp_default.ini | 31 -- tdwii_plus_examples/neventscp_handlers.py | 156 ------- tdwii_plus_examples/upsdb.py | 2 +- 11 files changed, 20 insertions(+), 852 deletions(-) delete mode 100644 tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp.py delete mode 100644 tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp_handlers.py rename tdwii_plus_examples/{neventscu.py => nevent_sender.py} (95%) delete mode 100755 tdwii_plus_examples/neventscp.py delete mode 100644 tdwii_plus_examples/neventscp_default.ini delete mode 100644 tdwii_plus_examples/neventscp_handlers.py diff --git a/README.md b/README.md index b65892d..1e507ee 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,9 @@ python nactionscu.py -T "1.2.826.0.1.3680043.8.498.23133079088775253446636289730 ``` the above will request that upsscp (listening at 11114) change the state using the Transaction UID (-T) of the UPS with the shown UID to "COMPLETED". The Transaction UID here is not optional. -## A sample application for receiving notifications (N-EVENT-REPORT-RQ) is provided in neventscp.py +## A sample application for receiving notifications (N-EVENT-REPORT-RQ) is provided in nevent_receiver.py ```console -python neventscp.py --debug +python nevent_receiver.py --debug ``` which listens on port 11115 by default, @@ -141,9 +141,9 @@ The application does not take specific actions when receiving an N-EVENT-REPORT -## A sample application for sending notifications is provided in neventscu.py (which can be run against neventscp.py mentioned above) +## A sample application for sending notifications is provided in nevent_sender.py (which can be run against nevent_receiver.py mentioned above) ```console -python neventscu.py 127.0.0.1 11115 +python nevent_sender.py 127.0.0.1 11115 ``` ## A Qt/PySide6 based utility for generating RT Beams Delivery Instructions and Unified Procedure Step content diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py index df1b996..28a7876 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py @@ -7,7 +7,7 @@ from typing import Tuple import pydicom.config -from neventscp_handlers import handle_echo +from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import handle_echo from pynetdicom import ( AE, ALL_TRANSFER_SYNTAXES, diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py index 249297f..5238afa 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py @@ -7,7 +7,7 @@ from typing import Tuple from time import sleep import pydicom.config -from neventscp_handlers import handle_echo +from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import handle_echo from pynetdicom import ( AE, ALL_TRANSFER_SYNTAXES, diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp.py deleted file mode 100644 index 7f08821..0000000 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp.py +++ /dev/null @@ -1,109 +0,0 @@ -import logging -import os -import sys -from argparse import Namespace -from configparser import ConfigParser -from datetime import datetime -from typing import Tuple -from time import sleep -import pydicom.config -from neventscp_handlers import handle_echo, handle_nevent -from pynetdicom import ( - AE, - ALL_TRANSFER_SYNTAXES, - UnifiedProcedurePresentationContexts, - _config, - _handlers, - evt, -) -from pynetdicom.apps.common import setup_logging -from pynetdicom.sop_class import Verification -from pynetdicom.utils import set_ae - -from basescp import BaseSCP -from echoscp import EchoSCP - -def nevent_cb(**kwargs): - logger = None - if "logger" in kwargs.keys(): - logger = kwargs["logger"] - if logger: - logger.info("nevent_cb invoked") - event_type_id = 0 # not a valid type ID - if logger: - logger.info( - "TODO: Invoke application response appropriate to content of N-EVENT-REPORT-RQ" - ) - if "type_id" in kwargs.keys(): - event_type_id = kwargs["type_id"] - if logger: - logger.info(f"Event Type ID is: {event_type_id}") - if "information_ds" in kwargs.keys(): - information_ds = kwargs["information_ds"] - if logger: - logger.info("Dataset in N-EVENT-REPORT-RQ: ") - logger.info(f"{information_ds}") - # TODO: replace if/elif with dict of {event_type_id,application_response_functions} - if event_type_id == 1: - if logger: - logger.info("UPS State Report") - logger.info("Probably time to do a C-FIND-RQ") - elif event_type_id == 2: - if logger: - logger.info("UPS Cancel Request") - elif event_type_id == 3: - if logger: - logger.info("UPS Progress Report") - logger.info( - "Probably time to see if the Beam (number) changed, or if adaptation is taking or took place" - ) - elif event_type_id == 4: - if logger: - logger.info("SCP Status Change") - logger.info( - "Probably a good time to check if this is a Cold Start and then re-subscribe \ - for specific UPS instances if this application has/had instance specific subscriptions" - ) - elif event_type_id == 5: - if logger: - logger.info("UPS Assigned") - logger.info( - "Not too interesting for TDW-II, UPS are typically assigned at the time of scheduling, \ - but a matching class of machines might make for a different approach" - ) - else: - if logger: - logger.warning(f"Unknown Event Type ID: {event_type_id}") - -class NEventSCP(EchoSCP): - def __init__(self, - nevent_callback=None, - ae_title:str="NEVENT_SCP", - port:int=11115, - logger=None, - bind_address:str="" - ): - if nevent_callback is None: - self.nevent_callback = nevent_cb # fallback to something that just logs incoming events - else: - self.nevent_callback = nevent_callback - EchoSCP.__init__(self,ae_title=ae_title,port=port,logger=logger,bind_address=bind_address) - - def _add_contexts(self): - EchoSCP._add_contexts(self) - for cx in UnifiedProcedurePresentationContexts: - self.ae.add_supported_context( - cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False - ) - - def _add_handlers(self): - EchoSCP._add_handlers(self) - self.handlers.append((evt.EVT_N_EVENT_REPORT, handle_nevent, [self.nevent_callback, None, self.logger])) - - def run(self): - BaseSCP.run(self) - -if __name__ == '__main__': - my_scp = NEventSCP() - my_scp.run() - while True: sleep(100) # sleep forever \ No newline at end of file diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp_handlers.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp_handlers.py deleted file mode 100644 index e61fd51..0000000 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/neventscp_handlers.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Event handlers for neventscp.py""" - -from pydicom import dcmread - - -def procedure_step_state_matches(query, ups): - is_match = True # until it's false? - requested_step_status = get_procedure_step_state_from_ups(query) - ups_step_status = get_procedure_step_state_from_ups(ups) - if requested_step_status is not None and len(requested_step_status) > 0: - if requested_step_status != ups_step_status: - is_match = False - return is_match - - -def machine_name_matches(query, ups): - requested_machine_name = get_machine_name_from_ups(query) - scheduled_machine_name = get_machine_name_from_ups(ups) - if requested_machine_name is not None and len(requested_machine_name) > 0: - if scheduled_machine_name != requested_machine_name: - return False - return True - - -def get_machine_name_from_ups(query): - seq = query.ScheduledStationNameCodeSequence - if seq is not None: - for item_index in range(len(seq)): - machine_name = seq[item_index].CodeValue - return machine_name - - -def get_procedure_step_state_from_ups(query): - step_status = query.ProcedureStepState - return step_status - - -def handle_echo(event, cli_config, logger): - """Handler for evt.EVT_C_ECHO. - - Parameters - ---------- - event : events.Event - The corresponding event. - cli_config : dict - A :class:`dict` containing configuration settings passed via CLI. - logger : logging.Logger - The application's logger. - - Returns - ------- - int - The status of the C-ECHO operation, always ``0x0000`` (Success). - """ - requestor = event.assoc.requestor - timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") - addr, port = requestor.address, requestor.port - logger.info(f"Received C-ECHO request from {addr}:{port} at {timestamp}") - - return 0x0000 - - -def handle_nevent(event, event_response_cb, cli_config, logger): - """Handler for evt.EVT_N_EVENT. - - Parameters - ---------- - event : pynetdicom.events.Event - The N-EVENT request :class:`~pynetdicom.events.Event`. - event_response_cb : function - The function to call when receiving an Event (that is a UPS Event). - cli_config : dict - A :class:`dict` containing configuration settings passed via CLI. - logger : logging.Logger - The application's logger. - - Yields - ------ - int - The number of sub-operations required to complete the request. - int or pydicom.dataset.Dataset, pydicom.dataset.Dataset or None - The C-GET response's *Status* and if the *Status* is pending then - the dataset to be sent, otherwise ``None``. - """ - - nevent_primitive = event.request - r"""Represents a N-EVENT-REPORT primitive. - - +------------------------------------------+---------+----------+ - | Parameter | Req/ind | Rsp/conf | - +==========================================+=========+==========+ - | Message ID | M | \- | - +------------------------------------------+---------+----------+ - | Message ID Being Responded To | \- | M | - +------------------------------------------+---------+----------+ - | Affected SOP Class UID | M | U(=) | - +------------------------------------------+---------+----------+ - | Affected SOP Instance UID | M | U(=) | - +------------------------------------------+---------+----------+ - | Event Type ID | M | C(=) | - +------------------------------------------+---------+----------+ - | Event Information | U | \- | - +------------------------------------------+---------+----------+ - | Event Reply | \- | C | - +------------------------------------------+---------+----------+ - | Status | \- | M | - +------------------------------------------+---------+----------+ - - | (=) - The value of the parameter is equal to the value of the parameter - in the column to the left - | C - The parameter is conditional. - | M - Mandatory - | MF - Mandatory with a fixed value - | U - The use of this parameter is a DIMSE service user option - | UF - User option with a fixed value - - Attributes - ---------- - MessageID : int - Identifies the operation and is used to distinguish this - operation from other notifications or operations that may be in - progress. No two identical values for the Message ID shall be used for - outstanding operations. - MessageIDBeingRespondedTo : int - The Message ID of the operation request/indication to which this - response/confirmation applies. - AffectedSOPClassUID : pydicom.uid.UID, bytes or str - For the request/indication this specifies the SOP Class for - storage. If included in the response/confirmation, it shall be equal - to the value in the request/indication - Status : int - The error or success notification of the operation. - """ - requestor = event.assoc.requestor - timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") - addr, port = requestor.address, requestor.port - logger.info(f"Received N-EVENT request from {addr}:{port} at {timestamp}") - - model = event.request.AffectedSOPClassUID - nevent_type_id = nevent_primitive.EventTypeID - nevent_information = dcmread(nevent_primitive.EventInformation, force=True) - nevent_rsp_primitive = nevent_primitive - nevent_rsp_primitive.Status = 0x0000 - - logger.info(f"Event Information: {nevent_information}") - - if model.keyword in ["UnifiedProcedureStepPush"]: - event_response_cb(type_id=nevent_type_id, information_ds=nevent_information, logger=logger) - else: - logger.warning(f"Received model.keyword = {model.keyword} with AffectedSOPClassUID = {model}") - logger.warning("Not a UPS Event") - - logger.info("Finished Processing N-EVENT-REPORT-RQ") - yield 0 # Number of suboperations remaining - # If a rsp dataset of None is provided below, the underlying handler and dimse primitive in pynetdicom raises an error - yield 0 # Status diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py index 41af5a4..e820d15 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py @@ -23,10 +23,10 @@ from basescp import BaseSCP from echoscp import EchoSCP -from neventscp import NEventSCP +from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver import NEventReceiver from storescp import StoreSCP -class PPVS_SCP(NEventSCP,StoreSCP): +class PPVS_SCP(NEventReceiver,StoreSCP): def __init__(self, ae_title:str="PPVS_SCP", port:int=11112, @@ -50,7 +50,7 @@ def __init__(self, storage_presentation_contexts=storage_presentation_contexts, transfer_syntaxes=transfer_syntaxes, store_directory=store_directory) - NEventSCP.__init__(self, + NEventReceiver.__init__(self, nevent_callback=nevent_callback, ae_title=ae_title, port=port, @@ -61,7 +61,7 @@ def __init__(self, def _add_contexts(self): StoreSCP._add_contexts(self) - NEventSCP._add_contexts(self) + NEventReceiver._add_contexts(self) @@ -69,7 +69,7 @@ def _add_contexts(self): def _add_handlers(self): StoreSCP._add_handlers(self) - NEventSCP._add_handlers(self) + NEventReceiver._add_handlers(self) def run(self): # Listen for incoming association requests diff --git a/tdwii_plus_examples/neventscu.py b/tdwii_plus_examples/nevent_sender.py similarity index 95% rename from tdwii_plus_examples/neventscu.py rename to tdwii_plus_examples/nevent_sender.py index 01f9e6d..d4f924d 100755 --- a/tdwii_plus_examples/neventscu.py +++ b/tdwii_plus_examples/nevent_sender.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""neventscu +"""nevent_sender Used for sending events to AE's who subscribes for UPS Events Currently at the toy level of functionality: @@ -104,10 +104,12 @@ def _setup_argparser(): # Description parser = argparse.ArgumentParser( description=( - "The neventscu application implements a Service Class User " + "The nevent_sender application implements a Service Class User " "(SCU) for the UPS Event Class. " + "Real world applications will often embed the nevent_sender functionality in an SCP" + "With the nevent_sender acting under role reversal (i.e.) as an SCU within the SCP" ), - usage="neventscu [options] addr port", + usage="nevent_sender [options] addr port", ) # Parameters @@ -309,11 +311,11 @@ def main(args=None): args = _setup_argparser() if args.version: - print(f"neventscu.py v{__version__}") + print(f"nevent_sender.py v{__version__}") sys.exit() - APP_LOGGER = setup_logging(args, "neventscu") - APP_LOGGER.debug(f"neventscu.py v{__version__}") + APP_LOGGER = setup_logging(args, "nevent_sender") + APP_LOGGER.debug(f"nevent_sender.py v{__version__}") APP_LOGGER.debug("") APP_LOGGER.debug("Using configuration from:") diff --git a/tdwii_plus_examples/neventscp.py b/tdwii_plus_examples/neventscp.py deleted file mode 100755 index f6c7a31..0000000 --- a/tdwii_plus_examples/neventscp.py +++ /dev/null @@ -1,382 +0,0 @@ -#!/usr/bin/env python -"""A Verification and N_EVENT_REPORT SCP application.""" - -import argparse -import os -import sys -from configparser import ConfigParser - -import pydicom.config -from neventscp_handlers import handle_echo, handle_nevent -from pynetdicom import ( - AE, - ALL_TRANSFER_SYNTAXES, - UnifiedProcedurePresentationContexts, - _config, - _handlers, - evt, -) -from pynetdicom.apps.common import setup_logging -from pynetdicom.sop_class import Verification -from pynetdicom.utils import set_ae - -# from pynetdicom.apps.neventscp import db - -# Use `None` for empty values -pydicom.config.use_none_as_empty_text_VR_value = True -# Don't log identifiers -_config.LOG_RESPONSE_IDENTIFIERS = False - - -# Override the standard logging handlers -def _dont_log(event): - pass - - -_handlers._send_c_find_rsp = _dont_log -_handlers._send_c_get_rsp = _dont_log -_handlers._send_c_move_rsp = _dont_log -_handlers._send_c_store_rq = _dont_log -_handlers._recv_c_store_rsp = _dont_log - - -__version__ = "0.1.0" - - -def _log_config(config, logger): - """Log the configuration settings. - - Parameters - ---------- - logger : logging.Logger - The application's logger. - """ - logger.debug("Configuration settings") - app = config["DEFAULT"] - aet, port, pdu = app["ae_title"], app["port"], app["max_pdu"] - logger.debug(f" AE title: {aet}, Port: {port}, Max. PDU: {pdu}") - logger.debug(" Timeouts:") - acse, dimse = app["acse_timeout"], app["dimse_timeout"] - network = app["network_timeout"] - logger.debug(f" ACSE: {acse}, DIMSE: {dimse}, Network: {network}") - logger.debug(f" Storage directory: {app['instance_location']}") - logger.debug(f" Database location: {app['database_location']}") - - if config.sections(): - logger.debug(" Move destinations: ") - else: - logger.debug(" Move destinations: none") - - for ae_title in config.sections(): - addr = config[ae_title]["address"] - port = config[ae_title]["port"] - logger.debug(f" {ae_title}: ({addr}, {port})") - - logger.debug("") - - -def _setup_argparser(): - """Setup the command line arguments""" - # Description - parser = argparse.ArgumentParser( - description=( - "The neventscp application implements a Service Class Provider (SCP) " - "for the Verification, Storage and Query/Retrieve (QR) Service " - "Classes." - ), - usage="neventscp [options]", - ) - - # General Options - gen_opts = parser.add_argument_group("General Options") - gen_opts.add_argument( - "--version", help="print version information and exit", action="store_true" - ) - output = gen_opts.add_mutually_exclusive_group() - output.add_argument( - "-q", - "--quiet", - help="quiet mode, print no warnings and errors", - action="store_const", - dest="log_type", - const="q", - ) - output.add_argument( - "-v", - "--verbose", - help="verbose mode, print processing details", - action="store_const", - dest="log_type", - const="v", - ) - output.add_argument( - "-d", - "--debug", - help="debug mode, print debug information", - action="store_const", - dest="log_type", - const="d", - ) - gen_opts.add_argument( - "-ll", - "--log-level", - metavar="[l]", - help=("use level l for the logger (critical, error, warn, info, debug)"), - type=str, - choices=["critical", "error", "warn", "info", "debug"], - ) - fdir = os.path.abspath(os.path.dirname(__file__)) - fpath = os.path.join(fdir, "neventscp_default.ini") - gen_opts.add_argument( - "-c", - "--config", - metavar="[f]ilename", - help="use configuration file f", - default=fpath, - ) - - net_opts = parser.add_argument_group("Networking Options") - net_opts.add_argument( - "--port", - help="override the configured TCP/IP listen port number", - ) - net_opts.add_argument( - "-aet", - "--ae-title", - metavar="[a]etitle", - help="override the configured AE title", - ) - net_opts.add_argument( - "-ta", - "--acse-timeout", - metavar="[s]econds", - help="override the configured timeout for ACSE messages", - ) - net_opts.add_argument( - "-td", - "--dimse-timeout", - metavar="[s]econds", - help="override the configured timeout for DIMSE messages", - ) - net_opts.add_argument( - "-tn", - "--network-timeout", - metavar="[s]econds", - help="override the configured timeout for the network", - ) - net_opts.add_argument( - "-pdu", - "--max-pdu", - metavar="[n]umber of bytes", - help="override the configured max receive pdu to n bytes", - ) - net_opts.add_argument( - "-ba", - "--bind-address", - metavar="[a]ddress", - help="override the configured address of the network interface to listen on", - ) - - db_opts = parser.add_argument_group("Database Options") - db_opts.add_argument( - "--database-location", - metavar="[f]ile", - help="override the location of the database using file f", - type=str, - ) - db_opts.add_argument( - "--instance-location", - metavar="[d]irectory", - help=("override the configured instance storage location to directory d"), - type=str, - ) - db_opts.add_argument( - "--clean", - help=( - "remove all entries from the database and delete the " - "corresponding stored instances" - ), - action="store_true", - ) - - return parser.parse_args() - - -def nevent_cb(**kwargs): - logger = None - if "logger" in kwargs.keys(): - logger = kwargs["logger"] - if logger: - logger.info("nevent_cb invoked") - event_type_id = 0 # not a valid type ID - if logger: - logger.info( - "TODO: Invoke application response appropriate to content of N-EVENT-REPORT-RQ" - ) - if "type_id" in kwargs.keys(): - event_type_id = kwargs["type_id"] - if logger: - logger.info(f"Event Type ID is: {event_type_id}") - if "information_ds" in kwargs.keys(): - information_ds = kwargs["information_ds"] - if logger: - logger.info("Dataset in N-EVENT-REPORT-RQ: ") - logger.info(f"{information_ds}") - # TODO: replace if/elif with dict of {event_type_id,application_response_functions} - if event_type_id == 1: - if logger: - logger.info("UPS State Report") - logger.info("Probably time to do a C-FIND-RQ") - elif event_type_id == 2: - if logger: - logger.info("UPS Cancel Request") - elif event_type_id == 3: - if logger: - logger.info("UPS Progress Report") - logger.info( - "Probably time to see if the Beam (number) changed, or if adaptation is taking or took place" - ) - elif event_type_id == 4: - if logger: - logger.info("SCP Status Change") - logger.info( - "Probably a good time to check if this is a Cold Start and then re-subscribe \ - for specific UPS instances if this application has/had instance specific subscriptions" - ) - elif event_type_id == 5: - if logger: - logger.info("UPS Assigned") - logger.info( - "Not too interesting for TDW-II, UPS are typically assigned at the time of scheduling, \ - but a matching class of machines might make for a different approach" - ) - else: - if logger: - logger.warning(f"Unkown Event Type ID: {event_type_id}") - - -def main(args=None): - """Run the application.""" - if args is not None: - sys.argv = args - - args = _setup_argparser() - - if args.version: - print(f"neventscp.py v{__version__}") - sys.exit() - - APP_LOGGER = setup_logging(args, "neventscp") - APP_LOGGER.debug(f"neventscp.py v{__version__}") - APP_LOGGER.debug("") - - APP_LOGGER.debug("Using configuration from:") - APP_LOGGER.debug(f" {args.config}") - APP_LOGGER.debug("") - config = ConfigParser() - config.read(args.config) - - if args.ae_title: - config["DEFAULT"]["ae_title"] = args.ae_title - if args.port: - config["DEFAULT"]["port"] = args.port - if args.max_pdu: - config["DEFAULT"]["max_pdu"] = args.max_pdu - if args.acse_timeout: - config["DEFAULT"]["acse_timeout"] = args.acse_timeout - if args.dimse_timeout: - config["DEFAULT"]["dimse_timeout"] = args.dimse_timeout - if args.network_timeout: - config["DEFAULT"]["network_timeout"] = args.network_timeout - if args.bind_address: - config["DEFAULT"]["bind_address"] = args.bind_address - if args.database_location: - config["DEFAULT"]["database_location"] = args.database_location - if args.instance_location: - config["DEFAULT"]["instance_location"] = args.instance_location - - # Log configuration settings - _log_config(config, APP_LOGGER) - app_config = config["DEFAULT"] - - dests = {} - for ae_title in config.sections(): - dest = config[ae_title] - # Convert to bytes and validate the AE title - ae_title = set_ae(ae_title, "ae_title", False, False) - dests[ae_title] = (dest["address"], dest.getint("port")) - - # Use default or specified configuration file - current_dir = os.path.abspath(os.path.dirname(__file__)) - instance_dir = os.path.join(current_dir, app_config["instance_location"]) - # db_path = os.path.join(current_dir, app_config["database_location"]) - - # Clean up the database and storage directory - if args.clean: - response = input( - "This will delete all instances from both the storage directory " - "and the database. Are you sure you wish to continue? [yes/no]: " - ) - if response != "yes": - sys.exit() - - # if clean(db_path, instance_dir, APP_LOGGER): - # sys.exit() - # else: - # sys.exit(1) - - # Try to create the instance storage directory - os.makedirs(instance_dir, exist_ok=True) - - ae = AE(app_config["ae_title"]) - ae.maximum_pdu_size = app_config.getint("max_pdu") - ae.acse_timeout = app_config.getfloat("acse_timeout") - ae.dimse_timeout = app_config.getfloat("dimse_timeout") - ae.network_timeout = app_config.getfloat("network_timeout") - - # Add supported presentation contexts - # Verification SCP - ae.add_supported_context(Verification, ALL_TRANSFER_SYNTAXES) - - # # Storage SCP - support all transfer syntaxes - # for cx in AllStoragePresentationContexts: - # ae.add_supported_context( - # cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False - # ) - - # # Query/Retrieve SCP - # ae.add_supported_context(PatientRootQueryRetrieveInformationModelFind) - # ae.add_supported_context(PatientRootQueryRetrieveInformationModelMove) - # ae.add_supported_context(PatientRootQueryRetrieveInformationModelGet) - # ae.add_supported_context(StudyRootQueryRetrieveInformationModelFind) - # ae.add_supported_context(StudyRootQueryRetrieveInformationModelMove) - # ae.add_supported_context(StudyRootQueryRetrieveInformationModelGet) - - # Unified Procedure Step SCP - for cx in UnifiedProcedurePresentationContexts: - ae.add_supported_context( - cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False - ) - - APP_LOGGER.info(f"Configured for instance_dir = {instance_dir}") - # Set our handler bindings - handlers = [ - (evt.EVT_C_ECHO, handle_echo, [args, APP_LOGGER]), - # (evt.EVT_C_FIND, handle_find, [instance_dir, args, APP_LOGGER]), - # (evt.EVT_C_GET, handle_get, [db_path, args, APP_LOGGER]), - # (evt.EVT_C_MOVE, handle_move, [dests, db_path, args, APP_LOGGER]), - # (evt.EVT_C_STORE, handle_store, [instance_dir, db_path, args, APP_LOGGER]), - # (evt.EVT_N_GET, handle_nget, [db_path, args, APP_LOGGER]), - # (evt.EVT_N_ACTION, handle_naction, [db_path, args, APP_LOGGER]), - (evt.EVT_N_EVENT_REPORT, handle_nevent, [nevent_cb, args, APP_LOGGER]), - # (evt.EVT_N_SET, handle_nset, [db_path, args, APP_LOGGER]), - ] - - # Listen for incoming association requests - ae.start_server( - (app_config["bind_address"], app_config.getint("port")), evt_handlers=handlers - ) - - -if __name__ == "__main__": - main() diff --git a/tdwii_plus_examples/neventscp_default.ini b/tdwii_plus_examples/neventscp_default.ini deleted file mode 100644 index 2f819ee..0000000 --- a/tdwii_plus_examples/neventscp_default.ini +++ /dev/null @@ -1,31 +0,0 @@ -# Default configuration file for upsscp.py - -## Application settings -[DEFAULT] - # Our AE Title - ae_title: NEVENT_SCP - # Our listen port - port: 11115 - # Our maximum PDU size; 0 for unlimited - max_pdu: 16382 - # The ACSE, DIMSE and network timeouts (in seconds) - acse_timeout: 30 - dimse_timeout: 30 - network_timeout: 30 - # The address of the network interface to listen on - # If unset, listen on all interfaces - bind_address: - # Directory where SOP Instances received from Storage SCUs will be stored - # This directory contains the QR service's managed SOP Instances - instance_location: nevent_instances - # Location of sqlite3 database for the QR service's managed SOP Instances - database_location: nevent_instances.sqlite - # Log C-FIND, C-GET and C-MOVE Identifier datasets - log_identifier: True - - -## Move Destinations -# The AE title of the move destination, as ASCII -[STORESCP] - address: 127.0.0.1 - port: 11113 diff --git a/tdwii_plus_examples/neventscp_handlers.py b/tdwii_plus_examples/neventscp_handlers.py deleted file mode 100644 index e61fd51..0000000 --- a/tdwii_plus_examples/neventscp_handlers.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Event handlers for neventscp.py""" - -from pydicom import dcmread - - -def procedure_step_state_matches(query, ups): - is_match = True # until it's false? - requested_step_status = get_procedure_step_state_from_ups(query) - ups_step_status = get_procedure_step_state_from_ups(ups) - if requested_step_status is not None and len(requested_step_status) > 0: - if requested_step_status != ups_step_status: - is_match = False - return is_match - - -def machine_name_matches(query, ups): - requested_machine_name = get_machine_name_from_ups(query) - scheduled_machine_name = get_machine_name_from_ups(ups) - if requested_machine_name is not None and len(requested_machine_name) > 0: - if scheduled_machine_name != requested_machine_name: - return False - return True - - -def get_machine_name_from_ups(query): - seq = query.ScheduledStationNameCodeSequence - if seq is not None: - for item_index in range(len(seq)): - machine_name = seq[item_index].CodeValue - return machine_name - - -def get_procedure_step_state_from_ups(query): - step_status = query.ProcedureStepState - return step_status - - -def handle_echo(event, cli_config, logger): - """Handler for evt.EVT_C_ECHO. - - Parameters - ---------- - event : events.Event - The corresponding event. - cli_config : dict - A :class:`dict` containing configuration settings passed via CLI. - logger : logging.Logger - The application's logger. - - Returns - ------- - int - The status of the C-ECHO operation, always ``0x0000`` (Success). - """ - requestor = event.assoc.requestor - timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") - addr, port = requestor.address, requestor.port - logger.info(f"Received C-ECHO request from {addr}:{port} at {timestamp}") - - return 0x0000 - - -def handle_nevent(event, event_response_cb, cli_config, logger): - """Handler for evt.EVT_N_EVENT. - - Parameters - ---------- - event : pynetdicom.events.Event - The N-EVENT request :class:`~pynetdicom.events.Event`. - event_response_cb : function - The function to call when receiving an Event (that is a UPS Event). - cli_config : dict - A :class:`dict` containing configuration settings passed via CLI. - logger : logging.Logger - The application's logger. - - Yields - ------ - int - The number of sub-operations required to complete the request. - int or pydicom.dataset.Dataset, pydicom.dataset.Dataset or None - The C-GET response's *Status* and if the *Status* is pending then - the dataset to be sent, otherwise ``None``. - """ - - nevent_primitive = event.request - r"""Represents a N-EVENT-REPORT primitive. - - +------------------------------------------+---------+----------+ - | Parameter | Req/ind | Rsp/conf | - +==========================================+=========+==========+ - | Message ID | M | \- | - +------------------------------------------+---------+----------+ - | Message ID Being Responded To | \- | M | - +------------------------------------------+---------+----------+ - | Affected SOP Class UID | M | U(=) | - +------------------------------------------+---------+----------+ - | Affected SOP Instance UID | M | U(=) | - +------------------------------------------+---------+----------+ - | Event Type ID | M | C(=) | - +------------------------------------------+---------+----------+ - | Event Information | U | \- | - +------------------------------------------+---------+----------+ - | Event Reply | \- | C | - +------------------------------------------+---------+----------+ - | Status | \- | M | - +------------------------------------------+---------+----------+ - - | (=) - The value of the parameter is equal to the value of the parameter - in the column to the left - | C - The parameter is conditional. - | M - Mandatory - | MF - Mandatory with a fixed value - | U - The use of this parameter is a DIMSE service user option - | UF - User option with a fixed value - - Attributes - ---------- - MessageID : int - Identifies the operation and is used to distinguish this - operation from other notifications or operations that may be in - progress. No two identical values for the Message ID shall be used for - outstanding operations. - MessageIDBeingRespondedTo : int - The Message ID of the operation request/indication to which this - response/confirmation applies. - AffectedSOPClassUID : pydicom.uid.UID, bytes or str - For the request/indication this specifies the SOP Class for - storage. If included in the response/confirmation, it shall be equal - to the value in the request/indication - Status : int - The error or success notification of the operation. - """ - requestor = event.assoc.requestor - timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") - addr, port = requestor.address, requestor.port - logger.info(f"Received N-EVENT request from {addr}:{port} at {timestamp}") - - model = event.request.AffectedSOPClassUID - nevent_type_id = nevent_primitive.EventTypeID - nevent_information = dcmread(nevent_primitive.EventInformation, force=True) - nevent_rsp_primitive = nevent_primitive - nevent_rsp_primitive.Status = 0x0000 - - logger.info(f"Event Information: {nevent_information}") - - if model.keyword in ["UnifiedProcedureStepPush"]: - event_response_cb(type_id=nevent_type_id, information_ds=nevent_information, logger=logger) - else: - logger.warning(f"Received model.keyword = {model.keyword} with AffectedSOPClassUID = {model}") - logger.warning("Not a UPS Event") - - logger.info("Finished Processing N-EVENT-REPORT-RQ") - yield 0 # Number of suboperations remaining - # If a rsp dataset of None is provided below, the underlying handler and dimse primitive in pynetdicom raises an error - yield 0 # Status diff --git a/tdwii_plus_examples/upsdb.py b/tdwii_plus_examples/upsdb.py index 70dd93d..90afc24 100644 --- a/tdwii_plus_examples/upsdb.py +++ b/tdwii_plus_examples/upsdb.py @@ -23,7 +23,7 @@ try: from sqlalchemy import Column, ForeignKey, Integer, String, create_engine except ImportError: - sys.exit("qrscp requires the sqlalchemy package") + sys.exit("upsdb.py requires the sqlalchemy package") from pydicom import dcmread from pydicom.dataset import Dataset From 775ea5f7e38d810b842eb6077df6b4a8040cd98f Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Sat, 6 Apr 2024 19:41:06 +1300 Subject: [PATCH 04/14] changed default port to align with nevent_receiver --- tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py index e820d15..0d3c73c 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py @@ -29,7 +29,7 @@ class PPVS_SCP(NEventReceiver,StoreSCP): def __init__(self, ae_title:str="PPVS_SCP", - port:int=11112, + port:int=11115, logger=None, bind_address:str="", storage_presentation_contexts=AllStoragePresentationContexts, From f943705f916a503b671b6eff1e97acb1a34f54c8 Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Sat, 6 Apr 2024 19:41:46 +1300 Subject: [PATCH 05/14] a set of Application Entities that can be used when everything is local, with the default values for the examples mentioned or provided --- LoopbackApplicationEntities.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 LoopbackApplicationEntities.json diff --git a/LoopbackApplicationEntities.json b/LoopbackApplicationEntities.json new file mode 100644 index 0000000..c2b0534 --- /dev/null +++ b/LoopbackApplicationEntities.json @@ -0,0 +1,32 @@ +[ + { + "AETitle": "OST", + "IPAddr": "127.0.0.1", + "Port": 11112 + }, + { + "AETitle": "QRSCP", + "IPAddr": "127.0.0.1", + "Port": 11112 + }, + { + "AETitle": "TMS", + "IPAddr": "127.0.0.1", + "Port": 11114 + }, + { + "AETitle": "UPSSCP", + "IPAddr": "127.0.0.1", + "Port": 11114 + }, + { + "AETitle": "PPVS_SCP", + "IPAddr": "127.0.0.1", + "Port": 11115 + }, + { + "AETitle": "TDD", + "IPAddr": "127.0.0.1", + "Port": 11113 + } +] From 199cfa119ecc7a8b30cf505f599b74f3edfffb2f Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Mon, 15 Apr 2024 00:27:21 +1200 Subject: [PATCH 06/14] added test of nevent_sender updated import statements to avoid relative addressing (caused failures in imports in certain situations) --- LoopbackApplicationEntities.json | 5 + tdwii_plus_examples/ApplicationEntities.json | 37 ++++ .../TDWII_PPVS_subscriber/echoscp.py | 2 +- .../TDWII_PPVS_subscriber/nevent_receiver.py | 6 +- tdwii_plus_examples/handlers.py | 46 +++-- tdwii_plus_examples/nevent_receiver.py | 4 +- tdwii_plus_examples/nevent_sender.py | 15 +- .../tests/test_nevent_sender.py | 168 ++++++++++++++++++ tdwii_plus_examples/upsscp.py | 2 +- tdwii_plus_examples/watchscu.py | 8 +- 10 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 tdwii_plus_examples/ApplicationEntities.json create mode 100644 tdwii_plus_examples/tests/test_nevent_sender.py diff --git a/LoopbackApplicationEntities.json b/LoopbackApplicationEntities.json index c2b0534..3456f0b 100644 --- a/LoopbackApplicationEntities.json +++ b/LoopbackApplicationEntities.json @@ -24,6 +24,11 @@ "IPAddr": "127.0.0.1", "Port": 11115 }, + { + "AETitle": "NEVENT_RECEIVER", + "IPAddr": "127.0.0.1", + "Port": 11115 + }, { "AETitle": "TDD", "IPAddr": "127.0.0.1", diff --git a/tdwii_plus_examples/ApplicationEntities.json b/tdwii_plus_examples/ApplicationEntities.json new file mode 100644 index 0000000..3456f0b --- /dev/null +++ b/tdwii_plus_examples/ApplicationEntities.json @@ -0,0 +1,37 @@ +[ + { + "AETitle": "OST", + "IPAddr": "127.0.0.1", + "Port": 11112 + }, + { + "AETitle": "QRSCP", + "IPAddr": "127.0.0.1", + "Port": 11112 + }, + { + "AETitle": "TMS", + "IPAddr": "127.0.0.1", + "Port": 11114 + }, + { + "AETitle": "UPSSCP", + "IPAddr": "127.0.0.1", + "Port": 11114 + }, + { + "AETitle": "PPVS_SCP", + "IPAddr": "127.0.0.1", + "Port": 11115 + }, + { + "AETitle": "NEVENT_RECEIVER", + "IPAddr": "127.0.0.1", + "Port": 11115 + }, + { + "AETitle": "TDD", + "IPAddr": "127.0.0.1", + "Port": 11113 + } +] diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py index 5238afa..30fbd00 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py @@ -20,7 +20,7 @@ from pynetdicom.sop_class import Verification from pynetdicom.utils import set_ae -from basescp import BaseSCP +from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP class EchoSCP: def __init__(self, diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py index d4276a4..09e5023 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py @@ -20,8 +20,8 @@ from pynetdicom.sop_class import Verification from pynetdicom.utils import set_ae -from basescp import BaseSCP -from echoscp import EchoSCP +from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.TDWII_PPVS_subscriber.echoscp import EchoSCP def nevent_cb(**kwargs): logger = None @@ -78,7 +78,7 @@ def nevent_cb(**kwargs): class NEventReceiver(EchoSCP): def __init__(self, nevent_callback=None, - ae_title:str="NEVENT_SCP", + ae_title:str="NEVENT_RECEIVER", port:int=11115, logger=None, bind_address:str="" diff --git a/tdwii_plus_examples/handlers.py b/tdwii_plus_examples/handlers.py index 7acac88..107409b 100644 --- a/tdwii_plus_examples/handlers.py +++ b/tdwii_plus_examples/handlers.py @@ -23,6 +23,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from upsdb import Instance, InvalidIdentifier, add_instance, search +import tdwii_config _SERVICE_STATUS = { "SCHEDULED": { @@ -55,6 +56,8 @@ _global_subscribers = dict() # AE Title and delection lock boolean "TRUE" or "FALSE" is the text representation _filtered_subscribers = dict() # AE Title and the Dataset acting as the query filter +REMOTE_AE_CONFIG_FILE = "ApplicationEntities.json" +tdwii_config.load_ae_config(REMOTE_AE_CONFIG_FILE) def _add_global_subscriber(subscriber_ae_title: str, deletion_lock: bool = False, logger=None): if subscriber_ae_title not in _global_subscribers.keys(): @@ -610,7 +613,7 @@ def handle_nset(event, db_path, cli_config, logger): Parameters ---------- event : pynetdicom.events.Event - The C-GET request :class:`~pynetdicom.events.Event`. + The N-SET request :class:`~pynetdicom.events.Event`. db_path : str The database path to use with create_engine(). cli_config : dict @@ -623,13 +626,13 @@ def handle_nset(event, db_path, cli_config, logger): int The number of sub-operations required to complete the request. int or pydicom.dataset.Dataset, pydicom.dataset.Dataset or None - The C-GET response's *Status* and if the *Status* is pending then + The N-SET response's *Status* and if the *Status* is pending then the dataset to be sent, otherwise ``None``. """ requestor = event.assoc.requestor timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") addr, port = requestor.address, requestor.port - logger.info(f"Received C-GET request from {addr}:{port} at {timestamp}") + logger.info(f"Received N-SET request from {addr}:{port} at {timestamp}") model = event.request.AffectedSOPClassUID @@ -642,7 +645,7 @@ def handle_nset(event, db_path, cli_config, logger): matches = search(model, event.identifier, session) except InvalidIdentifier as exc: session.rollback() - logger.error("Invalid C-GET Identifier received") + logger.error("Invalid N-SET Identifier received") logger.error(str(exc)) yield 0xA900, None return @@ -820,19 +823,42 @@ def handle_ncreate(event, storage_dir, db_path, cli_config, logger): for globalsubscriber in _global_subscribers: # Request association with subscriber ae = AE(ae_title=acceptor.ae_title) - # hard code for the moment, deal with configuration of AE's soon + if (not globalsubscriber in tdwii_config.known_ae_ipaddr): + logger.error(f"{globalsubscriber} missing IP Address configuration in {REMOTE_AE_CONFIG_FILE}") + continue + + if (not globalsubscriber in tdwii_config.known_ae_port): + logger.error(f"{globalsubscriber} missing Port configuration in {REMOTE_AE_CONFIG_FILE}") + continue + + subscriber_ip_addr = tdwii_config.known_ae_ipaddr[globalsubscriber] + subscriber_port = tdwii_config.known_ae_port[globalsubscriber] assoc = ae.associate( - "127.0.0.1", - 11112, + subscriber_ip_addr, + subscriber_port, contexts=UnifiedProcedurePresentationContexts, ae_title=globalsubscriber, max_pdu=16382, ) if assoc.is_established: + message_id=0 try: - logger.info(f"Send UPS State Report: {ds.SOPInstanceUID}, {ds.ProcedureStepState}") - assoc.send_n_event_report(event_info, event_type, UnifiedProcedureStepPush, ds.SOPInstanceUID) + if (event_type==1): + logger.info(f"Sending UPS State Report: {ds.SOPInstanceUID}, {ds.ProcedureStepState}") + message_id +=1 + assoc.send_n_event_report(event_info, event_type, UnifiedProcedureStepPush, ds.SOPInstanceUID, message_id) + elif (event_type==5): # The assignment took place at the time of creation + # notify of creation first, i.e. event type == 1 + logger.info(f"Sending UPS State Report: {ds.SOPInstanceUID}, {ds.ProcedureStepState}") + message_id +=1 + assoc.send_n_event_report(event_info, 1, UnifiedProcedureStepPush, ds.SOPInstanceUID, message_id) + # if the assignment happened after the creation (e.g. via N-SET or internal change in a TMS) + # then *only* send an N-EVENT-REPORT regarding the UPS Assignment + logger.info(f"Sending UPS Assignment: {ds.ScheduledStationNameCodeSequence}") + message_id +=1 + assoc.send_n_event_report(event_info, event_type, UnifiedProcedureStepPush, ds.SOPInstanceUID, message_id) + logger.info(f"Notified global subscriber: {globalsubscriber}") except InvalidDicomError: logger.error("Bad DICOM: ") @@ -845,6 +871,6 @@ def handle_ncreate(event, storage_dir, db_path, cli_config, logger): assoc.release() else: - logger.error(f"Failed to establish assocation with subscriber: {globalsubscriber}") + logger.error(f"Failed to establish association with subscriber: {globalsubscriber}") return 0x0000, ds diff --git a/tdwii_plus_examples/nevent_receiver.py b/tdwii_plus_examples/nevent_receiver.py index 07f99fe..0807c22 100755 --- a/tdwii_plus_examples/nevent_receiver.py +++ b/tdwii_plus_examples/nevent_receiver.py @@ -80,7 +80,7 @@ def _setup_argparser(): parser = argparse.ArgumentParser( description=( "The nevent_receiver application implements a Service Class Provider (SCP) " - "for the Verification, Storage and Query/Retrieve (QR) Service " + "for the Verification and Unified Procedure Step Service " "Classes." ), usage="nevent_receiver [options]", @@ -251,7 +251,7 @@ def nevent_cb(**kwargs): ) else: if logger: - logger.warning(f"Unkown Event Type ID: {event_type_id}") + logger.warning(f"Unknown Event Type ID: {event_type_id}") def main(args=None): diff --git a/tdwii_plus_examples/nevent_sender.py b/tdwii_plus_examples/nevent_sender.py index d4f924d..6b8903f 100755 --- a/tdwii_plus_examples/nevent_sender.py +++ b/tdwii_plus_examples/nevent_sender.py @@ -175,25 +175,25 @@ def _setup_argparser(): "-aet", "--calling-aet", metavar="[a]etitle", - help="set my calling AE title (default: WATCH_SCU)", + help="set my calling AE title (default: NEVENT_SENDER)", type=str, - default="WATCH_SCU", + default="NEVENT_SENDER", ) net_opts.add_argument( "-aec", "--called-aet", metavar="[a]etitle", - help="set called AE title of peer (default: WATCH_SCP)", + help="set called AE title of peer (default: NEVENT_RECEIVER)", type=str, - default="WATCH_SCP", + default="NEVENT_RECEIVER", ) net_opts.add_argument( "-aer", "--receiver-aet", metavar="[a]etitle", - help="set receiver AE title of peer (default: EVENT_SCP)", + help="set receiver AE title of peer (default: NEVENT_RECEIVER)", type=str, - default="EVENT_SCP", + default="NEVENT_RECEIVER", ) net_opts.add_argument( "-ta", @@ -356,11 +356,13 @@ def main(args=None): status, response = send_ups_state_report(assoc, UID("1.2.3.4"), "SCHEDULED") APP_LOGGER.info(f"Status: {os.linesep}{status}") APP_LOGGER.info(f"Response: {os.linesep}{response}") + status, response = send_ups_state_report( assoc, UID("1.2.3.4"), "IN PROGRESS" ) APP_LOGGER.info(f"Status: {os.linesep}{status}") APP_LOGGER.info(f"Response: {os.linesep}{response}") + status, response = send_ups_state_report(assoc, UID("1.2.3.4"), "COMPLETED") APP_LOGGER.info(f"Status: {os.linesep}{status}") APP_LOGGER.info(f"Response: {os.linesep}{response}") @@ -374,6 +376,7 @@ def main(args=None): assoc.release() else: + APP_LOGGER.error("Unable to form association with " + args.called_aet) sys.exit(1) diff --git a/tdwii_plus_examples/tests/test_nevent_sender.py b/tdwii_plus_examples/tests/test_nevent_sender.py new file mode 100644 index 0000000..c7e4c0f --- /dev/null +++ b/tdwii_plus_examples/tests/test_nevent_sender.py @@ -0,0 +1,168 @@ +"""Unit tests for nevent_sender.py""" + +from time import sleep +import os +import subprocess +import sys +import time + +import pytest +from pydicom import Dataset, dcmread +from pydicom.uid import ( + DeflatedExplicitVRLittleEndian, + ExplicitVRBigEndian, + ExplicitVRLittleEndian, + ImplicitVRLittleEndian, +) +from pynetdicom import ( + AE, + ALL_TRANSFER_SYNTAXES, + UnifiedProcedurePresentationContexts, + evt, +) +from pynetdicom.sop_class import UnifiedProcedureStepPush, Verification + +# from nevent_receiver_handlers import handle_nevent +from TDWII_PPVS_subscriber.nevent_receiver import NEventReceiver + +# debug_logger() + + +APP_DIR = os.path.join(os.path.dirname(__file__), "../") +APP_FILE = os.path.join(APP_DIR, "./", "nevent_sender.py") +DATA_DIR = os.path.join(APP_DIR, "../", "responses", "dcm") +DATASET_FILE = os.path.join(DATA_DIR, "rsp000001.dcm") +LIB_DIR = os.path.join(APP_DIR, "../") + + +def start_nevent_sender(args): + """Start the nevent_sender.py app and return the process.""" + pargs = [sys.executable, APP_FILE] + [*args] + return subprocess.Popen(pargs) + + +def default_handle_nevent(event): + req = event.request + # attr_list = event.attribute_list + ds = Dataset() + + # Add the SOP Common module elements (Annex C.12.1) + ds.AffectedSOPClassUID = UnifiedProcedureStepPush + ds.AffectedSOPInstanceUID = req.AffectedSOPInstanceUID + + # Update with the requested attributes + # ds.update(attr_list) + ds.is_little_endian = True + ds.is_implicit_VR = True + ds.Status = 0x0000 + return ds, None + + +class neventsenderBase: + """Tests for nevent_sender.py""" + + def setup_method(self): + """Run prior to each test""" + self.ae = None + self.func = None + + def teardown_method(self): + """Clear any active threads""" + if self.ae: + self.ae.shutdown() + + def test_default(self): + """Test default settings.""" + + events = [] + + def handle_nevent_receive(event): + events.append(event) + yield 0 + yield 0 + + def handle_release(event): + events.append(event) + + handlers = [ + (evt.EVT_N_EVENT_REPORT, handle_nevent_receive), + (evt.EVT_RELEASED, handle_release), + ] + + self.ae = ae = AE() + ae.acse_timeout = 5 + ae.dimse_timeout = 5 + ae.network_timeout = 5 + ae.add_supported_context(UnifiedProcedureStepPush) + scp = ae.start_server(("localhost", 11115), block=False, evt_handlers=handlers) + + + p = self.func(["127.0.0.1", "11115"]) + p.wait() + assert p.returncode == 0 + # sleep(1.0) + scp.shutdown() + + + + assert events[0].event == evt.EVT_N_EVENT_REPORT + current_event = events[0] + nevent_primitive = current_event.request + model = current_event.request.AffectedSOPClassUID + nevent_type_id = nevent_primitive.EventTypeID + nevent_information = dcmread(nevent_primitive.EventInformation, force=True) + assert model.keyword in ["UnifiedProcedureStepPush"] + assert nevent_type_id == 1 + assert nevent_information.ProcedureStepState == "SCHEDULED" + + assert events[1].event == evt.EVT_N_EVENT_REPORT + current_event = events[1] + nevent_primitive = current_event.request + model = current_event.request.AffectedSOPClassUID + nevent_type_id = nevent_primitive.EventTypeID + nevent_information = dcmread(nevent_primitive.EventInformation, force=True) + assert model.keyword in ["UnifiedProcedureStepPush"] + assert nevent_type_id == 1 + assert nevent_information.ProcedureStepState == "IN PROGRESS" + + assert events[2].event == evt.EVT_N_EVENT_REPORT + current_event = events[2] + nevent_primitive = current_event.request + model = current_event.request.AffectedSOPClassUID + nevent_type_id = nevent_primitive.EventTypeID + nevent_information = dcmread(nevent_primitive.EventInformation, force=True) + assert model.keyword in ["UnifiedProcedureStepPush"] + assert nevent_type_id == 1 + assert nevent_information.ProcedureStepState == "COMPLETED" + + assert events[3].event == evt.EVT_RELEASED + + # def test_no_peer(self, capfd): + # """Test trying to connect to non-existent host.""" + # p = self.func([DATASET_FILE]) + # p.wait() + # assert p.returncode == 1 + # out, err = capfd.readouterr() + # assert "Association request failed: unable to connect to remote" in err + # assert "TCP Initialisation Error" in err + + + # def test_bad_input(self, capfd): + # """Test being unable to read the input file.""" + # p = self.func(["no-such-file.dcm", "-d"]) + # p.wait() + # assert p.returncode == 0 + + # out, err = capfd.readouterr() + # assert "No suitable DICOM files found" in err + # assert "Cannot access path: no-such-file.dcm" in err + + + +class Testneventsender(neventsenderBase): + """Tests for nevent_sender.py""" + + def setup_method(self): + """Run prior to each test""" + self.ae = None + self.func = start_nevent_sender diff --git a/tdwii_plus_examples/upsscp.py b/tdwii_plus_examples/upsscp.py index 0adae61..ea6f0cd 100755 --- a/tdwii_plus_examples/upsscp.py +++ b/tdwii_plus_examples/upsscp.py @@ -91,7 +91,7 @@ def _setup_argparser(): parser = argparse.ArgumentParser( description=( "The upsscp application implements a Service Class Provider (SCP) " - "for the Verification, Storage and Query/Retrieve (QR) Service " + "for the Verification and Unified Procedure Step (UPS) Service " "Classes." ), usage="upsscp [options]", diff --git a/tdwii_plus_examples/watchscu.py b/tdwii_plus_examples/watchscu.py index e9836d1..e349521 100755 --- a/tdwii_plus_examples/watchscu.py +++ b/tdwii_plus_examples/watchscu.py @@ -162,17 +162,17 @@ def _setup_argparser(): "-aec", "--called-aet", metavar="[a]etitle", - help="set called AE title of peer (default: WATCH_SCP)", + help="set called AE title of peer (default: UPSSCP)", type=str, - default="WATCH_SCP", + default="UPSSCP", ) net_opts.add_argument( "-aer", "--receiver-aet", metavar="[a]etitle", - help="set receiver AE title of peer (default: EVENT_SCP)", + help="set receiver AE title of peer (default: NEVENT_RECEIVER)", type=str, - default="EVENT_SCP", + default="NEVENT_RECEIVER" ) net_opts.add_argument( "-ta", From 32c976b31dd8329e780fb5f29c3aff732fe43335 Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Fri, 26 Apr 2024 15:50:29 +1200 Subject: [PATCH 07/14] change titles on groupboxes in PPVS GUI make GUI applications executable (so the user doesn't need to start by invoking python) Add to README for RTBDI creator and describe use of CLI. --- .../ppvs_subscriber_widget.py | 1 + .../tdwii_ppvs_subscriber.ui | 8 ++++---- tdwii_plus_examples/rtbdi_creator/README.md | 16 ++++++++++++++++ .../rtbdi_creator/mainbdiwidget.py | 1 + .../rtbdi_creator/rtbdi_creator.pyproject.user | 2 +- 5 files changed, 23 insertions(+), 5 deletions(-) mode change 100644 => 100755 tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py mode change 100644 => 100755 tdwii_plus_examples/rtbdi_creator/mainbdiwidget.py diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py old mode 100644 new mode 100755 index 859c470..e7a316f --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # This Python file uses the following encoding: utf-8 import sys from pathlib import Path diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/tdwii_ppvs_subscriber.ui b/tdwii_plus_examples/TDWII_PPVS_subscriber/tdwii_ppvs_subscriber.ui index e36cbc6..d20da9e 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/tdwii_ppvs_subscriber.ui +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/tdwii_ppvs_subscriber.ui @@ -23,7 +23,7 @@ - group_box_aes_and_machine_name + Remote System Configuration @@ -68,7 +68,7 @@ - ppvs_scp_group_box + PPVS Configuration @@ -131,7 +131,7 @@ - group_box_cfind_request_and_response + UPS Query Specification @@ -210,7 +210,7 @@ - group_box_get_reference_data + Reference Data Retrieval diff --git a/tdwii_plus_examples/rtbdi_creator/README.md b/tdwii_plus_examples/rtbdi_creator/README.md index 90094bf..2b0d42b 100644 --- a/tdwii_plus_examples/rtbdi_creator/README.md +++ b/tdwii_plus_examples/rtbdi_creator/README.md @@ -3,3 +3,19 @@ RT Beams Delivery Instruction and UPS Creator PySide6 GUI and/or command line driven tool to automate construction of fraction/session specific files for use in the IHE-RO TDW-II profile. +Command Line Interface: + +generate_course_sessions_for_plan.py plan_filename cmove_AE_TITLE + +will generate an entire course worth of RT Beams Delivery Instructions and Unified Procedure Steps, based on the number of fractions specified in the plan, with the start datetime being "now", with one fraction scheduled per day. +Example: +You are using OpenTPS to generate RT Ion Plans, are saving your plans in a subdirectory of your home directory in OpenTPS/Plans and saved a plan in a file named my_plan.dcm and you are using the pynetdicom sample application qrscp as the Object Store: + +generate_course_sessions_for_plan.py ~/OpenTPS/Plans/my_plan.dcm QRSCP + + +GUI: + +mainbdiwidget.py + +After generating the RT BDI and UPS, you will want to C-STORE the RT BDI object(s) to QRSCP (e.g. using the pynetdicom sample application storescu) and then use ncreatescu.py to have the UPS(s) created in the UPSSCP diff --git a/tdwii_plus_examples/rtbdi_creator/mainbdiwidget.py b/tdwii_plus_examples/rtbdi_creator/mainbdiwidget.py old mode 100644 new mode 100755 index 091d273..ef92d9f --- a/tdwii_plus_examples/rtbdi_creator/mainbdiwidget.py +++ b/tdwii_plus_examples/rtbdi_creator/mainbdiwidget.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # This Python file uses the following encoding: utf-8 from datetime import datetime from pathlib import Path diff --git a/tdwii_plus_examples/rtbdi_creator/rtbdi_creator.pyproject.user b/tdwii_plus_examples/rtbdi_creator/rtbdi_creator.pyproject.user index eb50f15..72e4daa 100644 --- a/tdwii_plus_examples/rtbdi_creator/rtbdi_creator.pyproject.user +++ b/tdwii_plus_examples/rtbdi_creator/rtbdi_creator.pyproject.user @@ -1,6 +1,6 @@ - + EnvironmentId From ccafeed13317bb9c34ad69c331f9e21b2a2bf8f2 Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Fri, 26 Apr 2024 16:03:53 +1200 Subject: [PATCH 08/14] add init.py in subdirectories --- tdwii_plus_examples/TDWII_PPVS_subscriber/init.py | 0 tdwii_plus_examples/rtbdi_creator/init.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tdwii_plus_examples/TDWII_PPVS_subscriber/init.py create mode 100644 tdwii_plus_examples/rtbdi_creator/init.py diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/init.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/init.py new file mode 100644 index 0000000..e69de29 diff --git a/tdwii_plus_examples/rtbdi_creator/init.py b/tdwii_plus_examples/rtbdi_creator/init.py new file mode 100644 index 0000000..e69de29 From 4b5f59ae0f3a51f114fd9fef358f319f691e5f22 Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Fri, 26 Apr 2024 16:08:10 +1200 Subject: [PATCH 09/14] renamed init files --- .../TDWII_PPVS_subscriber/{init.py => __init__.py} | 0 tdwii_plus_examples/rtbdi_creator/{init.py => __init__.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tdwii_plus_examples/TDWII_PPVS_subscriber/{init.py => __init__.py} (100%) rename tdwii_plus_examples/rtbdi_creator/{init.py => __init__.py} (100%) diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/init.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/__init__.py similarity index 100% rename from tdwii_plus_examples/TDWII_PPVS_subscriber/init.py rename to tdwii_plus_examples/TDWII_PPVS_subscriber/__init__.py diff --git a/tdwii_plus_examples/rtbdi_creator/init.py b/tdwii_plus_examples/rtbdi_creator/__init__.py similarity index 100% rename from tdwii_plus_examples/rtbdi_creator/init.py rename to tdwii_plus_examples/rtbdi_creator/__init__.py From 303793888f751a78e347014a3d5f3f48a300b60e Mon Sep 17 00:00:00 2001 From: Stuart Swerdloff Date: Fri, 26 Apr 2024 16:18:27 +1200 Subject: [PATCH 10/14] update reference to tdwii related submodules --- tdwii_plus_examples/tests/test_nevent_sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdwii_plus_examples/tests/test_nevent_sender.py b/tdwii_plus_examples/tests/test_nevent_sender.py index c7e4c0f..ff11d5a 100644 --- a/tdwii_plus_examples/tests/test_nevent_sender.py +++ b/tdwii_plus_examples/tests/test_nevent_sender.py @@ -23,7 +23,7 @@ from pynetdicom.sop_class import UnifiedProcedureStepPush, Verification # from nevent_receiver_handlers import handle_nevent -from TDWII_PPVS_subscriber.nevent_receiver import NEventReceiver +from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver import NEventReceiver # debug_logger() From 56c5789e3c5766fe41816c2c1cb87c0c46d9acff Mon Sep 17 00:00:00 2001 From: sjswerdloff Date: Sat, 27 Apr 2024 05:59:12 +1200 Subject: [PATCH 11/14] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e507ee..4050710 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ To generate .dcm files needed by ups enabled findscu dump2dcm queryfile.dcmdump.txt queryfile.dcm ``` -## A sample C-FIND SCU is available from pynetdicom (it has been enhanced with support for UPS): clone from https://github.com/pynetdicom/pynetdicom.git +## A sample C-FIND SCU is available from pynetdicom (it has been enhanced with support for UPS): clone from https://github.com/pydicom/pynetdicom/pynetdicom.git + then use findscu to query the TMS (Treatment Management System): in pynetdicom/apps From e75faf960dc524872f7839ecc950c09a7ac12f0d Mon Sep 17 00:00:00 2001 From: sjswerdloff Date: Sat, 27 Apr 2024 06:00:38 +1200 Subject: [PATCH 12/14] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 4050710..8a8ba23 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,7 @@ To generate .dcm files needed by ups enabled findscu dump2dcm queryfile.dcmdump.txt queryfile.dcm ``` -## A sample C-FIND SCU is available from pynetdicom (it has been enhanced with support for UPS): clone from https://github.com/pydicom/pynetdicom/pynetdicom.git - +## A sample C-FIND SCU is available from pynetdicom (it has been enhanced with support for UPS): clone from https://github.com/pydicom/pynetdicom then use findscu to query the TMS (Treatment Management System): in pynetdicom/apps From ea6271fd244cbcb3882139a050be81d422f46d98 Mon Sep 17 00:00:00 2001 From: sjswerdloff Date: Sun, 28 Apr 2024 00:54:32 +1200 Subject: [PATCH 13/14] Update upsscp.py Delete vestigial commented out import statements --- tdwii_plus_examples/upsscp.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tdwii_plus_examples/upsscp.py b/tdwii_plus_examples/upsscp.py index ea6f0cd..b956c4b 100755 --- a/tdwii_plus_examples/upsscp.py +++ b/tdwii_plus_examples/upsscp.py @@ -8,7 +8,6 @@ import pydicom.config -# from neventscp_handlers import handle_nevent import upsdb from handlers import ( handle_echo, @@ -30,7 +29,6 @@ from pynetdicom.sop_class import Verification from pynetdicom.utils import set_ae -# from pynetdicom.apps.upsscp import db # Use `None` for empty values pydicom.config.use_none_as_empty_text_VR_value = True From 8da0e7053ce2c810e4c5f37cd3fbeaf1c20986a0 Mon Sep 17 00:00:00 2001 From: sjswerdloff Date: Sun, 28 Apr 2024 01:02:44 +1200 Subject: [PATCH 14/14] Update nevent_receiver_default.ini Correct the name of the module that uses this ini file --- tdwii_plus_examples/nevent_receiver_default.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdwii_plus_examples/nevent_receiver_default.ini b/tdwii_plus_examples/nevent_receiver_default.ini index 451d852..02db302 100644 --- a/tdwii_plus_examples/nevent_receiver_default.ini +++ b/tdwii_plus_examples/nevent_receiver_default.ini @@ -1,4 +1,4 @@ -# Default configuration file for upsscp.py +# Default configuration file for nevent_receiver.py ## Application settings [DEFAULT]