From 051f9045768c7e26a150f29cef76ef7333b589ea Mon Sep 17 00:00:00 2001
From: Pim Hutting
Date: Fri, 21 Nov 2025 16:35:40 +0100
Subject: [PATCH] chore: add documentation RI
Code functionality left unchanged, only added docs where missing
close: N25B-298
---
src/robot_interface/core/config.py | 106 +++++++++++++++++-
.../endpoints/actuation_receiver.py | 32 ++++--
src/robot_interface/endpoints/audio_sender.py | 29 ++++-
.../endpoints/main_receiver.py | 58 +++++++---
.../endpoints/receiver_base.py | 2 +-
src/robot_interface/endpoints/socket_base.py | 19 +++-
src/robot_interface/endpoints/video_sender.py | 11 ++
src/robot_interface/main.py | 13 +++
src/robot_interface/state.py | 36 +++++-
src/robot_interface/utils/qi_utils.py | 6 +
src/robot_interface/utils/timeblock.py | 38 +++++--
test/common/microphone_utils.py | 104 +++++++++++++++--
test/integration/test_microphone_utils.py | 11 ++
test/unit/test_actuation_receiver.py | 53 +++++++++
test/unit/test_audio_sender.py | 52 +++++++++
test/unit/test_main_receiver.py | 45 ++++++++
test/unit/test_microphone_utils.py | 55 ++++++++-
test/unit/test_time_block.py | 18 +++
18 files changed, 629 insertions(+), 59 deletions(-)
diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py
index f5295b2..b3a7adc 100644
--- a/src/robot_interface/core/config.py
+++ b/src/robot_interface/core/config.py
@@ -2,7 +2,27 @@ from __future__ import unicode_literals
class AgentSettings(object):
- """Agent port configuration."""
+ """
+ Agent port configuration.
+
+ :param actuating_receiver_port: Port for receiving actuation commands.
+ :type actuating_receiver_port: int
+ :param main_receiver_port: Port for receiving main messages.
+ :type main_receiver_port: int
+ :param video_sender_port: Port used for sending video frames.
+ :type video_sender_port: int
+ :param audio_sender_port: Port used for sending audio data.
+ :type audio_sender_port: int
+
+ :ivar actuating_receiver_port: Port for receiving actuation commands.
+ :type actuating_receiver_port: int
+ :ivar main_receiver_port: Port for receiving main messages.
+ :type main_receiver_port: int
+ :ivar video_sender_port: Port used for sending video frames.
+ :type video_sender_port: int
+ :ivar audio_sender_port: Port used for sending audio data.
+ :type audio_sender_port: int
+ """
def __init__(
self,
actuating_receiver_port=5557,
@@ -17,7 +37,35 @@ class AgentSettings(object):
class VideoConfig(object):
- """Video configuration constants."""
+ """
+ Video configuration constants.
+
+ :param camera_index: Index of the camera to use.
+ :type camera_index: int
+ :param resolution: Video resolution mode.
+ :type resolution: int
+ :param color_space: Color space identifier.
+ :type color_space: int
+ :param fps: Frames per second of the video stream.
+ :type fps: int
+ :param stream_name: Name of the video stream.
+ :type stream_name: str
+ :param image_buffer: Internal buffer size for video frames.
+ :type image_buffer: int
+
+ :ivar camera_index: Index of the camera used.
+ :type camera_index: int
+ :ivar resolution: Video resolution mode.
+ :type resolution: int
+ :ivar color_space: Color space identifier.
+ :type color_space: int
+ :ivar fps: Frames per second of the video stream.
+ :type fps: int
+ :ivar stream_name: Name of the video stream.
+ :type stream_name: str
+ :ivar image_buffer: Internal buffer size for video frames.
+ :type image_buffer: int
+ """
def __init__(
self,
camera_index=0,
@@ -36,7 +84,23 @@ class VideoConfig(object):
class AudioConfig(object):
- """Audio configuration constants."""
+ """
+ Audio configuration constants.
+
+ :param sample_rate: Audio sampling rate in Hz.
+ :type sample_rate: int
+ :param chunk_size: Size of audio chunks to capture/process.
+ :type chunk_size: int
+ :param channels: Number of audio channels.
+ :type channels: int
+
+ :ivar sample_rate: Audio sampling rate in Hz.
+ :type sample_rate: int
+ :ivar chunk_size: Size of audio chunks to capture/process.
+ :type chunk_size: int
+ :ivar channels: Number of audio channels.
+ :type 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 +108,46 @@ class AudioConfig(object):
class MainConfig(object):
- """Main configuration"""
+ """
+ Main system configuration.
+
+ :param poll_timeout_ms: Timeout for polling events, in milliseconds.
+ :type poll_timeout_ms: int
+ :param max_handler_time_ms: Maximum allowed handler time, in milliseconds.
+ :type max_handler_time_ms: int
+
+ :ivar poll_timeout_ms: Timeout for polling events, in milliseconds.
+ :type poll_timeout_ms: int
+ :ivar max_handler_time_ms: Maximum allowed handler time, in milliseconds.
+ :type 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.
+
+ :param agent_settings: Agent settings instance or None for defaults.
+ :type agent_settings: AgentSettings | None
+ :param video_config: VideoConfig instance or None for defaults.
+ :type video_config: VideoConfig | None
+ :param audio_config: AudioConfig instance or None for defaults.
+ :type audio_config: AudioConfig | None
+ :param main_config: MainConfig instance or None for defaults.
+ :type main_config: MainConfig | None
+
+ :ivar agent_settings: Agent-related port configuration.
+ :type agent_settings: AgentSettings
+ :ivar video_config: Video stream configuration.
+ :type video_config: VideoConfig
+ :ivar audio_config: Audio stream configuration.
+ :type audio_config: AudioConfig
+ :ivar main_config: Main system-level configuration.
+ :type 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..027d417 100644
--- a/src/robot_interface/endpoints/actuation_receiver.py
+++ b/src/robot_interface/endpoints/actuation_receiver.py
@@ -10,22 +10,32 @@ 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: ssl.SSLSession | 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 +58,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..6f3b135 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.
+ :type thread: threading.Thread | None
+
+ :ivar audio: PyAudio instance.
+ :type audio: pyaudio.PyAudio | None
+
+ :ivar microphone: Selected microphone information.
+ :type 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,7 +48,10 @@ 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.")
return
@@ -41,14 +62,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..248c7dd 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.
+ :type identifier: str
+
+ :ivar port: The port used by the socket, set by `create_socket`.
+ :type port: int | None
+
+ :ivar socket: The ZeroMQ socket object, set by `create_socket`.
+ :type socket: zmq.Socket | None
+
+ :ivar bound: Whether the socket is bound or connected, set by `create_socket`.
+ :type 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`
diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py
index 1eacc3b..1bc0617 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 of no qi session is available.
"""
if not state.qi_session:
logging.info("No Qi session available. Not starting video loop.")
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..2e2b149 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.
+ :type is_initialized: bool
+
+ :ivar exit_event: A thread event used to signal all threads that the program is shutting down.
+ :type exit_event: threading.Event | None
+
+ :ivar sockets: A list of ZeroMQ socket wrappers (`SocketBase`) that need to be closed during deinitialization.
+ :type sockets: List[SocketBase]
+
+ :ivar qi_session: The QI session object used for interaction with the robot/platform services.
+ :type qi_session: None | ssl.SSLSession
"""
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/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..3b50fff 100644
--- a/src/robot_interface/utils/timeblock.py
+++ b/src/robot_interface/utils/timeblock.py
@@ -5,27 +5,45 @@ 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.
+
+ :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
"""
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
- 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
- """
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..911f4a2 100644
--- a/test/common/microphone_utils.py
+++ b/test/common/microphone_utils.py
@@ -17,10 +17,15 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance used to query microphone information.
+ :type pyaudio_instance: PyAudio
"""
result = choose_mic_default(pyaudio_instance)
assert "index" in result
@@ -39,7 +44,15 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
"""
microphones = get_microphones(pyaudio_instance)
target_microphone = next(microphones)
@@ -59,7 +72,14 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
"""
microphones = get_microphones(pyaudio_instance)
target_microphone = next(microphones)
@@ -79,7 +99,14 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
"""
real_count = len(list(get_microphones(pyaudio_instance)))
mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"])
@@ -96,7 +123,15 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
"""
microphones = list(get_microphones(pyaudio_instance))
random_index = random.randrange(len(microphones))
@@ -115,6 +150,16 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
for mic in get_microphones(pyaudio_instance):
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
@@ -124,6 +169,16 @@ 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`).
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
for mic in get_microphones(pyaudio_instance):
mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])])
@@ -132,7 +187,17 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"])
result = choose_mic_arguments(pyaudio_instance)
@@ -140,6 +205,16 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
mic = next(get_microphones(pyaudio_instance))
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
@@ -149,6 +224,17 @@ 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.
+
+ :param pyaudio_instance: A mocked or real PyAudio instance.
+ :type pyaudio_instance: PyAudio
+
+ :param mocker: The fixture used for mocking built-in functions and system objects.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
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..7ea504c 100644
--- a/test/unit/test_actuation_receiver.py
+++ b/test/unit/test_actuation_receiver.py
@@ -9,11 +9,24 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+ """
receiver = ActuationReceiver(zmq_context)
# Should not error
receiver.handle_message({
@@ -23,6 +36,16 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+
+ :param mocker: The pytest-mock fixture used to patch `logging.warn`.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
mock_warn = mocker.patch("logging.warn")
receiver = ActuationReceiver(zmq_context)
@@ -32,6 +55,16 @@ 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).
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+
+ :param mocker: The pytest-mock fixture used to patch `logging.warn`.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
mock_warn = mocker.patch("logging.warn")
receiver = ActuationReceiver(zmq_context)
@@ -41,6 +74,16 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+
+ :param mocker: The pytest-mock fixture used to patch the global state.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
mock_qi_session = mock.PropertyMock(return_value=None)
@@ -53,6 +96,16 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+
+ :param mocker: The pytest-mock fixture used to patch state and modules.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
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..8923ba0 100644
--- a/test/unit/test_audio_sender.py
+++ b/test/unit/test_audio_sender.py
@@ -11,11 +11,26 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+
+ :param mocker: The pytest-mock fixture used to patch internal dependencies.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
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 +46,16 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+
+ :param mocker: The pytest-mock fixture used to patch internal dependencies.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
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 +72,25 @@ 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.
+
+ :param num_frames: The number of audio frames requested.
+ :type num_frames: int
+
+ :return: A byte string containing random data, simulating audio.
+ :rtype: str
+ """
return os.urandom(num_frames * 4)
def test_sending_audio(mocker):
+ """
+ Tests the successful sending of audio data over a ZeroMQ socket.
+
+ :param mocker: The pytest-mock fixture used to patch internal dependencies.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
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 +116,22 @@ def test_sending_audio(mocker):
def _fake_read_error(num_frames):
+ """
+ Helper function to simulate an I/O error during microphone stream reading.
+
+ :param num_frames: The number of audio frames requested.
+ :type num_frames: int
+ """
raise IOError()
def test_break_microphone(mocker):
+ """
+ Tests the error handling when the microphone stream breaks (raises an IOError).
+
+ :param mocker: The pytest-mock fixture used to patch internal dependencies.
+ :type mocker: pytest_mock.plugin.MockerFixture
+ """
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..c37bdff 100644
--- a/test/unit/test_main_receiver.py
+++ b/test/unit/test_main_receiver.py
@@ -7,11 +7,23 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+ """
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({"endpoint": "ping", "data": "pong"})
@@ -22,6 +34,13 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+ """
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({"endpoint": "ping", "data": None})
@@ -33,6 +52,15 @@ 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.
+
+ :param mock_state: Mocked global application state.
+ :type mock_state: mock.Mock
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+ """
receiver = MainReceiver(zmq_context)
mock_state.sockets = [receiver]
@@ -54,6 +82,13 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+ """
receiver = MainReceiver(zmq_context)
response = receiver.handle_message({
"endpoint": "some_endpoint_that_definitely_does_not_exist",
@@ -67,6 +102,16 @@ 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.
+
+ :param zmq_context: The ZeroMQ context fixture.
+ :type zmq_context: zmq.Context
+ """
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..7cb13de 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.
+ :type 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):