8 Commits

Author SHA1 Message Date
Storm
c52ea38f4a Merge branch 'main' into feat/pause-functionality 2026-01-30 17:30:05 +01:00
Storm
f1cc55efec refactor: refactored video_sender to send image as width, height and raw image bytes]
ref: N25B-393
2026-01-29 11:57:48 +01:00
Storm
e157eafc91 chore: mid-bugfixing 2026-01-28 12:42:27 +01:00
Storm
9a631066c0 chore: removed change 2026-01-20 12:52:27 +01:00
Storm
fb717ec488 Merge branch 'dev' into feat/pause-functionality 2026-01-20 12:50:34 +01:00
Storm
096fc1389f chore: fixed sending Pepper imagery 2026-01-20 12:44:16 +01:00
Storm
7afdea8dbc test: removed logging assertion from test
ref: N25B-350
2025-12-22 13:43:01 +01:00
Storm
358c4f6872 feat: pause RI
Pause functionality in RI implemented. The audio_sender and video_sender stop sending when paused.

ref: N25B-350
2025-12-22 13:36:18 +01:00
14 changed files with 514 additions and 426 deletions

View File

@@ -6,9 +6,6 @@
# The hostname or IP address of the Control Backend. # The hostname or IP address of the Control Backend.
AGENT__CONTROL_BACKEND_HOST=localhost AGENT__CONTROL_BACKEND_HOST=localhost
# Whether to use Pepper's microphone when Pepper is connected.
AUDIO__USE_PEPPER_MICROPHONE=true
# Variables that are unlikely to be configured, you can probably ignore these: # Variables that are unlikely to be configured, you can probably ignore these:

View File

@@ -7,5 +7,3 @@ sphinx
sphinx_rtd_theme sphinx_rtd_theme
pre-commit pre-commit
python-dotenv python-dotenv
numpy<=1.16.6
enum34

View File

@@ -68,7 +68,7 @@ class VideoConfig(object):
): ):
self.camera_index = get_config(camera_index, "VIDEO__CAMERA_INDEX", 0, int) self.camera_index = get_config(camera_index, "VIDEO__CAMERA_INDEX", 0, int)
self.resolution = get_config(resolution, "VIDEO__RESOLUTION", 2, int) self.resolution = get_config(resolution, "VIDEO__RESOLUTION", 2, int)
self.color_space = get_config(color_space, "VIDEO__COLOR_SPACE", 13, int) self.color_space = get_config(color_space, "VIDEO__COLOR_SPACE", 11, int)
self.fps = get_config(fps, "VIDEO__FPS", 15, int) self.fps = get_config(fps, "VIDEO__FPS", 15, int)
self.stream_name = get_config(stream_name, "VIDEO__STREAM_NAME", "Pepper Video") self.stream_name = get_config(stream_name, "VIDEO__STREAM_NAME", "Pepper Video")
self.image_buffer = get_config(image_buffer, "VIDEO__IMAGE_BUFFER", 6, int) self.image_buffer = get_config(image_buffer, "VIDEO__IMAGE_BUFFER", 6, int)
@@ -78,8 +78,6 @@ class AudioConfig(object):
""" """
Audio configuration constants. Audio configuration constants.
:ivar use_pepper_microphone: Whether to use Pepper's microphone or not, defaults to True.
:vartype use_pepper_microphone: bool
:ivar sample_rate: Audio sampling rate in Hz, defaults to 16000. :ivar sample_rate: Audio sampling rate in Hz, defaults to 16000.
:vartype sample_rate: int :vartype sample_rate: int
:ivar chunk_size: Size of audio chunks to capture/process, defaults to 512. :ivar chunk_size: Size of audio chunks to capture/process, defaults to 512.
@@ -87,14 +85,7 @@ class AudioConfig(object):
:ivar channels: Number of audio channels, defaults to 1. :ivar channels: Number of audio channels, defaults to 1.
:vartype channels: int :vartype channels: int
""" """
def __init__( def __init__(self, sample_rate=None, chunk_size=None, channels=None):
self,
use_pepper_microphone=None,
sample_rate=None,
chunk_size=None,
channels=None,
):
self.use_pepper_microphone = get_config(use_pepper_microphone, "AUDIO__USE_PEPPER_MICROPHONE", True, bool)
self.sample_rate = get_config(sample_rate, "AUDIO__SAMPLE_RATE", 16000, int) self.sample_rate = get_config(sample_rate, "AUDIO__SAMPLE_RATE", 16000, int)
self.chunk_size = get_config(chunk_size, "AUDIO__CHUNK_SIZE", 512, int) self.chunk_size = get_config(chunk_size, "AUDIO__CHUNK_SIZE", 512, int)
self.channels = get_config(channels, "AUDIO__CHANNELS", 1, int) self.channels = get_config(channels, "AUDIO__CHANNELS", 1, int)

View File

@@ -6,16 +6,9 @@ University within the Software Project course.
""" """
from __future__ import unicode_literals # So that `logging` can use Unicode characters in names from __future__ import unicode_literals # So that `logging` can use Unicode characters in names
import audioop
import enum
from abc import ABCMeta, abstractmethod
import threading import threading
import logging import logging
import Queue
import numpy as np
import pyaudio import pyaudio
import zmq import zmq
@@ -27,219 +20,97 @@ from robot_interface.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AudioCapturer(object): class AudioSender(SocketBase):
""" """
Interface for audio capturers. Audio sender endpoint, responsible for sending microphone audio data.
"""
__metaclass__ = ABCMeta
@abstractmethod :param zmq_context: The ZeroMQ context to use.
def setup(self): :type zmq_context: zmq.Context
raise NotImplementedError()
@abstractmethod :param port: The port to use.
def stop(self): :type port: int
raise NotImplementedError()
@abstractmethod :ivar thread: Thread used for sending audio.
def generate_chunk(self): :vartype thread: threading.Thread | None
raise NotImplementedError()
class SampleRate(enum.Enum):
"""
Sample rate to use in Hz.
"""
LOW = 16000
HIGH = 48000
class PepperMicrophone(enum.Enum):
"""
Which of Pepper's microphones to use. In our case, some of the mics were damages/didn't work
well, so we choose to only use the fron right. If you have a Pepper robot with all working mics,
you might wish to use all microphones, to improve overall audio quality.
"""
ALL = 0
LEFT = 1
RIGHT = 2
FRONT_LEFT = 3
FRONT_RIGHT = 4
class QiAudioCapturer(AudioCapturer):
# Some of this class' methods have docstrings as binary strings. Keep them that way, otherwise
# ``qi.Session.registerService`` will give RuntimeErrors.
def __init__(self, sample_rate=SampleRate.LOW, mic=PepperMicrophone.FRONT_RIGHT,
deinterleaved=0):
"""
:raises RuntimeError: If there is no Qi session available.
:raises ValueError: If the given arguments are not compatible.
"""
self.session = state.qi_session
if not self.session:
raise RuntimeError("Cannot capture from qi device, no qi session available.")
if sample_rate == SampleRate.HIGH and mic != PepperMicrophone.ALL:
raise RuntimeError("For 48000 Hz, you must select all microphones.")
if mic == PepperMicrophone.ALL and sample_rate != SampleRate.HIGH:
raise RuntimeError("For using all microphones, 48000 Hz is required.")
self.audio = self.session.service("ALAudioDevice")
self.service_name = "ZmqAudioStreamer"
self.sample_rate = sample_rate
self.mic = mic
self.deinterleaved = deinterleaved
self.overflow = np.empty(0, dtype=np.float32)
self.q = Queue.Queue()
self._rate_state = None
def setup(self):
b"""
:raises RuntimeError: If no Qi session is available or if the session is not compatible with audio streaming.
"""
assert self.session is not None
logger.info("Listening with Pepper's microphone.")
self.session.registerService(self.service_name, self)
self.audio.setClientPreferences(self.service_name, self.sample_rate.value, self.mic.value,
self.deinterleaved)
self.audio.subscribe(self.service_name)
def stop(self):
b"""
Stop the audio capturer.
"""
try:
self.audio.unsubscribe(self.service_name)
except:
pass
def generate_chunk(self):
try:
chunk = self.q.get(True, 0.1)
return chunk
except Queue.Empty:
return None
# Callback invoked by NAOqi
def processRemote(self, nbOfChannels, nbOfSamplesByChannel, timeStamp, inputBuffer):
raw_pcm = bytes(inputBuffer)
pcm_i16 = np.frombuffer(raw_pcm, dtype=np.int16)
# Make mono channel (if it was 4 channels)
pcm_i32_mono = self._make_mono(pcm_i16.astype(np.int32), nbOfChannels)
# Resample (if it was 48k)
pcm_i32_mono_16k, self._rate_state = audioop.ratecv(pcm_i32_mono.tobytes(), 4, 1,
self.sample_rate.value,
SampleRate.LOW.value, self._rate_state)
pcm_f32_mono_16k = (np.frombuffer(pcm_i32_mono_16k, dtype=np.int32).astype(np.float32) /
32768.0)
# Attach overflow
pcm_f32_mono_16k = np.append(self.overflow, pcm_f32_mono_16k)
for i in range(len(pcm_f32_mono_16k) // 512):
self.q.put_nowait(pcm_f32_mono_16k[i * 512 : (i + 1) * 512].tobytes())
self.overflow = pcm_f32_mono_16k[len(pcm_f32_mono_16k) // 512 * 512 :]
def _make_mono(self, frag, channels):
return frag.reshape(-1, channels).mean(axis=1, dtype=np.int32)
class StandaloneAudioCapturer(AudioCapturer):
"""
Audio capturer that uses a microphone from the local device, can be chosen with the
``--microphone`` program argument.
:ivar audio: PyAudio instance. :ivar audio: PyAudio instance.
:vartype audio: pyaudio.PyAudio | None :vartype audio: pyaudio.PyAudio | None
:ivar microphone: Selected microphone information. :ivar microphone: Selected microphone information.
:vartype microphone: dict | None :vartype microphone: dict | None
:ivar stream: PyAudio stream instance. None until ``setup()`` is called, remaining None if setup
fails for any reason.
:vartype stream: pyaudio.Stream | None
""" """
def __init__(self): def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port):
self.stream = None super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str
self.create_socket(zmq_context, zmq.PUB, port)
self.thread = None
try: try:
self.audio = pyaudio.PyAudio() self.audio = pyaudio.PyAudio()
self.microphone = choose_mic(self.audio) self.microphone = choose_mic(self.audio)
except IOError as e: except IOError as e:
logger.warning("PyAudio is not available. Won't be able to send audio.", exc_info=True) logger.warning("PyAudio is not available.", exc_info=e)
self.audio = None self.audio = None
self.microphone = None self.microphone = None
def setup(self): def start(self):
""" """
Setup audio stream. Will not if no microphone is available. 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.")
return return
logger.info("Listening with microphone \"{}\".".format(self.microphone["name"])) logger.info("Listening with microphone \"{}\".".format(self.microphone["name"]))
self.stream = self.audio.open( self.thread = threading.Thread(target=self._stream)
format=pyaudio.paFloat32,
channels=settings.audio_config.channels,
rate=settings.audio_config.sample_rate,
input=True,
input_device_index=self.microphone["index"],
frames_per_buffer=settings.audio_config.chunk_size,
)
def stop(self):
"""
Close the audio stream.
"""
if not self.stream: return
self.stream.stop_stream()
self.stream.close()
def generate_chunk(self):
"""
:return: Audio frames from the microphone of size ``settings.audio_config.chunk_size``.
:rtype: bytes.
:raises IOError: If reading from the audio stream fails.
"""
return self.stream.read(settings.audio_config.chunk_size)
class AudioSender(SocketBase):
def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port):
super(AudioSender, self).__init__(str("audio"))
self.create_socket(zmq_context, zmq.PUB, port)
self.thread = threading.Thread(target=self.stream)
self.capturer = self.choose_capturer()
def start(self):
self.capturer.setup()
self.thread.start() self.thread.start()
def close(self): def wait_until_done(self):
self.capturer.stop() """
super(AudioSender, self).close() 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
# Docs say this only raises an error if neither `input` nor `output` is True
def open_stream():
return self.audio.open(
format=pyaudio.paFloat32,
channels=audio_settings.channels,
rate=audio_settings.sample_rate,
input=True,
input_device_index=self.microphone["index"],
frames_per_buffer=chunk,
)
stream = None
try:
# Test in case exit_event was set while waiting
if not state.exit_event.is_set():
stream = open_stream()
def stream(self):
while not state.exit_event.is_set(): while not state.exit_event.is_set():
chunk = self.capturer.generate_chunk() data = stream.read(chunk)
if (state.is_speaking): continue # Do not send audio while the robot is speaking
if chunk is None or state.is_speaking: self.socket.send(data)
continue except IOError as e:
logger.error("Stopped listening: failed to get audio from microphone.", exc_info=e)
self.socket.send(chunk) finally:
if stream:
def choose_capturer(self): try:
if state.qi_session and settings.audio_config.use_pepper_microphone: stream.stop_stream()
return QiAudioCapturer() stream.close()
except IOError:
return StandaloneAudioCapturer() pass # Ignore errors on closing

View File

@@ -79,6 +79,30 @@ 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."}
@staticmethod
def _handle_pause(message):
"""
Handle a pause request. Pauses or resumes the video and audio streams.
:param message: The pause request message.
:type message: dict
:return: A response dictionary indicating success.
:rtype: dict[str, str]
"""
if message.get("data"):
if state.active_event.is_set():
state.active_event.clear()
return {"endpoint": "pause", "data": "Streams paused."}
else:
return {"endpoint": "pause", "data": "Streams are already paused."}
else:
if not state.active_event.is_set():
state.active_event.set()
return {"endpoint": "pause", "data": "Streams resumed."}
else:
return {"endpoint": "pause", "data": "Streams are already running."}
def handle_message(self, message): def handle_message(self, message):
""" """
Main entry point for handling incoming messages. Main entry point for handling incoming messages.
@@ -95,5 +119,7 @@ class MainReceiver(ReceiverBase):
return self._handle_ping(message) return self._handle_ping(message)
elif message["endpoint"].startswith("negotiate"): elif message["endpoint"].startswith("negotiate"):
return self._handle_negotiation(message) return self._handle_negotiation(message)
elif message["endpoint"] == "pause":
return self._handle_pause(message)
return {"endpoint": "error", "data": "The requested endpoint is not supported."} return {"endpoint": "error", "data": "The requested endpoint is not supported."}

View File

@@ -4,12 +4,13 @@ This program has been developed by students from the bachelor Computer Science a
University within the Software Project course. University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences) © Copyright Utrecht University (Department of Information and Computing Sciences)
""" """
import struct
import zmq import zmq
import threading import threading
import logging import logging
import numpy as np
import struct import cv2
from robot_interface.endpoints.socket_base import SocketBase from robot_interface.endpoints.socket_base import SocketBase
from robot_interface.state import state from robot_interface.state import state
@@ -38,6 +39,9 @@ class VideoSender(SocketBase):
""" """
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.")
logging.info("Starting test video stream from local webcam.")
thread = threading.Thread(target=self.test_video_stream)
thread.start()
return return
video = state.qi_session.service("ALVideoDevice") video = state.qi_session.service("ALVideoDevice")
@@ -51,9 +55,40 @@ class VideoSender(SocketBase):
thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name)) thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name))
thread.start() thread.start()
def test_video_stream(self):
"""
Test function to send video from a local webcam instead of the robot.
"""
cap = cv2.VideoCapture(0)
if not cap.isOpened():
logging.error("Could not open webcam for video stream test.")
return
while not state.exit_event.is_set():
ret, frame = cap.read()
if not ret:
logging.warning("Failed to read frame from webcam.")
continue
if cv2.waitKey(1) & 0xFF == ord('q'): # << Add this: Updates the window
break
height, width, channels = frame.shape
pixel_data = frame.tobytes()
width_bytes = struct.pack('<I', width)
height_bytes = struct.pack('<I', height)
self.socket.send_multipart([width_bytes, height_bytes, pixel_data])
cap.release()
def video_rcv_loop(self, vid_service, vid_stream_name): def video_rcv_loop(self, vid_service, vid_stream_name):
""" """
The main loop of retrieving video images from the robot. The main loop of retrieving video images from the robot.
Sends the image data over the ZMQ socket in 3 parts: image width, image height and raw image bytes.
:param vid_service: The video service object that the active Qi session is connected to. :param vid_service: The video service object that the active Qi session is connected to.
:type vid_service: Object (Qi service object) :type vid_service: Object (Qi service object)
@@ -66,18 +101,18 @@ class VideoSender(SocketBase):
try: try:
img = vid_service.getImageRemote(vid_stream_name) img = vid_service.getImageRemote(vid_stream_name)
if img is not None: if img is not None:
raw_data = img[6] image_bytes = img[6]
width = img[0] width = img[0]
height = img[1] height = img[1]
width_bytes = struct.pack('<I', width) width_bytes = struct.pack('<I', width)
height_bytes = struct.pack('<I', height) height_bytes = struct.pack('<I', height)
self.socket.send_multipart([width_bytes, height_bytes, raw_data]) self.socket.send_multipart([width_bytes, height_bytes, image_bytes])
except KeyboardInterrupt:
logging.info("Video receiving loop interrupted by user.")
except: except:
logging.warn("Failed to retrieve video image from robot.") logging.warn("Failed to retrieve video image from robot.")
except KeyboardInterrupt:
logging.info("Video receiving loop interrupted by user.")
finally: finally:
vid_service.unsubscribe(vid_stream_name) vid_service.unsubscribe(vid_stream_name)
logging.info("Unsubscribed from video stream.") logging.info("Unsubscribed from video stream.")

View File

@@ -91,6 +91,7 @@ def main():
context = zmq.Context() context = zmq.Context()
state.initialize() state.initialize()
state.active_event.set()
try: try:
main_loop(context) main_loop(context)

View File

@@ -56,6 +56,8 @@ class State(object):
signal.signal(signal.SIGINT, handle_exit) signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit) signal.signal(signal.SIGTERM, handle_exit)
self.active_event = threading.Event()
self.qi_session = get_qi_session() self.qi_session = get_qi_session()
self.is_initialized = True self.is_initialized = True

View File

@@ -16,8 +16,6 @@ def get_config(value, env, default, cast=None):
Small utility to get a configuration value, returns `value` if it is not None, else it will try to get the Small utility to get a configuration value, returns `value` if it is not None, else it will try to get the
environment variable cast with `cast`. If the environment variable is not set, it will return `default`. environment variable cast with `cast`. If the environment variable is not set, it will return `default`.
Special handling for booleans, which are only true if the value of the variable is "true" or "yes", ignoring capitalization.
:param value: The value to check. :param value: The value to check.
:type value: Any :type value: Any
:param env: The environment variable to check. :param env: The environment variable to check.
@@ -35,14 +33,7 @@ def get_config(value, env, default, cast=None):
env = os.environ.get(env, default) env = os.environ.get(env, default)
if cast is None or env is None: if cast is None:
return env return env
if cast == bool:
if isinstance(env, bool):
return env
if not isinstance(default, bool):
raise ValueError("Default value must be a boolean if the cast type is a boolean.")
return env.lower() == "true" or env.lower() == "yes"
return cast(env) return cast(env)

View File

@@ -14,20 +14,6 @@ except ImportError:
qi = None qi = None
def _get_qi_url():
"""
Get the Qi URL from the command line arguments, or None if not given.
"""
if "--qi-url" in sys.argv:
return sys.argv[sys.argv.index("--qi-url") + 1]
for arg in sys.argv:
if arg.startswith("--qi-url="):
return arg[len("--qi-url="):]
return None
def get_qi_session(): def get_qi_session():
""" """
Create and return a Qi session if available. Create and return a Qi session if available.
@@ -39,13 +25,12 @@ def get_qi_session():
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
qi_url = _get_qi_url() if "--qi-url" not in sys.argv:
if qi_url is None:
logging.info("No Qi URL argument given. Running in stand-alone mode.") logging.info("No Qi URL argument given. Running in stand-alone mode.")
return None return None
try: try:
app = qi.Application(["--qi-url", qi_url, "--qi-listen-url", "tcp://0.0.0.0:0"]) app = qi.Application()
app.start() app.start()
return app.session return app.session
except RuntimeError: except RuntimeError:

View File

@@ -33,14 +33,14 @@ def test_no_microphone(zmq_context, mocker):
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
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
sender = AudioSender(zmq_context) sender = AudioSender(zmq_context)
assert sender.capturer.microphone is None assert sender.microphone is None
sender.capturer.setup() sender.start()
mock_info_logger.assert_called() assert sender.thread is None
sender.wait_until_done() # Should return early because we didn't start a thread
def test_unicode_mic_name(zmq_context, mocker): def test_unicode_mic_name(zmq_context, mocker):
@@ -48,18 +48,19 @@ def test_unicode_mic_name(zmq_context, mocker):
Tests the robustness of the `AudioSender` when handling microphone names Tests the robustness of the `AudioSender` when handling microphone names
that contain Unicode characters. that contain Unicode characters.
""" """
mocker.patch("robot_interface.endpoints.audio_sender.threading")
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"• Some Unicode name", "index": 0L} mock_choose_mic.return_value = {"name": u"• Some Unicode name"}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
sender = AudioSender(zmq_context) sender = AudioSender(zmq_context)
assert sender.capturer.microphone is not None assert sender.microphone is not None
sender.capturer.audio.open = mock.Mock(return_value=mock.Mock())
# `.setup()` logs the name of the microphone. It should not give an error if it contains Unicode # `.start()` logs the name of the microphone. It should not give an error if it contains Unicode
# symbols. # symbols.
sender.capturer.setup() sender.start()
assert sender.thread is not None
sender.wait_until_done() # Should return instantly because we didn't start a real thread
def _fake_read(num_frames): def _fake_read(num_frames):
@@ -77,8 +78,7 @@ def test_sending_audio(mocker):
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock() mock_zmq_context = mock.Mock()
send_socket = mock.Mock() send_socket = mock.Mock()
@@ -90,14 +90,225 @@ def test_sending_audio(mocker):
sender = AudioSender(mock_zmq_context) sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket sender.socket.send = send_socket
sender.capturer.audio.open = mock.Mock() sender.audio.open = mock.Mock()
sender.capturer.audio.open.return_value = stream sender.audio.open.return_value = stream
sender.start() sender.start()
sender.thread.join() sender.wait_until_done()
send_socket.assert_called() send_socket.assert_called()
# SENDING PAUSE RESUME?
def test_stream_initial_wait_exit(mocker):
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.exit_event.is_set.return_value = True
mock_state.active_event.is_set.return_value = False
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender._stream()
mock_pyaudio_instance.open.assert_not_called()
def test_stream_pause_and_resume(mocker):
mock_stream = mock.Mock()
mock_stream.read.return_value = b"data"
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = False
mock_state.exit_event.is_set.side_effect = [False, False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
assert mock_pyaudio_instance.open.call_count == 2
assert mock_stream.close.call_count == 2
assert mock_stream.stop_stream.call_count == 2
assert mock_state.active_event.wait.called
def test_stream_exit_during_pause(mocker):
mock_stream = mock.Mock()
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = False
mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender._stream()
assert mock_pyaudio_instance.open.call_count == 1
assert mock_stream.close.call_count == 1
def test_stream_read_error_recovery(mocker):
stream_fail = mock.Mock()
stream_fail.read.side_effect = IOError("Overflow")
stream_ok = mock.Mock()
stream_ok.read.return_value = b"data"
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.side_effect = [stream_fail, stream_ok]
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.side_effect = [False, False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
stream_fail.close.assert_called()
assert mock_pyaudio_instance.open.call_count == 2
sender.socket.send.assert_called_with(b"data")
def test_stream_fatal_error(mocker):
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.side_effect = IOError("Fatal error")
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.return_value = False
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender._stream()
def test_wait_until_done(mocker):
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.wait_until_done()
mock_thread = mocker.Mock()
sender.thread = mock_thread
sender.wait_until_done()
mock_thread.join.assert_called_once()
assert sender.thread is None
def test_stream_pause_close_error(mocker):
"""
Tests that an IOError during stream closure (when pausing) is ignored,
covering the 'pass' statement in the pause logic.
"""
mock_stream = mock.Mock()
mock_stream.read.return_value = b"data"
# Raise IOError when stopping the stream during pause
mock_stream.stop_stream.side_effect = IOError("Failed to stop")
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
# 1. First False triggers the pause block
# 2. Second True resumes the loop
mock_state.active_event.is_set.side_effect = [False, True]
mock_state.exit_event.is_set.side_effect = [False, False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
# Verification: The error should be swallowed, and the stream should re-open
assert mock_stream.stop_stream.called
assert mock_pyaudio_instance.open.call_count == 2
def test_stream_finally_close_error(mocker):
"""
Tests that an IOError during stream closure in the finally block is ignored,
covering the 'pass' statement in the finally logic.
"""
mock_stream = mock.Mock()
mock_stream.read.return_value = b"data"
# Raise IOError when stopping the stream at exit
mock_stream.stop_stream.side_effect = IOError("Cleanup failed")
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
mock_pyaudio_instance.open.return_value = mock_stream
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
# Run
sender._stream()
# Assert: Should finish without raising exception despite the IOError in finally
assert mock_stream.stop_stream.called
def test_stream_recovery_failure(mocker):
"""
Tests the case where recovering from a read error (re-opening stream) also fails.
This ensures the outer try-except catches exceptions from the inner except block.
"""
mock_stream_initial = mock.Mock()
# Trigger the read error logic
mock_stream_initial.read.side_effect = IOError("Read failed")
mock_pyaudio_cls = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio.PyAudio")
mock_pyaudio_instance = mock_pyaudio_cls.return_value
# First open works, Second open (recovery) fails fatally
mock_pyaudio_instance.open.side_effect = [
mock_stream_initial,
IOError("Recovery failed")
]
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.active_event.is_set.return_value = True
mock_state.exit_event.is_set.return_value = False
mock_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger")
mock_zmq_context = mock.Mock()
sender = AudioSender(mock_zmq_context)
sender.socket = mock.Mock()
sender._stream()
# Assert we hit the outer error log
mock_logger.error.assert_called()
def test_no_sending_if_speaking(mocker): def test_no_sending_if_speaking(mocker):
""" """
@@ -107,7 +318,6 @@ def test_no_sending_if_speaking(mocker):
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
mock_state.exit_event.is_set.side_effect = [False, True] mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock() mock_zmq_context = mock.Mock()
@@ -121,11 +331,11 @@ def test_no_sending_if_speaking(mocker):
sender = AudioSender(mock_zmq_context) sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket sender.socket.send = send_socket
sender.capturer.audio.open = mock.Mock() sender.audio.open = mock.Mock()
sender.capturer.audio.open.return_value = stream sender.audio.open.return_value = stream
sender.start() sender.start()
sender.thread.join() sender.wait_until_done()
send_socket.assert_not_called() send_socket.assert_not_called()
@@ -145,7 +355,6 @@ def test_break_microphone(mocker):
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
mock_state.exit_event.is_set.side_effect = [False, True] mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock() mock_zmq_context = mock.Mock()
@@ -157,11 +366,11 @@ def test_break_microphone(mocker):
sender = AudioSender(mock_zmq_context) sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket sender.socket.send = send_socket
sender.capturer.audio.open = mock.Mock() sender.audio.open = mock.Mock()
sender.capturer.audio.open.return_value = stream sender.audio.open.return_value = stream
sender.start() sender.start()
sender.thread.join() sender.wait_until_done()
send_socket.assert_not_called() send_socket.assert_not_called()
@@ -172,8 +381,6 @@ def test_pyaudio_init_failure(mocker, zmq_context):
""" """
# Prevent binding the ZMQ socket # Prevent binding the ZMQ socket
mocker.patch("robot_interface.endpoints.audio_sender.AudioSender.create_socket") mocker.patch("robot_interface.endpoints.audio_sender.AudioSender.create_socket")
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.qi_session = None
# Simulate PyAudio() failing # Simulate PyAudio() failing
mocker.patch( mocker.patch(
@@ -183,5 +390,5 @@ def test_pyaudio_init_failure(mocker, zmq_context):
sender = AudioSender(zmq_context) sender = AudioSender(zmq_context)
assert sender.capturer.audio is None assert sender.audio is None
assert sender.capturer.microphone is None assert sender.microphone is None

View File

@@ -50,58 +50,3 @@ def test_get_config_casts_default_when_env_missing(monkeypatch):
result = get_config(None, "GET_CONFIG_MISSING", "42", int) result = get_config(None, "GET_CONFIG_MISSING", "42", int)
assert result == 42 assert result == 42
def test_get_config_unset_boolean_default(monkeypatch):
"""
When the env var is a boolean, and it's not set, ensure it uses the default value.
"""
monkeypatch.delenv("SOME_BOOLEAN_VARIABLE", raising=False)
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == False
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == True
def test_get_config_true_boolean(monkeypatch):
"""
When the env var is a boolean, and its value is "true", "TRUE", "yes", etc., it should return true.
"""
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "TRUE")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "true")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "yes")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "YES")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "TrUE")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", False, bool)
assert result == True
def test_get_config_false_boolean(monkeypatch):
"""
When the env var is a boolean, and its value is not "true", "TRUE", "yes", etc., it should return False.
"""
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "FALSE")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == False
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "false")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == False
monkeypatch.setenv("SOME_BOOLEAN_VARIABLE", "anything, tbh")
result = get_config(None, "SOME_BOOLEAN_VARIABLE", True, bool)
assert result == False

View File

@@ -62,7 +62,7 @@ def test_get_qi_session_runtime_error(monkeypatch):
raise RuntimeError("boom") raise RuntimeError("boom")
class FakeQi: class FakeQi:
Application = lambda *args, **kwargs: FakeApp() Application = lambda self=None: FakeApp()
reload_qi_utils_with(FakeQi()) reload_qi_utils_with(FakeQi())
@@ -87,7 +87,7 @@ def test_get_qi_session_success(monkeypatch):
return True return True
class FakeQi: class FakeQi:
Application = lambda *args, **kwargs: FakeApp() Application = lambda self=None: FakeApp()
reload_qi_utils_with(FakeQi()) reload_qi_utils_with(FakeQi())

View File

@@ -5,128 +5,167 @@ University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences) © Copyright Utrecht University (Department of Information and Computing Sciences)
""" """
import struct
import mock import mock
import pytest import pytest
import zmq import zmq
from robot_interface.endpoints.video_sender import VideoSender from robot_interface.endpoints.video_sender import VideoSender
from robot_interface.state import state
from robot_interface.core.config import settings
@pytest.fixture @pytest.fixture
def zmq_context(): def zmq_context():
"""Provide a ZMQ context.""" """
yield zmq.Context() Yields a real ZMQ context for socket creation.
"""
context = zmq.Context()
yield context
context.term()
def test_init_defaults(zmq_context, mocker):
"""
Test initialization of the VideoSender.
"""
# We patch settings to ensure valid port access inside the class logic,
# although the default arg is evaluated at import time.
mocker.patch("robot_interface.endpoints.video_sender.settings")
def _patch_basics(mocker): mock_zmq = mock.Mock()
"""Common patches: prevent real threads, port binds, and state errors.""" sender = VideoSender(mock_zmq)
mocker.patch("robot_interface.endpoints.socket_base.zmq.Socket.bind")
mocker.patch("robot_interface.endpoints.video_sender.threading.Thread")
mocker.patch.object(state, "is_initialized", True)
# Verify socket type is PUB
assert sender.identifier == "video"
def _patch_exit_event(mocker): def test_start_no_qi_session(mocker):
"""Make exit_event stop the loop after one iteration.""" """
fake_event = mock.Mock() Test that the loop does not start if no Qi session is available.
fake_event.is_set.side_effect = [False, True] """
mocker.patch.object(state, "exit_event", fake_event) # Mock state to return None for qi_session
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
mock_state.qi_session = None
mock_threading = mocker.patch("robot_interface.endpoints.video_sender.threading")
def test_no_qi_session(zmq_context, mocker): mock_zmq = mock.Mock()
"""Video loop should not start without a qi_session.""" sender = VideoSender(mock_zmq)
_patch_basics(mocker)
mocker.patch.object(state, "qi_session", None)
sender = VideoSender(zmq_context)
sender.start_video_rcv() sender.start_video_rcv()
assert not hasattr(sender, "thread") # Assertions
mock_threading.Thread.assert_not_called()
def test_start_success(mocker):
"""
Test successful startup of the video receiver thread.
"""
# Mock the Qi Session and Service
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
mock_session = mock.Mock()
mock_state.qi_session = mock_session
def test_video_streaming(zmq_context, mocker):
"""VideoSender should send retrieved image data."""
_patch_basics(mocker)
_patch_exit_event(mocker)
# Pepper's image buffer lives at index 6
mocker.patch.object(settings.video_config, "image_buffer", 6)
test_width = 320
test_height = 240
mock_video_service = mock.Mock() mock_video_service = mock.Mock()
mock_video_service.getImageRemote.return_value = [test_width, test_height, None, None, None, None, b"fake_img"] mock_session.service.return_value = mock_video_service
mock_video_service.subscribeCamera.return_value = "test_subscriber_id"
fake_session = mock.Mock() # Mock Settings
fake_session.service.return_value = mock_video_service mock_settings = mocker.patch("robot_interface.endpoints.video_sender.settings")
mocker.patch.object(state, "qi_session", fake_session) mock_settings.video_config.camera_index = 0
mock_settings.video_config.resolution = 2
mock_settings.video_config.color_space = 11
mock_settings.video_config.fps = 30
mock_settings.video_config.stream_name = "test_stream"
mocker.patch.object( mock_threading = mocker.patch("robot_interface.endpoints.video_sender.threading")
fake_session.service("ALVideoDevice"),
"subscribeCamera",
return_value="stream_name"
)
sender = VideoSender(zmq_context)
send_socket = mock.Mock()
sender.socket.send_multipart = send_socket
# Run
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
sender.start_video_rcv() sender.start_video_rcv()
sender.video_rcv_loop(mock_video_service, "stream_name")
send_socket.assert_called_with([ # Assertions
struct.pack('<I', 320), mock_session.service.assert_called_with("ALVideoDevice")
struct.pack('<I', 240), mock_video_service.subscribeCamera.assert_called_with("test_stream", 0, 2, 11, 30)
b"fake_img"
])
mock_threading.Thread.assert_called_once()
# Verify arguments passed to the thread target
call_args = mock_threading.Thread.call_args[1]
assert call_args["target"] == sender.video_rcv_loop
assert call_args["args"] == (mock_video_service, "test_subscriber_id")
def test_video_receive_error(zmq_context, mocker): # Ensure thread was started
"""Errors retrieving images should not call send().""" mock_threading.Thread.return_value.start.assert_called_once()
_patch_basics(mocker)
_patch_exit_event(mocker)
mock_video_service = mock.Mock() def test_video_loop_happy_path(mocker):
mock_video_service.getImageRemote.side_effect = Exception("boom") """
Test the main loop: Wait -> Get Image -> Send -> Repeat/Exit.
"""
# Mock settings for image buffer index
mock_settings = mocker.patch("robot_interface.endpoints.video_sender.settings")
mock_settings.video_config.image_buffer = 6
fake_session = mock.Mock() # Mock Video Service to return a fake image structure
fake_session.service.return_value = mock_video_service # Standard NaoQi image is a list, binary data is usually at index 6
mocker.patch.object(state, "qi_session", fake_session) fake_image_data = b"binary_jpeg_data"
fake_image_list = [0] * 7
fake_image_list[6] = fake_image_data
mocker.patch.object( mock_service = mock.Mock()
fake_session.service("ALVideoDevice"), mock_service.getImageRemote.return_value = fake_image_list
"subscribeCamera",
return_value="stream_name"
)
sender = VideoSender(zmq_context) # Mock Events:
send_socket = mock.Mock() # exit_event: False (start), False (loop once), True (break)
sender.socket.send_multipart = send_socket mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
mock_state.exit_event.is_set.side_effect = [False, False, True]
sender.start_video_rcv() # Run
sender.video_rcv_loop(mock_video_service, "stream_name") mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
sender.socket = mock.Mock() # Mock the socket to verify send
send_socket.assert_not_called() sender.video_rcv_loop(mock_service, "sub_id")
def test_video_loop_keyboard_interrupt(zmq_context, mocker): # Assertions
"""Video loop should handle KeyboardInterrupt gracefully and unsubscribe.""" mock_state.active_event.wait.assert_called()
_patch_basics(mocker) mock_service.getImageRemote.assert_called_with("sub_id")
_patch_exit_event(mocker) sender.socket.send.assert_called_with(fake_image_data)
# We mock the video service to raise KeyboardInterrupt when accessed def test_video_loop_exit_during_wait(zmq_context, mocker):
mock_video_service = mock.Mock() """
mock_video_service.getImageRemote.side_effect = KeyboardInterrupt Test that the loop breaks immediately if exit_event is set while waiting.
"""
mock_service = mock.Mock()
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
# Mock logging to verify the specific interrupt message is logged # 1. Loop check: False (enter loop)
mock_logger = mocker.patch("robot_interface.endpoints.video_sender.logging") # 2. Wait happens (mock returns instantly)
# 3. Post-wait check: True (break)
mock_state.exit_event.is_set.side_effect = [False, True]
sender = VideoSender(zmq_context) mock_zqm = mock.Mock()
sender = VideoSender(mock_zqm)
sender.video_rcv_loop(mock_service, "sub_id")
# Execute the loop # Assert we never tried to get an image
sender.video_rcv_loop(mock_video_service, "stream_name") mock_service.getImageRemote.assert_not_called()
# Verify the 'finally' block executed (unsubscribe) def test_video_loop_exception_handling(zmq_context, mocker):
mock_video_service.unsubscribe.assert_called_with("stream_name") """
mock_logger.info.assert_any_call("Unsubscribed from video stream.") Test that exceptions during image retrieval are caught and logged,
and do not crash the thread.
"""
mock_settings = mocker.patch("robot_interface.endpoints.video_sender.settings")
mock_service = mock.Mock()
# First call raises Exception, Second call works (if we allowed it, but we exit)
mock_service.getImageRemote.side_effect = Exception("Camera disconnected")
mock_state = mocker.patch("robot_interface.endpoints.video_sender.state")
# Loop runs once then exits
mock_state.exit_event.is_set.side_effect = [False, False, True]
mock_zmq = mock.Mock()
sender = VideoSender(mock_zmq)
sender.socket = mock.Mock()
sender.video_rcv_loop(mock_service, "sub_id")
# Assertions
# Ensure loop didn't crash; it should have completed the iteration and checked exit_event
assert mock_state.exit_event.is_set.call_count >= 2