diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py deleted file mode 100644 index 1e83626..0000000 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py +++ /dev/null @@ -1,33 +0,0 @@ -from argparse import Namespace - -from pynetdicom import AE -from pynetdicom.apps.common import setup_logging - - -class BaseSCP: - def __init__(self, ae_title: str = "BASE_SCP", port: int = 11112, logger=None, bind_address: str = ""): - self.ae_title = ae_title - self.port = port - if logger is None: - logger_args = Namespace(log_type="d", log_level="debug") - self.logger = setup_logging(logger_args, "base_scp") - else: - self.logger = logger - self.bind_address = bind_address - self.threaded_server = None - self.ae = AE(self.ae_title) - self.handlers = [] - self._add_contexts() - self._add_handlers() - - def _add_contexts(self): - # self.ae.add_supported_context(Verification, ALL_TRANSFER_SYNTAXES) - pass # base class, do nothing, pure virtual - - def _add_handlers(self): - # self.handlers.append((evt.EVT_C_ECHO, handle_echo, [None, self.logger])) - pass # base class, do nothing, pure virtual - - def run(self): - # Listen for incoming association requests - self.threaded_server = self.ae.start_server((self.bind_address, self.port), evt_handlers=self.handlers, block=False) diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py deleted file mode 100644 index 5c7cd05..0000000 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py +++ /dev/null @@ -1,33 +0,0 @@ -from time import sleep - -from pynetdicom import ALL_TRANSFER_SYNTAXES, evt -from pynetdicom.sop_class import Verification - -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP -from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import ( - handle_echo, -) - - -class EchoSCP: - def __init__(self, ae_title: str = "ECHO_SCP", port: int = 11112, logger=None, bind_address: str = ""): - BaseSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) - - def _add_contexts(self): - BaseSCP._add_contexts(self) - self.ae.add_supported_context(Verification, ALL_TRANSFER_SYNTAXES) - - def _add_handlers(self): - BaseSCP._add_handlers(self) - self.handlers.append((evt.EVT_C_ECHO, handle_echo, [None, self.logger])) - - def run(self): - # Listen for incoming association requests - BaseSCP.run(self) - - -if __name__ == "__main__": - myecho_scp = EchoSCP() - myecho_scp.run() - while True: - sleep(100) # sleep forever diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py index 8b267c9..9e651c5 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py @@ -2,8 +2,8 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, UnifiedProcedurePresentationContexts, evt -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP -from tdwii_plus_examples.TDWII_PPVS_subscriber.echoscp import EchoSCP +from tdwii_plus_examples.basescp import BaseSCP +from tdwii_plus_examples.cechoscp import CEchoSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import ( handle_nevent, ) @@ -58,7 +58,7 @@ def nevent_cb(**kwargs): logger.warning(f"Unknown Event Type ID: {event_type_id}") -class NEventReceiver(EchoSCP): +class NEventReceiver(CEchoSCP): def __init__( self, nevent_callback=None, ae_title: str = "NEVENT_RECEIVER", port: int = 11115, logger=None, bind_address: str = "" ): @@ -66,15 +66,15 @@ def __init__( 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) + CEchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) def _add_contexts(self): - EchoSCP._add_contexts(self) + CEchoSCP._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) + CEchoSCP._add_handlers(self) self.handlers.append((evt.EVT_N_EVENT_REPORT, handle_nevent, [self.nevent_callback, None, self.logger])) def run(self): diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py index 1668211..c761741 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py @@ -4,7 +4,7 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, AllStoragePresentationContexts from tdwii_plus_examples import tdwii_config -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.basescp import BaseSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver import NEventReceiver from tdwii_plus_examples.TDWII_PPVS_subscriber.storescp import StoreSCP diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py index 0fb3fcc..49e3b74 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py @@ -4,12 +4,12 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, AllStoragePresentationContexts, evt -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.basescp import BaseSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.cstore_handler import handle_store -from tdwii_plus_examples.TDWII_PPVS_subscriber.echoscp import EchoSCP +from tdwii_plus_examples.cechoscp import CEchoSCP -class StoreSCP(EchoSCP): +class StoreSCP(CEchoSCP): def __init__( self, ae_title: str = "STORE_SCP", @@ -28,15 +28,15 @@ def __init__( else: self.handle_cstore = custom_handler self.store_directory = store_directory - EchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) + CEchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) def _add_contexts(self): - EchoSCP._add_contexts(self) + CEchoSCP._add_contexts(self) for context in self.storage_presentation_contexts: self.ae.add_supported_context(context.abstract_syntax, self.transfer_syntaxes) def _add_handlers(self): - EchoSCP._add_handlers(self) + CEchoSCP._add_handlers(self) args = Namespace(ignore=False, output_directory=self.store_directory) self.handlers.append((evt.EVT_C_STORE, self.handle_cstore, [args, self.logger])) diff --git a/tdwii_plus_examples/basehandlers.py b/tdwii_plus_examples/basehandlers.py new file mode 100644 index 0000000..9112679 --- /dev/null +++ b/tdwii_plus_examples/basehandlers.py @@ -0,0 +1,44 @@ +def handle_open(event, logger): + """Handler for evt.EVT_CONN_OPEN + + Parameters + ---------- + event : events.Event + The corresponding event. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of possible processing of opened connection parameters, + always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + addr, port = requestor.address, requestor.port + logger.info(f"Succesful connection from {addr}:{port}") + + return 0x0000 + + +def handle_close(event, logger): + """Handler for evt.EVT_CONN_CLOSE + + Parameters + ---------- + event : events.Event + The corresponding event. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of possible processing of closed connection parameters, + always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + aet, addr, port = requestor.ae_title, requestor.address, requestor.port + logger.info(f"Closed connection with {aet}@{addr}:{port}") + + return 0x0000 diff --git a/tdwii_plus_examples/basescp.py b/tdwii_plus_examples/basescp.py new file mode 100644 index 0000000..a2ee739 --- /dev/null +++ b/tdwii_plus_examples/basescp.py @@ -0,0 +1,143 @@ +from argparse import Namespace +import logging + +from pynetdicom import AE, evt +from pynetdicom.apps.common import setup_logging + +from tdwii_plus_examples.basehandlers import handle_open, handle_close + + +class BaseSCP: + """ + The BaseSCP class is a base class for DICOM Service Class Providers (SCPs). + + This class provides basic functionality for creating and managing + a DICOM Service Class Provider (SCP) including setting up the + Application Entity (AE) and handling incoming associations. + + Usage: + To use the BaseSCP class, create a subclass and override + the [_add_contexts] and [_add_handlers] methods + to add presentation contexts and handlers for specific DICOM services. + + Attributes: + ae_title (str): The title of the Application Entity (AE) + bind_address (str): The IP address or hostname of the AE + port (int): The port number the AE will listen on + logger(logging.Logger): A logger instance + + Methods: + _add_contexts(self) + Adds presentation contexts to the SCP instance. + _add_handlers(self) + Adds handlers for DICOM communication events to the SCP instance. + run(self) + Starts the SCP AE thread. + stop(self) + Stops the SCP AE. + """ + def __init__(self, + ae_title: str = "BASE_SCP", + bind_address: str = "", + port: int = 11112, + logger=None): + + """ + Initializes a new instance of the BaseSCP class. + This method creates an AE without presentation contexts. + + Parameters + ---------- + ae_title : str + The title of the Application Entity (AE) + Optional, default: "BASE_SCP" + + bind_address : str + A specific IP address or hostname of the AE + Optional, default: "" will bind to all interfaces. + + port: int + The port number to listen on + Optional, default: 11112 (as registered for DICOM at IANA) + + logger: logging.Logger + A logger instance + Optional, default: None, a debug logger will be used. + """ + if logger is None: + self.logger = setup_logging( + Namespace(log_type="d", log_level="debug"), "base_scp") + elif not isinstance(logger, logging.Logger): + raise TypeError("logger must be an instance of logging.Logger") + else: + self.logger = logger + + if not isinstance(bind_address, str): + raise ValueError("bind_address must be a string") + if not bind_address: + self.logger.debug("bind_address empty, binding to all interfaces") + self.bind_address = bind_address + + if not isinstance(port, int): + raise TypeError("port must be an integer") + self.port = port + + if not isinstance(ae_title, str): + raise TypeError("ae_title must be a string") + self.ae_title = ae_title + self.ae = AE(self.ae_title) + + self._add_contexts() + + self.handlers = [] + self._add_handlers() + + self.threaded_server = None + + def _add_contexts(self): + """ + Adds DICOM presentation contexts to the SCP class. + + This method is intended to be overridden in derived classes. + """ + pass # base class, do nothing, pure virtual + + def _add_handlers(self): + """ + Adds handlers for processing TCP events from incoming associations. + + This method is intended to be overridden in derived classes. + """ + # To define actions when a TCP connection in opened or closed + self.handlers.append((evt.EVT_CONN_OPEN, handle_open, + [self.logger])) + self.handlers.append((evt.EVT_CONN_CLOSE, handle_close, + [self.logger])) + + def run(self): + """ + Start the SCP server and listen for incoming association requests + + This method starts an SCP server and begins listening for incoming + association requests. The server is run in a separate thread and will + not block the calling thread. + """ + # Listen for incoming association requests + try: + self.threaded_server = self.ae.start_server( + (self.bind_address, self.port), + evt_handlers=self.handlers, + block=False) + if self.threaded_server is not None: + self.logger.info("SCP server started successfully") + except Exception as e: + self.logger.error("SCP server failed to start: %s", e) + + def stop(self): + """ + Stop the SCP server. + + This method shuts down the SCP server by terminating any active + association servers and thread of the AE. + """ + self.ae.shutdown() diff --git a/tdwii_plus_examples/cechohandler.py b/tdwii_plus_examples/cechohandler.py new file mode 100644 index 0000000..8c2505c --- /dev/null +++ b/tdwii_plus_examples/cechohandler.py @@ -0,0 +1,22 @@ +def handle_cecho(event, logger): + """Handler for evt.EVT_C_ECHO. + Parameters + ---------- + event : events.Event + The corresponding event. + 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") + aet, addr, port = requestor.ae_title, requestor.address, requestor.port + logger.info( + f"Received C-ECHO request from {aet}@{addr}:{port} at {timestamp}" + ) + + return 0x0000 diff --git a/tdwii_plus_examples/cechoscp.py b/tdwii_plus_examples/cechoscp.py new file mode 100644 index 0000000..641ba4f --- /dev/null +++ b/tdwii_plus_examples/cechoscp.py @@ -0,0 +1,92 @@ +from pynetdicom import evt +from pynetdicom.sop_class import Verification + +from tdwii_plus_examples.basescp import BaseSCP +from tdwii_plus_examples.cechohandler import handle_cecho + + +class CEchoSCP(BaseSCP): + """ + A subclass of the BaseSCP class that implements the DICOM Verification + Service Class Provider (SCP). + + Usage: + Create an instance of CEchoSCP and call the run method inherited + from the BaseSCP parent class to start listening for incoming + DICOM association requests. + The CEchoSCP class is generally not used directly. Instead, create + a subclass and override the [_add_contexts] and [_add_handlers] methods + to add presentation contexts and handlers for specific DICOM services. + + Attributes: + ae_title (str): The title of the Application Entity (AE) + bind_address (str): The IP address or hostname of the AE + port (int): The port number the AE will listen on + logger (logging.Logger): A logger instance + + Methods: + _add_contexts(self) + Adds presentation contexts to the SCP instance. + _add_handlers(self) + Adds handlers for DICOM communication events to the SCP instance. + """ + def __init__(self, + ae_title: str = "ECHO_SCP", + bind_address: str = "", + port: int = 11112, + logger=None): + + """ + Initializes a new instance of the CEchoSCP class. + This method creates an AE without presentation contexts. + + Parameters + ---------- + ae_title : str + The title of the Application Entity (AE) + Optional, default: "ECHO_SCP" + + bind_address : str + A specific IP address or hostname of the AE + Optional, default: "" will bind to all interfaces. + + port: int + The port number to listen on + Optional, default: 11112 (as registered for DICOM at IANA) + + logger: logging.Logger + A logger instance + Optional, default: None, a debug logger will be used. + """ + super().__init__( + ae_title=ae_title, + bind_address=bind_address, + port=port, + logger=logger) + + def _add_contexts(self): + """ + Adds the DICOM Verification SOP Class presentation context to the AE. + Only the default Implicit VR Little Endian transfer syntax is included. + + This method overrides the base class method to add support for the + Verification SOP Class which is required for any DICOM SCP. + + This method is intended to be overridden in derived classes. + """ + super()._add_contexts() + self.ae.add_supported_context(Verification, "1.2.840.10008.1.2") + + def _add_handlers(self): + """ + Adds a handler for for processing incoming C-ECHO requests. + + This method overrides the base class method to add a handler + for the Verification SOP Class, allowing the AE to respond + to C-ECHO requests. + + This method is intended to be overridden in derived classes. + """ + super()._add_handlers() + self.handlers.append((evt.EVT_C_ECHO, handle_cecho, + [self.logger])) diff --git a/tdwii_plus_examples/cli/echoscp.py b/tdwii_plus_examples/cli/echoscp.py new file mode 100755 index 0000000..15976c4 --- /dev/null +++ b/tdwii_plus_examples/cli/echoscp.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +import argparse +import logging + +from tdwii_plus_examples.cechoscp import CEchoSCP + + +def main(): + parser = argparse.ArgumentParser( + description="Run a DICOM Verification SCP." + ) + parser.add_argument( + '-a', '--ae_title', type=str, default='ECHO_SCP', + help='Application Entity Title' + ) + parser.add_argument( + '-b', '--bind_address', type=str, default='', + help='Specific IP address or hostname, omit to bind to all interfaces' + ) + parser.add_argument( + '-p', '--port', type=int, default=11112, + help='Port number' + ) + parser.add_argument( + '-v', '--verbose', action='store_true', + help='Set log level to INFO' + ) + parser.add_argument( + '-d', '--debug', action='store_true', + help='Set log level to DEBUG' + ) + + args = parser.parse_args() + + log_level = logging.WARNING + if args.verbose: + log_level = logging.INFO + elif args.debug: + log_level = logging.DEBUG + + logging.basicConfig(level=log_level) + logger = logging.getLogger('echoscp') + + logger.info("Starting up the DICOM Verification SCP...") + cechoscp = CEchoSCP(ae_title=args.ae_title, bind_address=args.bind_address, + port=args.port, logger=logger) + cechoscp.run() + # Keep the main application running + try: + while True: + pass # You can replace this with your main application logic + except KeyboardInterrupt: + logger.info("Shutting down the DICOM Verification SCP...") + + +if __name__ == "__main__": + main() diff --git a/tdwii_plus_examples/tests/test_basescp.py b/tdwii_plus_examples/tests/test_basescp.py new file mode 100644 index 0000000..6cde278 --- /dev/null +++ b/tdwii_plus_examples/tests/test_basescp.py @@ -0,0 +1,91 @@ +import unittest +import logging +from logging.handlers import MemoryHandler +import subprocess +import time +import re +from tdwii_plus_examples.basescp import BaseSCP +from pynetdicom.sop_class import Verification + + +class CEchoSCP(BaseSCP): + def __init__(self, bind_address, logger=None): + super().__init__(bind_address=bind_address, logger=logger) + + def _add_contexts(self): + super()._add_contexts() + self.ae.add_supported_context(Verification, "1.2.840.10008.1.2") + + +class TestBaseSCP(unittest.TestCase): + + def setUp(self): + # Set up the logger for the BaseSCP to INFO level + # with a memory handler to store up to 100 log messages + self.scp_logger = logging.getLogger('basescp') + self.scp_logger.setLevel(logging.INFO) + self.memory_handler = MemoryHandler(100) + self.scp_logger.addHandler(self.memory_handler) + + # Set up the logger for this test to DEBUG level + # with a stream handler to print the log messages to the console + self.test_logger = logging.getLogger('test_basescp') + self.test_logger.setLevel(logging.DEBUG) + self.stream_handler = logging.StreamHandler() + self.test_logger.addHandler(self.stream_handler) + + # Create a subclass of BaseSCP with only 1 presentation context: + # the required Verification presentation context with the + # default DICOM transfer syntax (Implicit VR Little Endian) + self.scp = CEchoSCP(bind_address="localhost", logger=self.scp_logger) + + def test_run_and_check_log(self): + # Run the SCP + self.scp.run() + + # Send an echo request using pynetdicom's echoscu.py + subprocess.check_call(['python', '-m', 'pynetdicom', 'echoscu', + 'localhost', '11112', + '-aet', 'ECHOSCU', '-aec', 'BASE_SCP']) + + # Wait for 1 second to ensure the logs are generated + time.sleep(1) + + # Get the log messages + log_messages = [record.getMessage() for record + in self.memory_handler.buffer] + + # Check the EVT_CONN_OPEN event log message + self.test_logger.info("Searching for EVT_CONN_OPEN event log message") + pattern = r"^Succesful connection from " + \ + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})" + matching_message = None + for message in log_messages: + if re.search(pattern, message): + matching_message = message + break + self.test_logger.info( + f"EVT_CONN_OPEN event log message found: {matching_message}") + self.assertIsNotNone(matching_message, + "EVT_CONN_OPEN event log message not found") + # Stop the SCP + self.scp.stop() + + # Check the EVT_CONN_CLOSE event log message + self.test_logger.info("Searching for EVT_CONN_CLOSE event log message") + pattern = r"^Closed connection with " + \ + r"([\w]+)@" + \ + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})" + matching_message = None + for message in log_messages: + if re.search(pattern, message): + matching_message = message + break + self.test_logger.info( + f"EVT_CONN_CLOSE event log message found: {matching_message}") + self.assertIsNotNone(matching_message, + "EVT_CONN_CLOSE event log message not found") + + +if __name__ == "__main__": + unittest.main() diff --git a/tdwii_plus_examples/tests/test_cechoscp.py b/tdwii_plus_examples/tests/test_cechoscp.py new file mode 100644 index 0000000..15957aa --- /dev/null +++ b/tdwii_plus_examples/tests/test_cechoscp.py @@ -0,0 +1,65 @@ +import unittest +import logging +from logging.handlers import MemoryHandler +import subprocess +import time +import re +from tdwii_plus_examples.cechoscp import CEchoSCP + + +class TestCEchoSCP(unittest.TestCase): + + def setUp(self): + # Set up the logger for the BaseSCP to INFO level + # with a memory handler to store up to 100 log messages + self.scp_logger = logging.getLogger('cechoscp') + self.scp_logger.setLevel(logging.INFO) + self.memory_handler = MemoryHandler(100) + self.scp_logger.addHandler(self.memory_handler) + + # Set up the logger for this test to DEBUG level + # with a stream handler to print the log messages to the console + self.test_logger = logging.getLogger('test_cechoscp') + self.test_logger.setLevel(logging.DEBUG) + self.stream_handler = logging.StreamHandler() + self.test_logger.addHandler(self.stream_handler) + + # Create a CEchoSCP instance + self.scp = CEchoSCP(bind_address="localhost", logger=self.scp_logger) + + def test_run_and_check_log(self): + # Run the SCP + self.scp.run() + + # Send an echo request using pynetdicom's echoscu.py + subprocess.check_call(['python', '-m', 'pynetdicom', 'echoscu', + 'localhost', '11112', + '-aet', 'ECHOSCU', '-aec', 'ECHO_SCP']) + + # Wait for 1 second to ensure the logs are generated + time.sleep(1) + + # Get the log messages + log_messages = [record.getMessage() for record + in self.memory_handler.buffer] + + # Search for the EVT_C_ECHO event log message + self.test_logger.info("Searching for EVT_C_ECHO event log message") + pattern = r"^Received C-ECHO request from ECHOSCU@" + \ + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})" + matching_message = None + for message in log_messages: + if re.search(pattern, message): + matching_message = message + break + self.test_logger.info( + f"EVT_C_ECHO event log message found: {matching_message}") + self.assertIsNotNone(matching_message, + "EVT_C_ECHO event log message not found") + + # Stop the SCP + self.scp.stop() + + +if __name__ == "__main__": + unittest.main()