Merge dev with main #27
@@ -2,7 +2,18 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
|
|
||||||
class AgentSettings(object):
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
actuating_receiver_port=5557,
|
actuating_receiver_port=5557,
|
||||||
@@ -17,7 +28,22 @@ class AgentSettings(object):
|
|||||||
|
|
||||||
|
|
||||||
class VideoConfig(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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
camera_index=0,
|
camera_index=0,
|
||||||
@@ -36,7 +62,16 @@ class VideoConfig(object):
|
|||||||
|
|
||||||
|
|
||||||
class AudioConfig(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):
|
def __init__(self, sample_rate=16000, chunk_size=512, channels=1):
|
||||||
self.sample_rate = sample_rate
|
self.sample_rate = sample_rate
|
||||||
self.chunk_size = chunk_size
|
self.chunk_size = chunk_size
|
||||||
@@ -44,14 +79,32 @@ class AudioConfig(object):
|
|||||||
|
|
||||||
|
|
||||||
class MainConfig(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):
|
def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50):
|
||||||
self.poll_timeout_ms = poll_timeout_ms
|
self.poll_timeout_ms = poll_timeout_ms
|
||||||
self.max_handler_time_ms = max_handler_time_ms
|
self.max_handler_time_ms = max_handler_time_ms
|
||||||
|
|
||||||
|
|
||||||
class Settings(object):
|
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):
|
def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None):
|
||||||
self.agent_settings = agent_settings or AgentSettings()
|
self.agent_settings = agent_settings or AgentSettings()
|
||||||
self.video_config = video_config or VideoConfig()
|
self.video_config = video_config or VideoConfig()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from robot_interface.core.config import settings
|
|||||||
|
|
||||||
|
|
||||||
class ActuationReceiver(ReceiverBase):
|
class ActuationReceiver(ReceiverBase):
|
||||||
def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port):
|
|
||||||
"""
|
"""
|
||||||
The actuation receiver endpoint, responsible for handling speech and gesture requests.
|
The actuation receiver endpoint, responsible for handling speech and gesture requests.
|
||||||
|
|
||||||
@@ -19,13 +18,23 @@ class ActuationReceiver(ReceiverBase):
|
|||||||
|
|
||||||
:param port: The port to use.
|
:param port: The port to use.
|
||||||
:type port: int
|
: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):
|
||||||
super(ActuationReceiver, self).__init__("actuation")
|
super(ActuationReceiver, self).__init__("actuation")
|
||||||
self.create_socket(zmq_context, zmq.SUB, port)
|
self.create_socket(zmq_context, zmq.SUB, port)
|
||||||
self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options
|
self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options
|
||||||
self._tts_service = None
|
self._tts_service = None
|
||||||
|
|
||||||
def _handle_speech(self, message):
|
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")
|
text = message.get("data")
|
||||||
if not text:
|
if not text:
|
||||||
logging.warn("Received message to speak, but it lacks data.")
|
logging.warn("Received message to speak, but it lacks data.")
|
||||||
@@ -48,5 +57,11 @@ class ActuationReceiver(ReceiverBase):
|
|||||||
qi.async(self._tts_service.say, text)
|
qi.async(self._tts_service.say, text)
|
||||||
|
|
||||||
def handle_message(self, message):
|
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":
|
if message["endpoint"] == "actuate/speech":
|
||||||
self._handle_speech(message)
|
self._handle_speech(message)
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class AudioSender(SocketBase):
|
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):
|
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
|
super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str
|
||||||
self.create_socket(zmq_context, zmq.PUB, port)
|
self.create_socket(zmq_context, zmq.PUB, port)
|
||||||
@@ -30,6 +48,8 @@ class AudioSender(SocketBase):
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""
|
"""
|
||||||
Start sending audio in a different thread.
|
Start sending audio in a different thread.
|
||||||
|
|
||||||
|
Will not start if no microphone is available.
|
||||||
"""
|
"""
|
||||||
if not self.microphone:
|
if not self.microphone:
|
||||||
logger.info("Not listening: no microphone available.")
|
logger.info("Not listening: no microphone available.")
|
||||||
@@ -41,14 +61,18 @@ class AudioSender(SocketBase):
|
|||||||
|
|
||||||
def wait_until_done(self):
|
def wait_until_done(self):
|
||||||
"""
|
"""
|
||||||
Wait until the audio thread is done. Will only be done if `state.exit_event` is set, so
|
Wait until the audio thread is done.
|
||||||
make sure to set that before calling this method or it will block.
|
|
||||||
|
Will block until `state.exit_event` is set. If the thread is not running, does nothing.
|
||||||
"""
|
"""
|
||||||
if not self.thread: return
|
if not self.thread: return
|
||||||
self.thread.join()
|
self.thread.join()
|
||||||
self.thread = None
|
self.thread = None
|
||||||
|
|
||||||
def _stream(self):
|
def _stream(self):
|
||||||
|
"""
|
||||||
|
Internal method to continuously read audio from the microphone and send it over the socket.
|
||||||
|
"""
|
||||||
audio_settings = settings.audio_config
|
audio_settings = settings.audio_config
|
||||||
chunk = audio_settings.chunk_size # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD
|
chunk = audio_settings.chunk_size # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from robot_interface.state import state
|
|||||||
from robot_interface.core.config import settings
|
from robot_interface.core.config import settings
|
||||||
|
|
||||||
class MainReceiver(ReceiverBase):
|
class MainReceiver(ReceiverBase):
|
||||||
def __init__(self, zmq_context, port=settings.agent_settings.main_receiver_port):
|
|
||||||
"""
|
"""
|
||||||
The main receiver endpoint, responsible for handling ping and negotiation requests.
|
The main receiver endpoint, responsible for handling ping and negotiation requests.
|
||||||
|
|
||||||
@@ -16,16 +15,38 @@ class MainReceiver(ReceiverBase):
|
|||||||
:param port: The port to use.
|
:param port: The port to use.
|
||||||
:type port: int
|
:type port: int
|
||||||
"""
|
"""
|
||||||
|
def __init__(self, zmq_context, port=settings.agent_settings.main_receiver_port):
|
||||||
super(MainReceiver, self).__init__("main")
|
super(MainReceiver, self).__init__("main")
|
||||||
self.create_socket(zmq_context, zmq.REP, port, bind=False)
|
self.create_socket(zmq_context, zmq.REP, port, bind=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_ping(message):
|
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")}
|
return {"endpoint": "ping", "data": message.get("data")}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_port_negotiation(message):
|
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]
|
endpoints = [socket.endpoint_description() for socket in state.sockets]
|
||||||
|
|
||||||
return {"endpoint": "negotiate/ports", "data": endpoints}
|
return {"endpoint": "negotiate/ports", "data": endpoints}
|
||||||
@@ -33,13 +54,13 @@ class MainReceiver(ReceiverBase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_negotiation(message):
|
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.
|
:param message: The negotiation request message.
|
||||||
:type message: dict
|
:type message: dict
|
||||||
|
|
||||||
:return: A response dictionary with a 'ports' key containing a list of ports and their function.
|
:return: A response dictionary with the negotiation result.
|
||||||
:rtype: dict[str, list[dict]]
|
:rtype: dict[str, str | list[dict]]
|
||||||
"""
|
"""
|
||||||
# In the future, the sender could send information like the robot's IP address, etc.
|
# 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."}
|
return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."}
|
||||||
|
|
||||||
def handle_message(self, message):
|
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":
|
if message["endpoint"] == "ping":
|
||||||
return self._handle_ping(message)
|
return self._handle_ping(message)
|
||||||
elif message["endpoint"].startswith("negotiate"):
|
elif message["endpoint"].startswith("negotiate"):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from robot_interface.endpoints.socket_base import SocketBase
|
|||||||
|
|
||||||
|
|
||||||
class ReceiverBase(SocketBase, object):
|
class ReceiverBase(SocketBase, object):
|
||||||
"""Associated with a ZeroMQ socket."""
|
"""Base class for receivers associated with a ZeroMQ socket."""
|
||||||
__metaclass__ = ABCMeta
|
__metaclass__ = ABCMeta
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -4,16 +4,27 @@ import zmq
|
|||||||
|
|
||||||
|
|
||||||
class SocketBase(object):
|
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
|
__metaclass__ = ABCMeta
|
||||||
|
|
||||||
name = None
|
name = None
|
||||||
socket = None
|
socket = None
|
||||||
|
|
||||||
def __init__(self, identifier):
|
def __init__(self, identifier):
|
||||||
"""
|
|
||||||
:param identifier: The identifier of the endpoint.
|
|
||||||
:type identifier: str
|
|
||||||
"""
|
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
self.port = None # Set later by `create_socket`
|
self.port = None # Set later by `create_socket`
|
||||||
self.socket = 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.
|
:param port: The port to use.
|
||||||
:type port: int
|
: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
|
:param options: A list of tuples where the first element contains the option and the second the value.
|
||||||
and the second the value, for example (zmq.CONFLATE, 1).
|
|
||||||
:type options: list[tuple[int, int]]
|
:type options: list[tuple[int, int]]
|
||||||
|
|
||||||
:param bind: Whether to bind the socket or connect to it.
|
:param bind: Whether to bind the socket or connect to it.
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ from robot_interface.state import state
|
|||||||
from robot_interface.core.config import settings
|
from robot_interface.core.config import settings
|
||||||
|
|
||||||
class VideoSender(SocketBase):
|
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):
|
def __init__(self, zmq_context, port=settings.agent_settings.video_sender_port):
|
||||||
super(VideoSender, self).__init__("video")
|
super(VideoSender, self).__init__("video")
|
||||||
self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)])
|
self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)])
|
||||||
@@ -14,6 +23,8 @@ class VideoSender(SocketBase):
|
|||||||
def start_video_rcv(self):
|
def start_video_rcv(self):
|
||||||
"""
|
"""
|
||||||
Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread.
|
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:
|
if not state.qi_session:
|
||||||
logging.info("No Qi session available. Not starting video loop.")
|
logging.info("No Qi session available. Not starting video loop.")
|
||||||
@@ -38,7 +49,7 @@ class VideoSender(SocketBase):
|
|||||||
:type vid_service: Object (Qi service object)
|
:type vid_service: Object (Qi service object)
|
||||||
|
|
||||||
:param vid_stream_name: The name of a camera subscription on the video service object vid_service
|
: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():
|
while not state.exit_event.is_set():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ def main_loop(context):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
def overtime_callback(time_ms):
|
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.",
|
logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.",
|
||||||
message["endpoint"], time_ms)
|
message["endpoint"], time_ms)
|
||||||
|
|
||||||
@@ -68,6 +75,12 @@ def main_loop(context):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
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()
|
context = zmq.Context()
|
||||||
|
|
||||||
state.initialize()
|
state.initialize()
|
||||||
|
|||||||
@@ -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
|
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.
|
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):
|
def __init__(self):
|
||||||
self.is_initialized = False
|
self.is_initialized = False
|
||||||
self.exit_event = None
|
self.exit_event = None
|
||||||
self.sockets = [] # type: List[SocketBase]
|
self.sockets = []
|
||||||
self.qi_session = None # type: None | ssl.SSLSession
|
self.qi_session = None
|
||||||
|
|
||||||
def initialize(self):
|
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:
|
if self.is_initialized:
|
||||||
logging.warn("Already initialized")
|
logging.warn("Already initialized")
|
||||||
return
|
return
|
||||||
@@ -36,6 +53,9 @@ class State(object):
|
|||||||
self.is_initialized = True
|
self.is_initialized = True
|
||||||
|
|
||||||
def deinitialize(self):
|
def deinitialize(self):
|
||||||
|
"""
|
||||||
|
Closes all sockets stored in the `sockets` list.
|
||||||
|
"""
|
||||||
if not self.is_initialized: return
|
if not self.is_initialized: return
|
||||||
|
|
||||||
for socket in self.sockets:
|
for socket in self.sockets:
|
||||||
@@ -44,7 +64,17 @@ class State(object):
|
|||||||
self.is_initialized = False
|
self.is_initialized = False
|
||||||
|
|
||||||
def __getattribute__(self, name):
|
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 (
|
if name in (
|
||||||
"initialize",
|
"initialize",
|
||||||
"deinitialize",
|
"deinitialize",
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
def get_qi_session():
|
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:
|
if qi is None:
|
||||||
logging.info("Unable to import qi. Running in stand-alone mode.")
|
logging.info("Unable to import qi. Running in stand-alone mode.")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ class TimeBlock(object):
|
|||||||
"""
|
"""
|
||||||
A context manager that times the execution of the block it contains. If execution exceeds the
|
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.
|
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,
|
: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.
|
unless the code block did not exceed the time limit.
|
||||||
:type callback: Callable[[float], None]
|
:type callback: Callable[[float], None]
|
||||||
@@ -16,16 +14,45 @@ class TimeBlock(object):
|
|||||||
exceeds this time, or if it's None, the callback function will be called with the time the
|
exceeds this time, or if it's None, the callback function will be called with the time the
|
||||||
block took.
|
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.limit_ms = float(limit_ms) if limit_ms is not None else None
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.start = None
|
self.start = None
|
||||||
|
|
||||||
def __enter__(self):
|
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()
|
self.start = time.time()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
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
|
elapsed = (time.time() - self.start) * 1000.0 # ms
|
||||||
if self.limit_ms is None or elapsed > self.limit_ms:
|
if self.limit_ms is None or elapsed > self.limit_ms:
|
||||||
self.callback(elapsed)
|
self.callback(elapsed)
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ class MicrophoneUtils(object):
|
|||||||
|
|
||||||
def test_choose_mic_default(self, pyaudio_instance):
|
def test_choose_mic_default(self, pyaudio_instance):
|
||||||
"""
|
"""
|
||||||
The result must contain at least "index", as this is used to identify the microphone.
|
Tests that the default microphone selection function returns a valid
|
||||||
The "name" is used for logging, so it should also exist.
|
microphone dictionary containing all necessary keys with correct types and values.
|
||||||
It must have one or more channels.
|
|
||||||
Lastly it must be capable of sending at least 16000 samples per second.
|
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)
|
result = choose_mic_default(pyaudio_instance)
|
||||||
assert "index" in result
|
assert "index" in result
|
||||||
@@ -39,7 +41,9 @@ class MicrophoneUtils(object):
|
|||||||
|
|
||||||
def test_choose_mic_interactive_input_not_int(self, pyaudio_instance, mocker):
|
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)
|
microphones = get_microphones(pyaudio_instance)
|
||||||
target_microphone = next(microphones)
|
target_microphone = next(microphones)
|
||||||
@@ -59,7 +63,8 @@ class MicrophoneUtils(object):
|
|||||||
|
|
||||||
def test_choose_mic_interactive_negative_index(self, pyaudio_instance, mocker):
|
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)
|
microphones = get_microphones(pyaudio_instance)
|
||||||
target_microphone = next(microphones)
|
target_microphone = next(microphones)
|
||||||
@@ -79,7 +84,8 @@ class MicrophoneUtils(object):
|
|||||||
|
|
||||||
def test_choose_mic_interactive_index_too_high(self, pyaudio_instance, mocker):
|
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)))
|
real_count = len(list(get_microphones(pyaudio_instance)))
|
||||||
mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"])
|
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):
|
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))
|
microphones = list(get_microphones(pyaudio_instance))
|
||||||
random_index = random.randrange(len(microphones))
|
random_index = random.randrange(len(microphones))
|
||||||
@@ -108,6 +116,9 @@ class MicrophoneUtils(object):
|
|||||||
assert result["index"] == microphones[random_index]["index"]
|
assert result["index"] == microphones[random_index]["index"]
|
||||||
|
|
||||||
def test_choose_mic_no_arguments(self, pyaudio_instance, mocker):
|
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", [])
|
mocker.patch.object(sys, "argv", [])
|
||||||
|
|
||||||
result = choose_mic_arguments(pyaudio_instance)
|
result = choose_mic_arguments(pyaudio_instance)
|
||||||
@@ -115,6 +126,10 @@ class MicrophoneUtils(object):
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_choose_mic_arguments(self, pyaudio_instance, mocker):
|
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):
|
for mic in get_microphones(pyaudio_instance):
|
||||||
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
|
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
|
||||||
|
|
||||||
@@ -124,6 +139,10 @@ class MicrophoneUtils(object):
|
|||||||
assert result == mic
|
assert result == mic
|
||||||
|
|
||||||
def test_choose_mic_arguments_eq(self, pyaudio_instance, mocker):
|
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):
|
for mic in get_microphones(pyaudio_instance):
|
||||||
mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])])
|
mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])])
|
||||||
|
|
||||||
@@ -132,7 +151,11 @@ class MicrophoneUtils(object):
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result == mic
|
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"])
|
mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"])
|
||||||
|
|
||||||
result = choose_mic_arguments(pyaudio_instance)
|
result = choose_mic_arguments(pyaudio_instance)
|
||||||
@@ -140,6 +163,10 @@ class MicrophoneUtils(object):
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_choose_mic_with_argument(self, pyaudio_instance, mocker):
|
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))
|
mic = next(get_microphones(pyaudio_instance))
|
||||||
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
|
mocker.patch.object(sys, "argv", ["--microphone", mic["name"]])
|
||||||
|
|
||||||
@@ -149,6 +176,11 @@ class MicrophoneUtils(object):
|
|||||||
assert result == mic
|
assert result == mic
|
||||||
|
|
||||||
def test_choose_mic_no_argument(self, pyaudio_instance, mocker):
|
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)
|
default_mic = choose_mic_default(pyaudio_instance)
|
||||||
mocker.patch.object(sys, "argv", [])
|
mocker.patch.object(sys, "argv", [])
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ from common.microphone_utils import MicrophoneUtils
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def pyaudio_instance():
|
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()
|
audio = pyaudio.PyAudio()
|
||||||
try:
|
try:
|
||||||
audio.get_default_input_device_info()
|
audio.get_default_input_device_info()
|
||||||
|
|||||||
@@ -9,11 +9,21 @@ from robot_interface.endpoints.actuation_receiver import ActuationReceiver
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def zmq_context():
|
def zmq_context():
|
||||||
|
"""
|
||||||
|
A pytest fixture that creates and yields a ZMQ context.
|
||||||
|
|
||||||
|
:return: An initialized ZeroMQ context.
|
||||||
|
:rtype: zmq.Context
|
||||||
|
"""
|
||||||
context = zmq.Context()
|
context = zmq.Context()
|
||||||
yield context
|
yield context
|
||||||
|
|
||||||
|
|
||||||
def test_handle_unimplemented_endpoint(zmq_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)
|
receiver = ActuationReceiver(zmq_context)
|
||||||
# Should not error
|
# Should not error
|
||||||
receiver.handle_message({
|
receiver.handle_message({
|
||||||
@@ -23,6 +33,10 @@ def test_handle_unimplemented_endpoint(zmq_context):
|
|||||||
|
|
||||||
|
|
||||||
def test_speech_message_no_data(zmq_context, mocker):
|
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")
|
mock_warn = mocker.patch("logging.warn")
|
||||||
|
|
||||||
receiver = ActuationReceiver(zmq_context)
|
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):
|
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")
|
mock_warn = mocker.patch("logging.warn")
|
||||||
|
|
||||||
receiver = ActuationReceiver(zmq_context)
|
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):
|
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_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
|
||||||
|
|
||||||
mock_qi_session = mock.PropertyMock(return_value=None)
|
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):
|
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_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
|
||||||
|
|
||||||
mock_qi = mock.Mock()
|
mock_qi = mock.Mock()
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ from robot_interface.endpoints.audio_sender import AudioSender
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def zmq_context():
|
def zmq_context():
|
||||||
|
"""
|
||||||
|
A pytest fixture that creates and yields a ZMQ context.
|
||||||
|
|
||||||
|
:return: An initialized ZeroMQ context.
|
||||||
|
:rtype: zmq.Context
|
||||||
|
"""
|
||||||
context = zmq.Context()
|
context = zmq.Context()
|
||||||
yield context
|
yield context
|
||||||
|
|
||||||
|
|
||||||
def test_no_microphone(zmq_context, mocker):
|
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_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 = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
|
||||||
mock_choose_mic.return_value = None
|
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):
|
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")
|
mocker.patch("robot_interface.endpoints.audio_sender.threading")
|
||||||
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
|
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
|
||||||
mock_choose_mic.return_value = {"name": u"• Some Unicode name"}
|
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):
|
def _fake_read(num_frames):
|
||||||
|
"""
|
||||||
|
Helper function to simulate reading raw audio data from a microphone stream.
|
||||||
|
"""
|
||||||
return os.urandom(num_frames * 4)
|
return os.urandom(num_frames * 4)
|
||||||
|
|
||||||
|
|
||||||
def test_sending_audio(mocker):
|
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 = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
|
||||||
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
|
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):
|
def _fake_read_error(num_frames):
|
||||||
|
"""
|
||||||
|
Helper function to simulate an I/O error during microphone stream reading.
|
||||||
|
"""
|
||||||
raise IOError()
|
raise IOError()
|
||||||
|
|
||||||
|
|
||||||
def test_break_microphone(mocker):
|
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 = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
|
||||||
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
|
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ from robot_interface.endpoints.main_receiver import MainReceiver
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def zmq_context():
|
def zmq_context():
|
||||||
|
"""
|
||||||
|
A pytest fixture that creates and yields a ZMQ context.
|
||||||
|
|
||||||
|
:return: An initialized ZeroMQ context.
|
||||||
|
:rtype: zmq.Context
|
||||||
|
"""
|
||||||
context = zmq.Context()
|
context = zmq.Context()
|
||||||
yield context
|
yield context
|
||||||
|
|
||||||
|
|
||||||
def test_handle_ping(zmq_context):
|
def test_handle_ping(zmq_context):
|
||||||
|
"""
|
||||||
|
Tests the receiver's ability to handle the "ping" endpoint with data.
|
||||||
|
"""
|
||||||
receiver = MainReceiver(zmq_context)
|
receiver = MainReceiver(zmq_context)
|
||||||
response = receiver.handle_message({"endpoint": "ping", "data": "pong"})
|
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):
|
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)
|
receiver = MainReceiver(zmq_context)
|
||||||
response = receiver.handle_message({"endpoint": "ping", "data": None})
|
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")
|
@mock.patch("robot_interface.endpoints.main_receiver.state")
|
||||||
def test_handle_negotiate_ports(mock_state, zmq_context):
|
def test_handle_negotiate_ports(mock_state, zmq_context):
|
||||||
|
"""
|
||||||
|
Tests the handling of the "negotiate/ports" endpoint.
|
||||||
|
"""
|
||||||
receiver = MainReceiver(zmq_context)
|
receiver = MainReceiver(zmq_context)
|
||||||
mock_state.sockets = [receiver]
|
mock_state.sockets = [receiver]
|
||||||
|
|
||||||
@@ -54,6 +70,10 @@ def test_handle_negotiate_ports(mock_state, zmq_context):
|
|||||||
|
|
||||||
|
|
||||||
def test_handle_unimplemented_endpoint(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)
|
receiver = MainReceiver(zmq_context)
|
||||||
response = receiver.handle_message({
|
response = receiver.handle_message({
|
||||||
"endpoint": "some_endpoint_that_definitely_does_not_exist",
|
"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):
|
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)
|
receiver = MainReceiver(zmq_context)
|
||||||
response = receiver.handle_message({
|
response = receiver.handle_message({
|
||||||
"endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist",
|
"endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist",
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ from robot_interface.utils.microphone import choose_mic_default, choose_mic_inte
|
|||||||
|
|
||||||
|
|
||||||
class MockPyAudio:
|
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):
|
def __init__(self):
|
||||||
# You can predefine fake device info here
|
# You can predefine fake device info here
|
||||||
self.devices = [
|
self.devices = [
|
||||||
@@ -37,18 +47,36 @@ class MockPyAudio:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_device_count(self):
|
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)
|
return len(self.devices)
|
||||||
|
|
||||||
def get_device_info_by_index(self, index):
|
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):
|
if 0 <= index < len(self.devices):
|
||||||
return self.devices[index]
|
return self.devices[index]
|
||||||
else:
|
else:
|
||||||
raise IOError("Invalid device index: {}".format(index))
|
raise IOError("Invalid device index: {}".format(index))
|
||||||
|
|
||||||
def get_default_input_device_info(self):
|
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:
|
for device in self.devices:
|
||||||
if device.get("maxInputChannels", 0) > 0:
|
if device.get("maxInputChannels", 0) > 0:
|
||||||
return device
|
return device
|
||||||
@@ -57,16 +85,32 @@ class MockPyAudio:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def pyaudio_instance():
|
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()
|
return MockPyAudio()
|
||||||
|
|
||||||
|
|
||||||
def _raise_io_error():
|
def _raise_io_error():
|
||||||
|
"""
|
||||||
|
Helper function used to mock PyAudio methods that are expected to fail
|
||||||
|
when no device is available.
|
||||||
|
"""
|
||||||
raise IOError()
|
raise IOError()
|
||||||
|
|
||||||
|
|
||||||
class TestAudioUnit(MicrophoneUtils):
|
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):
|
def test_choose_mic_default_no_mic(self):
|
||||||
|
"""
|
||||||
|
Tests `choose_mic_default` when no microphones are available.
|
||||||
|
"""
|
||||||
mock_pyaudio = mock.Mock()
|
mock_pyaudio = mock.Mock()
|
||||||
mock_pyaudio.get_device_count = mock.Mock(return_value=0L)
|
mock_pyaudio.get_device_count = mock.Mock(return_value=0L)
|
||||||
mock_pyaudio.get_default_input_device_info = _raise_io_error
|
mock_pyaudio.get_default_input_device_info = _raise_io_error
|
||||||
@@ -76,6 +120,9 @@ class TestAudioUnit(MicrophoneUtils):
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_choose_mic_interactive_no_mic(self):
|
def test_choose_mic_interactive_no_mic(self):
|
||||||
|
"""
|
||||||
|
Tests `choose_mic_interactive` when no microphones are available.
|
||||||
|
"""
|
||||||
mock_pyaudio = mock.Mock()
|
mock_pyaudio = mock.Mock()
|
||||||
mock_pyaudio.get_device_count = mock.Mock(return_value=0L)
|
mock_pyaudio.get_device_count = mock.Mock(return_value=0L)
|
||||||
mock_pyaudio.get_default_input_device_info = _raise_io_error
|
mock_pyaudio.get_default_input_device_info = _raise_io_error
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ from robot_interface.utils.timeblock import TimeBlock
|
|||||||
|
|
||||||
|
|
||||||
class AnyFloat(object):
|
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):
|
def __eq__(self, other):
|
||||||
return isinstance(other, float)
|
return isinstance(other, float)
|
||||||
|
|
||||||
|
|
||||||
def test_no_limit():
|
def test_no_limit():
|
||||||
|
"""
|
||||||
|
Tests the scenario where the `TimeBlock` context manager is used without
|
||||||
|
a time limit.
|
||||||
|
"""
|
||||||
callback = mock.Mock()
|
callback = mock.Mock()
|
||||||
|
|
||||||
with TimeBlock(callback):
|
with TimeBlock(callback):
|
||||||
@@ -20,6 +30,10 @@ def test_no_limit():
|
|||||||
|
|
||||||
|
|
||||||
def test_exceed_limit():
|
def test_exceed_limit():
|
||||||
|
"""
|
||||||
|
Tests the scenario where the execution time within the `TimeBlock`
|
||||||
|
exceeds the provided limit.
|
||||||
|
"""
|
||||||
callback = mock.Mock()
|
callback = mock.Mock()
|
||||||
|
|
||||||
with TimeBlock(callback, 0):
|
with TimeBlock(callback, 0):
|
||||||
@@ -29,6 +43,10 @@ def test_exceed_limit():
|
|||||||
|
|
||||||
|
|
||||||
def test_within_limit():
|
def test_within_limit():
|
||||||
|
"""
|
||||||
|
Tests the scenario where the execution time within the `TimeBlock`
|
||||||
|
stays within the provided limit.
|
||||||
|
"""
|
||||||
callback = mock.Mock()
|
callback = mock.Mock()
|
||||||
|
|
||||||
with TimeBlock(callback, 5):
|
with TimeBlock(callback, 5):
|
||||||
|
|||||||
Reference in New Issue
Block a user