diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index f5295b2..86cbe5a 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -2,7 +2,18 @@ from __future__ import unicode_literals class AgentSettings(object): - """Agent port configuration.""" + """ + Agent port configuration. + + :ivar actuating_receiver_port: Port for receiving actuation commands. + :vartype actuating_receiver_port: int + :ivar main_receiver_port: Port for receiving main messages. + :vartype main_receiver_port: int + :ivar video_sender_port: Port used for sending video frames. + :vartype video_sender_port: int + :ivar audio_sender_port: Port used for sending audio data. + :vartype audio_sender_port: int + """ def __init__( self, actuating_receiver_port=5557, @@ -17,7 +28,22 @@ class AgentSettings(object): class VideoConfig(object): - """Video configuration constants.""" + """ + Video configuration constants. + + :ivar camera_index: Index of the camera used. + :vartype camera_index: int + :ivar resolution: Video resolution mode. + :vartype resolution: int + :ivar color_space: Color space identifier. + :vartype color_space: int + :ivar fps: Frames per second of the video stream. + :vartype fps: int + :ivar stream_name: Name of the video stream. + :vartype stream_name: str + :ivar image_buffer: Internal buffer size for video frames. + :vartype image_buffer: int + """ def __init__( self, camera_index=0, @@ -36,7 +62,16 @@ class VideoConfig(object): class AudioConfig(object): - """Audio configuration constants.""" + """ + Audio configuration constants. + + :ivar sample_rate: Audio sampling rate in Hz. + :vartype sample_rate: int + :ivar chunk_size: Size of audio chunks to capture/process. + :vartype chunk_size: int + :ivar channels: Number of audio channels. + :vartype channels: int + """ def __init__(self, sample_rate=16000, chunk_size=512, channels=1): self.sample_rate = sample_rate self.chunk_size = chunk_size @@ -44,14 +79,32 @@ class AudioConfig(object): class MainConfig(object): - """Main configuration""" + """ + Main system configuration. + + :ivar poll_timeout_ms: Timeout for polling events, in milliseconds. + :vartype poll_timeout_ms: int + :ivar max_handler_time_ms: Maximum allowed handler time, in milliseconds. + :vartype max_handler_time_ms: int + """ def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): self.poll_timeout_ms = poll_timeout_ms self.max_handler_time_ms = max_handler_time_ms class Settings(object): - """Global settings container.""" + """ + Global settings container. + + :ivar agent_settings: Agent-related port configuration. + :vartype agent_settings: AgentSettings + :ivar video_config: Video stream configuration. + :vartype video_config: VideoConfig + :ivar audio_config: Audio stream configuration. + :vartype audio_config: AudioConfig + :ivar main_config: Main system-level configuration. + :vartype main_config: MainConfig + """ def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None): self.agent_settings = agent_settings or AgentSettings() self.video_config = video_config or VideoConfig() diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index c5e8ab3..927efbd 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -10,22 +10,31 @@ from robot_interface.core.config import settings class ActuationReceiver(ReceiverBase): + """ + The actuation receiver endpoint, responsible for handling speech and gesture requests. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use. + :type port: int + + :ivar _tts_service: The text-to-speech service object from the Qi session. + :vartype _tts_service: qi.Session | None + """ def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port): - """ - The actuation receiver endpoint, responsible for handling speech and gesture requests. - - :param zmq_context: The ZeroMQ context to use. - :type zmq_context: zmq.Context - - :param port: The port to use. - :type port: int - """ super(ActuationReceiver, self).__init__("actuation") self.create_socket(zmq_context, zmq.SUB, port) self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options self._tts_service = None def _handle_speech(self, message): + """ + Handle a speech actuation request. + + :param message: The message to handle, must contain properties "endpoint" and "data". + :type message: dict + """ text = message.get("data") if not text: logging.warn("Received message to speak, but it lacks data.") @@ -48,5 +57,11 @@ class ActuationReceiver(ReceiverBase): qi.async(self._tts_service.say, text) def handle_message(self, message): + """ + Handle an actuation/speech message with the receiver. + + :param message: The message to handle, must contain properties "endpoint" and "data". + :type message: dict + """ if message["endpoint"] == "actuate/speech": self._handle_speech(message) diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index e6de258..448d6f3 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -14,6 +14,24 @@ logger = logging.getLogger(__name__) class AudioSender(SocketBase): + """ + Audio sender endpoint, responsible for sending microphone audio data. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use. + :type port: int + + :ivar thread: Thread used for sending audio. + :vartype thread: threading.Thread | None + + :ivar audio: PyAudio instance. + :vartype audio: pyaudio.PyAudio | None + + :ivar microphone: Selected microphone information. + :vartype microphone: dict | None + """ def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port): super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str self.create_socket(zmq_context, zmq.PUB, port) @@ -30,6 +48,8 @@ class AudioSender(SocketBase): def start(self): """ Start sending audio in a different thread. + + Will not start if no microphone is available. """ if not self.microphone: logger.info("Not listening: no microphone available.") @@ -41,14 +61,18 @@ class AudioSender(SocketBase): def wait_until_done(self): """ - Wait until the audio thread is done. Will only be done if `state.exit_event` is set, so - make sure to set that before calling this method or it will block. + Wait until the audio thread is done. + + Will block until `state.exit_event` is set. If the thread is not running, does nothing. """ if not self.thread: return self.thread.join() self.thread = None def _stream(self): + """ + Internal method to continuously read audio from the microphone and send it over the socket. + """ audio_settings = settings.audio_config chunk = audio_settings.chunk_size # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index b0adf24..bd47198 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -6,26 +6,47 @@ from robot_interface.state import state from robot_interface.core.config import settings class MainReceiver(ReceiverBase): + """ + The main receiver endpoint, responsible for handling ping and negotiation requests. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use. + :type port: int + """ def __init__(self, zmq_context, port=settings.agent_settings.main_receiver_port): - """ - The main receiver endpoint, responsible for handling ping and negotiation requests. - - :param zmq_context: The ZeroMQ context to use. - :type zmq_context: zmq.Context - - :param port: The port to use. - :type port: int - """ super(MainReceiver, self).__init__("main") self.create_socket(zmq_context, zmq.REP, port, bind=False) @staticmethod def _handle_ping(message): - """A simple ping endpoint. Returns the provided data.""" + """ + Handle a ping request. + + Returns the provided data in a standardized response dictionary. + + :param message: The ping request message. + :type message: dict + + :return: A response dictionary containing the original data. + :rtype: dict[str, str | list[dict]] + """ return {"endpoint": "ping", "data": message.get("data")} @staticmethod def _handle_port_negotiation(message): + """ + Handle a port negotiation request. + + Returns a list of all known endpoints and their descriptions. + + :param message: The negotiation request message. + :type message: dict + + :return: A response dictionary with endpoint descriptions as data. + :rtype: dict[str, list[dict]] + """ endpoints = [socket.endpoint_description() for socket in state.sockets] return {"endpoint": "negotiate/ports", "data": endpoints} @@ -33,13 +54,13 @@ class MainReceiver(ReceiverBase): @staticmethod def _handle_negotiation(message): """ - Handle a negotiation request. Will respond with ports that can be used to connect to the robot. + Handle a negotiation request. Responds with ports that can be used to connect to the robot. :param message: The negotiation request message. :type message: dict - :return: A response dictionary with a 'ports' key containing a list of ports and their function. - :rtype: dict[str, list[dict]] + :return: A response dictionary with the negotiation result. + :rtype: dict[str, str | list[dict]] """ # In the future, the sender could send information like the robot's IP address, etc. @@ -49,6 +70,17 @@ class MainReceiver(ReceiverBase): return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."} def handle_message(self, message): + """ + Main entry point for handling incoming messages. + + Dispatches messages to the appropriate handler based on the endpoint. + + :param message: The received message. + :type message: dict + + :return: A response dictionary based on the requested endpoint. + :rtype: dict[str, str | list[dict]] + """ if message["endpoint"] == "ping": return self._handle_ping(message) elif message["endpoint"].startswith("negotiate"): diff --git a/src/robot_interface/endpoints/receiver_base.py b/src/robot_interface/endpoints/receiver_base.py index 498d38b..8425889 100644 --- a/src/robot_interface/endpoints/receiver_base.py +++ b/src/robot_interface/endpoints/receiver_base.py @@ -4,7 +4,7 @@ from robot_interface.endpoints.socket_base import SocketBase class ReceiverBase(SocketBase, object): - """Associated with a ZeroMQ socket.""" + """Base class for receivers associated with a ZeroMQ socket.""" __metaclass__ = ABCMeta @abstractmethod diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index d08c360..9c7c20b 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -4,16 +4,27 @@ import zmq class SocketBase(object): + """ + Base class for endpoints associated with a ZeroMQ socket. + + :ivar identifier: The identifier of the endpoint. + :vartype identifier: str + + :ivar port: The port used by the socket, set by `create_socket`. + :vartype port: int | None + + :ivar socket: The ZeroMQ socket object, set by `create_socket`. + :vartype socket: zmq.Socket | None + + :ivar bound: Whether the socket is bound or connected, set by `create_socket`. + :vartype bound: bool | None + """ __metaclass__ = ABCMeta name = None socket = None def __init__(self, identifier): - """ - :param identifier: The identifier of the endpoint. - :type identifier: str - """ self.identifier = identifier self.port = None # Set later by `create_socket` self.socket = None # Set later by `create_socket` @@ -32,8 +43,7 @@ class SocketBase(object): :param port: The port to use. :type port: int - :param options: A list of options to be set on the socket. The list contains tuples where the first element contains the option - and the second the value, for example (zmq.CONFLATE, 1). + :param options: A list of tuples where the first element contains the option and the second the value. :type options: list[tuple[int, int]] :param bind: Whether to bind the socket or connect to it. @@ -62,7 +72,7 @@ class SocketBase(object): Description of the endpoint. Used for negotiation. :return: A dictionary with the following keys: id, port, bind. See API specification at: - https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication#negotiation + https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication#negotiation :rtype: dict """ return { diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 1eacc3b..9fa1132 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -7,6 +7,15 @@ from robot_interface.state import state from robot_interface.core.config import settings class VideoSender(SocketBase): + """ + Video sender endpoint, responsible for sending video frames. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use for sending video frames. + :type port: int + """ def __init__(self, zmq_context, port=settings.agent_settings.video_sender_port): super(VideoSender, self).__init__("video") self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)]) @@ -14,6 +23,8 @@ class VideoSender(SocketBase): def start_video_rcv(self): """ Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. + + Will not start if no qi session is available. """ if not state.qi_session: logging.info("No Qi session available. Not starting video loop.") @@ -38,7 +49,7 @@ class VideoSender(SocketBase): :type vid_service: Object (Qi service object) :param vid_stream_name: The name of a camera subscription on the video service object vid_service - :type vid_stream_name: String + :type vid_stream_name: str """ while not state.exit_event.is_set(): try: diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 5af10ce..816e53b 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -57,6 +57,13 @@ def main_loop(context): continue def overtime_callback(time_ms): + """ + A callback function executed by TimeBlock if the message handling + exceeds the allowed time limit. + + :param time_ms: The elapsed time, in milliseconds, that the block took. + :type time_ms: float + """ logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", message["endpoint"], time_ms) @@ -68,6 +75,12 @@ def main_loop(context): def main(): + """ + Initializes the ZeroMQ context and the application state. + It executes the main event loop (`main_loop`) and ensures that both the + application state and the ZeroMQ context are properly cleaned up (deinitialized/terminated) + upon exit, including handling a KeyboardInterrupt. + """ context = zmq.Context() state.initialize() diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index cae3ce6..b625867 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -12,14 +12,31 @@ class State(object): This class is used to share state between threads. For example, when the program is quit, that all threads can detect this via the `exit_event` property being set. + + :ivar is_initialized: Flag indicating whether the state setup (exit handlers, QI session) has completed. + :vartype is_initialized: bool + + :ivar exit_event: A thread event used to signal all threads that the program is shutting down. + :vartype exit_event: threading.Event | None + + :ivar sockets: A list of ZeroMQ socket wrappers (`SocketBase`) that need to be closed during deinitialization. + :vartype sockets: List[SocketBase] + + :ivar qi_session: The QI session object used for interaction with the robot/platform services. + :vartype qi_session: None | qi.Session """ def __init__(self): self.is_initialized = False self.exit_event = None - self.sockets = [] # type: List[SocketBase] - self.qi_session = None # type: None | ssl.SSLSession + self.sockets = [] + self.qi_session = None def initialize(self): + """ + Sets up the application state. Creates the thread exit event, registers + signal handlers (`SIGINT`, `SIGTERM`) for graceful shutdown, and + establishes the QI session. + """ if self.is_initialized: logging.warn("Already initialized") return @@ -36,6 +53,9 @@ class State(object): self.is_initialized = True def deinitialize(self): + """ + Closes all sockets stored in the `sockets` list. + """ if not self.is_initialized: return for socket in self.sockets: @@ -44,7 +64,17 @@ class State(object): self.is_initialized = False def __getattribute__(self, name): - # Enforce that the state is initialized before accessing any property (aside from the basic ones) + """ + Custom attribute access method that enforces a check: the state must be + fully initialized before any non-setup attributes (like `sockets` or `qi_session`) + can be accessed. + + :param name: The name of the attribute being accessed. + :type name: str + + :return: The value of the requested attribute. + :rtype: Any + """ if name in ( "initialize", "deinitialize", diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index 3bf9fe6..816c7f6 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -29,7 +29,7 @@ def choose_mic_interactive(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone. + if there is no microphone. :rtype: dict | None """ microphones = list(get_microphones(audio)) @@ -61,7 +61,7 @@ def choose_mic_default(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone. + if there is no microphone. :rtype: dict | None """ try: @@ -78,7 +78,7 @@ def choose_mic_arguments(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone satisfied by the arguments. + if there is no microphone satisfied by the arguments. :rtype: dict | None """ microphone_name = None @@ -112,7 +112,7 @@ def choose_mic(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone. + if there is no microphone. :rtype: dict | None """ chosen_mic = choose_mic_arguments(audio) diff --git a/src/robot_interface/utils/qi_utils.py b/src/robot_interface/utils/qi_utils.py index fc7640b..23028cd 100644 --- a/src/robot_interface/utils/qi_utils.py +++ b/src/robot_interface/utils/qi_utils.py @@ -8,6 +8,12 @@ except ImportError: def get_qi_session(): + """ + Create and return a Qi session if available. + + :return: The active Qi session or ``None`` if unavailable. + :rtype: qi.Session | None + """ if qi is None: logging.info("Unable to import qi. Running in stand-alone mode.") return None diff --git a/src/robot_interface/utils/timeblock.py b/src/robot_interface/utils/timeblock.py index 23f1c85..584963b 100644 --- a/src/robot_interface/utils/timeblock.py +++ b/src/robot_interface/utils/timeblock.py @@ -5,27 +5,54 @@ class TimeBlock(object): """ A context manager that times the execution of the block it contains. If execution exceeds the limit, or if no limit is given, the callback will be called with the time that the block took. - """ - def __init__(self, callback, limit_ms=None): - """ - :param callback: The callback function that is called when the block of code is over, - unless the code block did not exceed the time limit. - :type callback: Callable[[float], None] - :param limit_ms: The number of milliseconds the block of code is allowed to take. If it + :param callback: The callback function that is called when the block of code is over, + unless the code block did not exceed the time limit. + :type callback: Callable[[float], None] + + :param limit_ms: The number of milliseconds the block of code is allowed to take. If it exceeds this time, or if it's None, the callback function will be called with the time the block took. - :type limit_ms: int | None - """ + :type limit_ms: int | None + + :ivar limit_ms: The number of milliseconds the block of code is allowed to take. + :vartype limit_ms: float | None + + :ivar callback: The callback function that is called when the block of code is over. + :vartype callback: Callable[[float], None] + + ivar start: The start time of the block, set when entering the context. + :vartype start: float | None + """ + def __init__(self, callback, limit_ms=None): self.limit_ms = float(limit_ms) if limit_ms is not None else None self.callback = callback self.start = None def __enter__(self): + """ + Enter the context manager and record the start time. + + :return: Returns itself so timing information can be accessed if needed. + :rtype: TimeBlock + """ self.start = time.time() return self def __exit__(self, exc_type, exc_value, traceback): + """ + Exit the context manager, calculate the elapsed time, and call the callback + if the time limit was exceeded or not provided. + + :param exc_type: The exception type, or None if no exception occurred. + :type exc_type: Type[BaseException] | None + + :param exc_value: The exception instance, or None if no exception occurred. + :type exc_value: BaseException | None + + :param traceback: The traceback object, or None if no exception occurred. + :type traceback: TracebackType | None + """ elapsed = (time.time() - self.start) * 1000.0 # ms if self.limit_ms is None or elapsed > self.limit_ms: self.callback(elapsed) diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py index 74bd777..51b353e 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -17,10 +17,12 @@ class MicrophoneUtils(object): def test_choose_mic_default(self, pyaudio_instance): """ - The result must contain at least "index", as this is used to identify the microphone. - The "name" is used for logging, so it should also exist. - It must have one or more channels. - Lastly it must be capable of sending at least 16000 samples per second. + Tests that the default microphone selection function returns a valid + microphone dictionary containing all necessary keys with correct types and values. + + The result must contain at least "index", as this is used to identify the microphone, + and "name" for logging. It must have one or more channels (`maxInputChannels`), + and a default sample rate of at least 16000 Hz. """ result = choose_mic_default(pyaudio_instance) assert "index" in result @@ -39,7 +41,9 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_input_not_int(self, pyaudio_instance, mocker): """ - First mock an input that's not an integer, then a valid integer. There should be no errors. + Tests the robustness of the interactive selection when the user first enters + a non-integer value, ensuring the system prompts again without error and accepts + a valid integer on the second attempt. """ microphones = get_microphones(pyaudio_instance) target_microphone = next(microphones) @@ -59,7 +63,8 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_negative_index(self, pyaudio_instance, mocker): """ - Make sure that the interactive method does not allow negative integers as input. + Tests that the interactive selection method prevents the user from entering + a negative integer as a microphone index. """ microphones = get_microphones(pyaudio_instance) target_microphone = next(microphones) @@ -79,7 +84,8 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_index_too_high(self, pyaudio_instance, mocker): """ - Make sure that the interactive method does not allow indices higher than the highest mic index. + Tests that the interactive selection method prevents the user from entering + an index that exceeds the total number of available microphones. """ real_count = len(list(get_microphones(pyaudio_instance))) mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"]) @@ -96,7 +102,9 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_random_index(self, pyaudio_instance, mocker): """ - Get a random index from the list of available mics, make sure it's correct. + Tests the core interactive functionality by simulating the selection of a + random valid microphone index and verifying that the correct microphone + information is returned. """ microphones = list(get_microphones(pyaudio_instance)) random_index = random.randrange(len(microphones)) @@ -108,6 +116,9 @@ class MicrophoneUtils(object): assert result["index"] == microphones[random_index]["index"] def test_choose_mic_no_arguments(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when no command-line arguments are provided, + """ mocker.patch.object(sys, "argv", []) result = choose_mic_arguments(pyaudio_instance) @@ -115,6 +126,10 @@ class MicrophoneUtils(object): assert result is None def test_choose_mic_arguments(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when the microphone name is passed as a separate + argument. + """ for mic in get_microphones(pyaudio_instance): mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) @@ -124,6 +139,10 @@ class MicrophoneUtils(object): assert result == mic def test_choose_mic_arguments_eq(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when the microphone name is passed using an + equals sign (`--microphone=NAME`). + """ for mic in get_microphones(pyaudio_instance): mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])]) @@ -132,7 +151,11 @@ class MicrophoneUtils(object): assert result is not None assert result == mic - def test_choose_mic_arguments_not_exits(self, pyaudio_instance, mocker): + def test_choose_mic_arguments_not_exist(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when a non-existent microphone name is passed + via command-line arguments, expecting the function to return None. + """ mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"]) result = choose_mic_arguments(pyaudio_instance) @@ -140,6 +163,10 @@ class MicrophoneUtils(object): assert result is None def test_choose_mic_with_argument(self, pyaudio_instance, mocker): + """ + Tests `choose_mic` function when a valid microphone is + specified via command-line arguments. + """ mic = next(get_microphones(pyaudio_instance)) mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) @@ -149,6 +176,11 @@ class MicrophoneUtils(object): assert result == mic def test_choose_mic_no_argument(self, pyaudio_instance, mocker): + """ + Tests `choose_mic` function when no command-line arguments + are provided, verifying that the function falls back correctly to the + system's default microphone selection. + """ default_mic = choose_mic_default(pyaudio_instance) mocker.patch.object(sys, "argv", []) diff --git a/test/integration/test_microphone_utils.py b/test/integration/test_microphone_utils.py index a857498..1af1181 100644 --- a/test/integration/test_microphone_utils.py +++ b/test/integration/test_microphone_utils.py @@ -7,6 +7,17 @@ from common.microphone_utils import MicrophoneUtils @pytest.fixture def pyaudio_instance(): + """ + A pytest fixture that provides an initialized PyAudio instance for tests + requiring microphone access. + + It first initializes PyAudio. If a default input device (microphone) is not + found, the test is skipped to avoid failures in environments + without a mic. + + :return: An initialized PyAudio instance. + :rtype: pyaudio.PyAudio + """ audio = pyaudio.PyAudio() try: audio.get_default_input_device_info() diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py index da70964..74620ab 100644 --- a/test/unit/test_actuation_receiver.py +++ b/test/unit/test_actuation_receiver.py @@ -9,11 +9,21 @@ from robot_interface.endpoints.actuation_receiver import ActuationReceiver @pytest.fixture def zmq_context(): + """ + A pytest fixture that creates and yields a ZMQ context. + + :return: An initialized ZeroMQ context. + :rtype: zmq.Context + """ context = zmq.Context() yield context def test_handle_unimplemented_endpoint(zmq_context): + """ + Tests that the ``ActuationReceiver.handle_message`` method can + handle an unknown or unimplemented endpoint without raising an error. + """ receiver = ActuationReceiver(zmq_context) # Should not error receiver.handle_message({ @@ -23,6 +33,10 @@ def test_handle_unimplemented_endpoint(zmq_context): def test_speech_message_no_data(zmq_context, mocker): + """ + Tests that the message handler logs a warning when a speech actuation + request (`actuate/speech`) is received but contains empty string data. + """ mock_warn = mocker.patch("logging.warn") receiver = ActuationReceiver(zmq_context) @@ -32,6 +46,10 @@ def test_speech_message_no_data(zmq_context, mocker): def test_speech_message_invalid_data(zmq_context, mocker): + """ + Tests that the message handler logs a warning when a speech actuation + request (`actuate/speech`) is received with data that is not a string (e.g., a boolean). + """ mock_warn = mocker.patch("logging.warn") receiver = ActuationReceiver(zmq_context) @@ -41,6 +59,10 @@ def test_speech_message_invalid_data(zmq_context, mocker): def test_speech_no_qi(zmq_context, mocker): + """ + Tests the actuation receiver's behavior when processing a speech request + but the global state does not have an active QI session. + """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") mock_qi_session = mock.PropertyMock(return_value=None) @@ -53,6 +75,10 @@ def test_speech_no_qi(zmq_context, mocker): def test_speech(zmq_context, mocker): + """ + Tests the core speech actuation functionality by mocking the QI TextToSpeech + service and verifying that it is called correctly. + """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") mock_qi = mock.Mock() diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index fc21805..13cd4bf 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -11,11 +11,20 @@ from robot_interface.endpoints.audio_sender import AudioSender @pytest.fixture def zmq_context(): + """ + A pytest fixture that creates and yields a ZMQ context. + + :return: An initialized ZeroMQ context. + :rtype: zmq.Context + """ context = zmq.Context() yield context def test_no_microphone(zmq_context, mocker): + """ + Tests the scenario where no valid microphone can be chosen for recording. + """ mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = None @@ -31,6 +40,10 @@ def test_no_microphone(zmq_context, mocker): def test_unicode_mic_name(zmq_context, mocker): + """ + Tests the robustness of the `AudioSender` when handling microphone names + that contain Unicode characters. + """ mocker.patch("robot_interface.endpoints.audio_sender.threading") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"• Some Unicode name"} @@ -47,10 +60,16 @@ def test_unicode_mic_name(zmq_context, mocker): def _fake_read(num_frames): + """ + Helper function to simulate reading raw audio data from a microphone stream. + """ return os.urandom(num_frames * 4) def test_sending_audio(mocker): + """ + Tests the successful sending of audio data over a ZeroMQ socket. + """ mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} @@ -76,10 +95,16 @@ def test_sending_audio(mocker): def _fake_read_error(num_frames): + """ + Helper function to simulate an I/O error during microphone stream reading. + """ raise IOError() def test_break_microphone(mocker): + """ + Tests the error handling when the microphone stream breaks (raises an IOError). + """ mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} diff --git a/test/unit/test_main_receiver.py b/test/unit/test_main_receiver.py index 4ded502..2fb8dfc 100644 --- a/test/unit/test_main_receiver.py +++ b/test/unit/test_main_receiver.py @@ -7,11 +7,20 @@ from robot_interface.endpoints.main_receiver import MainReceiver @pytest.fixture def zmq_context(): + """ + A pytest fixture that creates and yields a ZMQ context. + + :return: An initialized ZeroMQ context. + :rtype: zmq.Context + """ context = zmq.Context() yield context def test_handle_ping(zmq_context): + """ + Tests the receiver's ability to handle the "ping" endpoint with data. + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({"endpoint": "ping", "data": "pong"}) @@ -22,6 +31,10 @@ def test_handle_ping(zmq_context): def test_handle_ping_none(zmq_context): + """ + Tests the receiver's ability to handle the ping endpoint when the + data field is explicitly set to None. + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({"endpoint": "ping", "data": None}) @@ -33,6 +46,9 @@ def test_handle_ping_none(zmq_context): @mock.patch("robot_interface.endpoints.main_receiver.state") def test_handle_negotiate_ports(mock_state, zmq_context): + """ + Tests the handling of the "negotiate/ports" endpoint. + """ receiver = MainReceiver(zmq_context) mock_state.sockets = [receiver] @@ -54,6 +70,10 @@ def test_handle_negotiate_ports(mock_state, zmq_context): def test_handle_unimplemented_endpoint(zmq_context): + """ + Tests that the receiver correctly handles a request to a completely + unknown or non-existent endpoint. + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({ "endpoint": "some_endpoint_that_definitely_does_not_exist", @@ -67,6 +87,13 @@ def test_handle_unimplemented_endpoint(zmq_context): def test_handle_unimplemented_negotiation_endpoint(zmq_context): + """ + Tests handling a request to an unknown sub-endpoint within a known + group + + The expected behavior is to return a specific "negotiate/error" response + with a descriptive error string. + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({ "endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist", diff --git a/test/unit/test_microphone_utils.py b/test/unit/test_microphone_utils.py index 5ad551d..df31ca3 100644 --- a/test/unit/test_microphone_utils.py +++ b/test/unit/test_microphone_utils.py @@ -7,6 +7,16 @@ from robot_interface.utils.microphone import choose_mic_default, choose_mic_inte class MockPyAudio: + """ + A mock implementation of the PyAudio library class, designed for testing + microphone utility functions without requiring actual audio hardware. + + It provides fake devices, including one input microphone, and implements + the core PyAudio methods required for device enumeration. + + :ivar devices: A list of dictionaries representing mock audio devices. + :vartype devices: List[Dict[str, Any]] + """ def __init__(self): # You can predefine fake device info here self.devices = [ @@ -37,18 +47,36 @@ class MockPyAudio: ] def get_device_count(self): - """Return the number of available mock devices.""" + """ + Returns the number of available mock devices. + + :return: The total number of devices in the mock list. + :rtype: int + """ return len(self.devices) def get_device_info_by_index(self, index): - """Return information for a given mock device index.""" + """ + Returns information for a given mock device index. + + :param index: The index of the device to retrieve. + :type index: int + + :return: A dictionary containing device information. + :rtype: Dict[str, Any] + """ if 0 <= index < len(self.devices): return self.devices[index] else: raise IOError("Invalid device index: {}".format(index)) def get_default_input_device_info(self): - """Return info for a default mock input device.""" + """ + Returns information for the default mock input device. + + :return: A dictionary containing the default input device information. + :rtype: Dict[str, Any] + """ for device in self.devices: if device.get("maxInputChannels", 0) > 0: return device @@ -57,16 +85,32 @@ class MockPyAudio: @pytest.fixture def pyaudio_instance(): + """ + A pytest fixture that returns an instance of the `MockPyAudio` class. + + :return: An initialized instance of the mock PyAudio class. + :rtype: MockPyAudio + """ return MockPyAudio() def _raise_io_error(): + """ + Helper function used to mock PyAudio methods that are expected to fail + when no device is available. + """ raise IOError() class TestAudioUnit(MicrophoneUtils): - """Run shared audio behavior tests with the mock implementation.""" + """ + Runs the shared microphone behavior tests defined in `MicrophoneUtils` using + the mock PyAudio implementation. + """ def test_choose_mic_default_no_mic(self): + """ + Tests `choose_mic_default` when no microphones are available. + """ mock_pyaudio = mock.Mock() mock_pyaudio.get_device_count = mock.Mock(return_value=0L) mock_pyaudio.get_default_input_device_info = _raise_io_error @@ -76,6 +120,9 @@ class TestAudioUnit(MicrophoneUtils): assert result is None def test_choose_mic_interactive_no_mic(self): + """ + Tests `choose_mic_interactive` when no microphones are available. + """ mock_pyaudio = mock.Mock() mock_pyaudio.get_device_count = mock.Mock(return_value=0L) mock_pyaudio.get_default_input_device_info = _raise_io_error diff --git a/test/unit/test_time_block.py b/test/unit/test_time_block.py index eabc91b..129b4b9 100644 --- a/test/unit/test_time_block.py +++ b/test/unit/test_time_block.py @@ -6,11 +6,21 @@ from robot_interface.utils.timeblock import TimeBlock class AnyFloat(object): + """ + A helper class used in tests to assert that a mock function was called + with an argument that is specifically a float, regardless of its value. + + It overrides the equality comparison (`__eq__`) to check only the type. + """ def __eq__(self, other): return isinstance(other, float) def test_no_limit(): + """ + Tests the scenario where the `TimeBlock` context manager is used without + a time limit. + """ callback = mock.Mock() with TimeBlock(callback): @@ -20,6 +30,10 @@ def test_no_limit(): def test_exceed_limit(): + """ + Tests the scenario where the execution time within the `TimeBlock` + exceeds the provided limit. + """ callback = mock.Mock() with TimeBlock(callback, 0): @@ -29,6 +43,10 @@ def test_exceed_limit(): def test_within_limit(): + """ + Tests the scenario where the execution time within the `TimeBlock` + stays within the provided limit. + """ callback = mock.Mock() with TimeBlock(callback, 5):