15 Commits

Author SHA1 Message Date
JobvAlewijk
e129113c9e chore: forgot copyright 2026-01-29 17:53:57 +01:00
JobvAlewijk
18a4bde4ca test: added tests and docstrings
ref: N25B-397
2026-01-29 17:51:39 +01:00
Twirre Meulenbelt
815fc7bcde feat: publish face detection instead of req/res
ref: N25B-395
2026-01-29 17:18:39 +01:00
JobvAlewijk
3bbe97579d Merge branch 'main' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ri into feat/face-detection 2026-01-29 16:22:42 +01:00
ad58b16559 Merge branch 'dev' into 'main'
Merge dev with main

See merge request ics/sp/2025/n25b/pepperplus-ri!27
2026-01-28 10:54:22 +00:00
fb0d7850cc Merge branch 'main' into dev 2026-01-28 11:53:23 +01:00
JobvAlewijk
4afceccf46 feat: fixed connection !!! 2026-01-19 16:58:01 +01:00
JobvAlewijk
83099a2810 chore: modified into req reply socket on 5559 2026-01-17 14:01:32 +01:00
JobvAlewijk
4e9afbaaf5 Merge branch 'dev' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ri into feat/face-detection 2026-01-16 16:48:51 +01:00
Pim Hutting
da97eb8a1a Merge branch 'feat/robot-speech-agent-force-speech' into 'dev'
feat: implemented forced speech and speech queue

See merge request ics/sp/2025/n25b/pepperplus-ri!23
2026-01-14 14:26:39 +00:00
Luijkx,S.O.H. (Storm)
e51cf8fe65 feat: implemented forced speech and speech queue 2026-01-14 14:26:38 +00:00
JobvAlewijk
49386ef8cd feat: communicate face to CB
Had to do some weird socket stuff

ref: N25B-397
2026-01-12 14:25:10 +01:00
JobvAlewijk
3b470c8f29 feat: fully working face detection
ref: N25B-397
2026-01-07 17:56:21 +01:00
JobvAlewijk
b8f71f6bee feat: base face detection
ref: N25B-397
2026-01-04 18:56:04 +01:00
aad2044b6e chore: add .gitignore 2025-09-27 17:58:12 +02:00
13 changed files with 636 additions and 33 deletions

View File

@@ -17,6 +17,10 @@ class AgentSettings(object):
:vartype video_sender_port: int
:ivar audio_sender_port: Port used for sending audio data, defaults to 5558.
:vartype audio_sender_port: int
:ivar face_detection_port: Port used for sending face detection events, defaults to 5559.
:vartype face_detection_port: int
:ivar face_detection_interval: Time between face detection events, defaults to 1000 ms.
:vartype face_detection_interval: int
"""
def __init__(
self,
@@ -25,12 +29,16 @@ class AgentSettings(object):
main_receiver_port=None,
video_sender_port=None,
audio_sender_port=None,
face_detection_port=None,
face_detection_interval=None,
):
self.control_backend_host = get_config(control_backend_host, "AGENT__CONTROL_BACKEND_HOST", "localhost")
self.actuation_receiver_port = get_config(actuation_receiver_port, "AGENT__ACTUATION_RECEIVER_PORT", 5557, int)
self.main_receiver_port = get_config(main_receiver_port, "AGENT__MAIN_RECEIVER_PORT", 5555, int)
self.video_sender_port = get_config(video_sender_port, "AGENT__VIDEO_SENDER_PORT", 5556, int)
self.audio_sender_port = get_config(audio_sender_port, "AGENT__AUDIO_SENDER_PORT", 5558, int)
self.face_detection_port = get_config(face_detection_port, "AGENT__FACE_DETECTION_PORT", 5559, int)
self.face_detection_interval = get_config(face_detection_interval, "AGENT__FACE_DETECTION_INTERVAL", 1000, int)
class VideoConfig(object):

View File

@@ -1,11 +1,12 @@
from __future__ import unicode_literals # So that we can log texts with Unicode characters
import logging
from threading import Thread
import Queue
import zmq
from robot_interface.endpoints.receiver_base import ReceiverBase
from robot_interface.state import state
from robot_interface.core.config import settings
from robot_interface.endpoints.gesture_settings import GestureTags
@@ -32,6 +33,9 @@ class ActuationReceiver(ReceiverBase):
self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options
self._tts_service = None
self._animation_service = None
self._message_queue = Queue.Queue()
self.message_thread = Thread(target=self._handle_messages)
self.message_thread.start()
def _handle_speech(self, message):
"""
@@ -58,8 +62,26 @@ class ActuationReceiver(ReceiverBase):
if not self._tts_service:
self._tts_service = state.qi_session.service("ALTextToSpeech")
# Returns instantly. Messages received while speaking will be queued.
getattr(qi, "async")(self._tts_service.say, text)
if message.get("is_priority"):
# Bypass queue and speak immediately
self.clear_queue()
self._message_queue.put(text)
logging.debug("Force speaking immediately: {}".format(text))
else:
self._message_queue.put(text)
def clear_queue(self):
"""
Safely drains all pending messages from the queue.
"""
logging.info("Message queue size: {}".format(self._message_queue.qsize()))
try:
while True:
# Remove items one by one without waiting
self._message_queue.get_nowait()
except Queue.Empty:
pass
logging.info("Message queue cleared.")
def _handle_gesture(self, message, is_single):
"""
@@ -122,6 +144,19 @@ class ActuationReceiver(ReceiverBase):
if message["endpoint"] == "actuate/gesture/single":
self._handle_gesture(message, True)
def _handle_messages(self):
while not state.exit_event.is_set():
try:
text = self._message_queue.get(timeout=0.1)
state.is_speaking = True
self._tts_service.say(text)
except Queue.Empty:
state.is_speaking = False
except RuntimeError:
logging.error("Lost connection to Pepper. Please check if you're connected to the "
"local WiFi and restart this application.")
state.exit_event.set()
def endpoint_description(self):
"""
Extend the default endpoint description with gesture tags.

View File

@@ -89,6 +89,7 @@ class AudioSender(SocketBase):
try:
while not state.exit_event.is_set():
data = stream.read(chunk)
if (state.is_speaking): continue # Do not send audio while the robot is speaking
self.socket.send(data)
except IOError as e:
logger.error("Stopped listening: failed to get audio from microphone.", exc_info=e)

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""
This program has been developed by students from the bachelor Computer Science at Utrecht
University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences)
"""
from __future__ import unicode_literals
import json
import logging
import threading
import time
import zmq
from robot_interface.endpoints.socket_base import SocketBase
from robot_interface.state import state
from robot_interface.core.config import settings
class FaceDetectionSender(SocketBase):
"""
Face detection endpoint.
Subscribes to and polls ALMemory["FaceDetected"], sends events to CB.
"""
def __init__(self, zmq_context, port=settings.agent_settings.face_detection_port):
super(FaceDetectionSender, self).__init__("face")
self.create_socket(zmq_context, zmq.PUB, port)
self._face_service = None
self._memory_service = None
self._face_thread = None
def start_face_detection(self):
if not state.qi_session:
logging.warning("No Qi session available. Face detection not started.")
return
self._face_service = state.qi_session.service("ALFaceDetection")
self._memory_service = state.qi_session.service("ALMemory")
self._face_service.setTrackingEnabled(False)
self._face_service.setRecognitionEnabled(False)
self._face_service.subscribe(
"FaceDetectionSender",
settings.agent_settings.face_detection_interval,
0.0,
)
self._face_thread = threading.Thread(target=self._face_loop)
self._face_thread.start()
logging.info("Face detection started.")
def _face_loop(self):
"""
Continuously send face detected to the CB, at the interval set in the
``start_face_detection`` method.
"""
while not state.exit_event.is_set():
try:
value = self._memory_service.getData("FaceDetected", 0)
face_present = (
value
and len(value) > 1
and value[1]
and value[1][0]
and len(value[1][0]) > 0
)
self.socket.send(json.dumps({"face_detected": face_present}).encode("utf-8"))
except Exception:
logging.exception("Error reading FaceDetected")
time.sleep(settings.agent_settings.face_detection_interval / 1000.0)
def stop_face_detection(self):
try:
if self._face_service:
self._face_service.unsubscribe("FaceDetectionSender")
self._face_service.setTrackingEnabled(False)
logging.info("Face detection stopped.")
except Exception:
logging.warning("Error during face detection cleanup.")
def close(self):
super(FaceDetectionSender, self).close()
self.stop_face_detection()

View File

@@ -4,6 +4,7 @@ from robot_interface.endpoints.receiver_base import ReceiverBase
from robot_interface.state import state
from robot_interface.core.config import settings
from robot_interface.endpoints.face_detector import FaceDetectionSender
class MainReceiver(ReceiverBase):
@@ -37,6 +38,7 @@ class MainReceiver(ReceiverBase):
"""
return {"endpoint": "ping", "data": message.get("data")}
@staticmethod
def _handle_port_negotiation(message):
"""

View File

@@ -6,6 +6,7 @@ from robot_interface.endpoints.socket_base import SocketBase
from robot_interface.state import state
from robot_interface.core.config import settings
class VideoSender(SocketBase):
"""
Video sender endpoint, responsible for sending video frames.

View File

@@ -12,6 +12,8 @@ from robot_interface.endpoints.video_sender import VideoSender
from robot_interface.state import state
from robot_interface.core.config import settings
from robot_interface.utils.timeblock import TimeBlock
from robot_interface.endpoints.face_detector import FaceDetectionSender
def main_loop(context):
@@ -35,6 +37,12 @@ def main_loop(context):
video_sender.start_video_rcv()
audio_sender.start()
# --- Face detection sender ---
face_sender = FaceDetectionSender(context)
state.sockets.append(face_sender)
face_sender.start_face_detection()
# Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most).
receivers = [main_receiver, actuation_receiver]

View File

@@ -30,6 +30,7 @@ class State(object):
self.exit_event = None
self.sockets = []
self.qi_session = None
self.is_speaking = False
def initialize(self):
"""

10
test/conftest.py Normal file
View File

@@ -0,0 +1,10 @@
from mock import patch, MagicMock
import pytest
@pytest.fixture(autouse=True)
def mock_zmq_context():
with patch("zmq.Context") as mock:
mock.instance.return_value = MagicMock()
yield mock

View File

@@ -20,46 +20,109 @@ def zmq_context():
yield context
def test_handle_unimplemented_endpoint(zmq_context):
def test_force_speech_clears_queue(mocker):
"""
Tests that the ``ActuationReceiver.handle_message`` method can
handle an unknown or unimplemented endpoint without raising an error.
Tests that a force speech message clears the existing queue
and places the high-priority message at the front.
"""
receiver = ActuationReceiver(zmq_context)
# Should not error
mocker.patch("threading.Thread")
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
mock_qi = mock.Mock()
sys.modules["qi"] = mock_qi
mock_tts_service = mock.Mock()
mock_state.qi_session.service.return_value = mock_tts_service
# Use Mock Context
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
receiver._message_queue.put("old_message_1")
receiver._message_queue.put("old_message_2")
assert receiver._message_queue.qsize() == 2
force_msg = {
"endpoint": "actuate/speech",
"data": "Emergency Notification",
"is_priority": True,
}
receiver.handle_message(force_msg)
assert receiver._message_queue.qsize() == 1
queued_item = receiver._message_queue.get()
assert queued_item == "Emergency Notification"
def test_handle_unimplemented_endpoint(mocker):
"""
Tests handling of unknown endpoints.
"""
mocker.patch("threading.Thread")
# Use Mock Context
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
receiver.handle_message({
"endpoint": "some_endpoint_that_definitely_does_not_exist",
"data": None,
})
def test_speech_message_no_data(zmq_context, mocker):
def test_speech_message_no_data(mocker):
"""
Tests that the message handler logs a warning when a speech actuation
request (`actuate/speech`) is received but contains empty string data.
Tests that if the message data is empty, the receiver returns immediately
WITHOUT attempting to access the global robot state or session.
"""
mock_warn = mocker.patch("logging.warn")
# 1. Prevent background threads from running
mocker.patch("threading.Thread")
# 2. Mock the global state object
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
receiver = ActuationReceiver(zmq_context)
# 3. Create a PropertyMock to track whenever 'qi_session' is accessed
# We attach it to the class type of the mock so it acts like a real property
mock_session_prop = mock.PropertyMock(return_value=None)
type(mock_state).qi_session = mock_session_prop
# 4. Initialize Receiver (Mocking the context to avoid ZMQ errors)
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
# 5. Send empty data
receiver.handle_message({"endpoint": "actuate/speech", "data": ""})
mock_warn.assert_called_with(mock.ANY)
# 6. Assertion:
# Because the code does `if not text: return` BEFORE `if not state.qi_session`,
# the state property should NEVER be read.
mock_session_prop.assert_not_called()
def test_speech_message_invalid_data(zmq_context, mocker):
def test_speech_message_invalid_data(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).
Tests that if the message data is not a string, the function returns.
:param mocker: Description
"""
mock_warn = mocker.patch("logging.warn")
mocker.patch("threading.Thread")
receiver = ActuationReceiver(zmq_context)
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
mock_session_prop = mock.PropertyMock(return_value=None)
type(mock_state).qi_session = mock_session_prop
# Use Mock Context
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
receiver.handle_message({"endpoint": "actuate/speech", "data": True})
mock_warn.assert_called_with(mock.ANY)
# Because the code does `if not text: return` BEFORE `if not state.qi_session`,
# the state property should NEVER be read.
mock_session_prop.assert_not_called()
def test_speech_no_qi(zmq_context, mocker):
def test_speech_no_qi(mocker):
"""
Tests the actuation receiver's behavior when processing a speech request
but the global state does not have an active QI session.
@@ -69,16 +132,21 @@ def test_speech_no_qi(zmq_context, mocker):
mock_qi_session = mock.PropertyMock(return_value=None)
type(mock_state).qi_session = mock_qi_session
receiver = ActuationReceiver(zmq_context)
mock_tts_service = mock.Mock()
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
receiver._tts_service = mock_tts_service
receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."})
mock_qi_session.assert_called()
receiver._tts_service.assert_not_called()
def test_speech(zmq_context, mocker):
def test_speech(mocker):
"""
Tests the core speech actuation functionality by mocking the QI TextToSpeech
service and verifying that it is called correctly.
service and verifying that the received message is put into the queue.
"""
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
@@ -89,17 +157,182 @@ def test_speech(zmq_context, mocker):
mock_state.qi_session = mock.Mock()
mock_state.qi_session.service.return_value = mock_tts_service
receiver = ActuationReceiver(zmq_context)
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
receiver._tts_service = None
receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."})
mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech")
assert receiver._message_queue.qsize() == 1
getattr(mock_qi, "async").assert_called_once()
call_args = getattr(mock_qi, "async").call_args[0]
assert call_args[0] == mock_tts_service.say
assert call_args[1] == "Some message to speak."
queued_item = receiver._message_queue.get()
assert queued_item == "Some message to speak."
def test_speech_priority(mocker):
"""
Tests that a priority speech message is handled correctly by clearing the queue
and placing the priority message at the front.
"""
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
mock_qi = mock.Mock()
sys.modules["qi"] = mock_qi
mock_tts_service = mock.Mock()
mock_state.qi_session = mock.Mock()
mock_state.qi_session.service.return_value = mock_tts_service
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
receiver._message_queue.put("old_message_1")
receiver._message_queue.put("old_message_2")
assert receiver._message_queue.qsize() == 2
priority_msg = {
"endpoint": "actuate/speech",
"data": "Urgent Message",
"is_priority": True,
}
receiver._handle_speech(priority_msg)
assert receiver._message_queue.qsize() == 1
queued_item = receiver._message_queue.get()
assert queued_item == "Urgent Message"
def test_handle_messages_loop(mocker):
"""
Tests the background consumer loop (_handle_messages) processing an item.
Runs SYNCHRONOUSLY to ensure coverage tools pick up the lines.
"""
# Patch Thread so the real background thread NEVER starts automatically
mocker.patch("threading.Thread")
# Mock state
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
# Setup initial speaking state to False (covers "Started speaking" print)
mock_state.is_speaking = False
# Mock the TextToSpeech service
mock_tts_service = mock.Mock()
mock_state.qi_session.service.return_value = mock_tts_service
# Initialize receiver (Thread is patched, so no thread starts)
# Use Mock Context to avoid ZMQ errors
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
# Manually inject service (since lazy loading might handle it, but this is safer)
receiver._tts_service = mock_tts_service
# This ensures the while loop iterates exactly once
mock_state.exit_event.is_set.side_effect = [False, True]
# Put an item in the queue
receiver._message_queue.put("Hello World")
# RUN MANUALLY in the main thread
# This executes the code: while -> try -> get -> if print -> speaking=True -> say
receiver._handle_messages()
# Assertions
assert receiver._message_queue.empty()
mock_tts_service.say.assert_called_with("Hello World")
assert mock_state.is_speaking is True
def test_handle_messages_queue_empty(mocker):
"""
Tests the Queue.Empty exception handler in the consumer loop.
This covers the logic that resets 'state.is_speaking' to False.
"""
# Prevent the real background thread from starting
mocker.patch("threading.Thread")
# Mock the state object
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
# Setup 'is_speaking' property mock
# We set return_value=True so the code enters the 'if state.is_speaking:' block.
# We use PropertyMock to track when this attribute is set.
type(mock_state).is_speaking = True
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
# This ensures the while loop body runs exactly once for our test
mock_state.exit_event.is_set.side_effect = [False, True]
# Force get() to raise Queue.Empty immediately (simulate timeout)
# We patch the 'get' method on the specific queue instance of our receiver
#mocker.patch.object(receiver._message_queue, 'get', side_effect=Queue.Empty)
# Run the loop logic manually (synchronously)
receiver._handle_messages()
# Final Assertion: Verify is_speaking was set to False
# The code execution order is: read (returns True) -> print -> set (to False)
# assert_called_with checks the arguments of the LAST call, which is the setter.
assert mock_state.is_speaking is False
def test_handle_messages_runtime_error(mocker):
"""
Tests the RuntimeError exception handler (e.g. lost WiFi connection).
Uses a Mock ZMQ context to avoid 'Address already in use' errors.
"""
# Patch Thread so we don't accidentally spawn real threads
mocker.patch("threading.Thread")
# Mock the state and logging
mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state")
# Use a MOCK ZMQ context.
# This prevents the receiver from trying to bind to a real TCP port.
mock_zmq_ctx = mock.Mock()
# Initialize receiver with the mock context
receiver = ActuationReceiver(mock_zmq_ctx)
mock_state.exit_event.is_set.side_effect = [False, True]
receiver._message_queue.put("Test Message")
# Setup: ...BUT the service raises RuntimeError when asked to speak
mock_tts = mock.Mock()
mock_tts.say.side_effect = RuntimeError("Connection lost")
receiver._tts_service = mock_tts
# Run the loop logic manually
receiver._handle_messages()
# Assertions
assert mock_state.exit_event.is_set.called
def test_clear_queue(mocker):
"""
Tests that the clear_queue method properly drains all items from the message queue.
"""
mocker.patch("threading.Thread")
# Use Mock Context
mock_zmq_ctx = mock.Mock()
receiver = ActuationReceiver(mock_zmq_ctx)
# Populate the queue with multiple items
receiver._message_queue.put("msg1")
receiver._message_queue.put("msg2")
receiver._message_queue.put("msg3")
assert receiver._message_queue.qsize() == 3
# Clear the queue
receiver.clear_queue()
# Assert the queue is empty
assert receiver._message_queue.qsize() == 0
def test_gesture_no_data(zmq_context, mocker):
receiver = ActuationReceiver(zmq_context)

View File

@@ -77,7 +77,8 @@ def test_sending_audio(mocker):
mock_zmq_context = mock.Mock()
send_socket = mock.Mock()
mock_state.is_speaking = False
# If there's something wrong with the microphone, it will raise an IOError when `read`ing.
stream = mock.Mock()
stream.read = _fake_read
@@ -93,6 +94,36 @@ def test_sending_audio(mocker):
send_socket.assert_called()
def test_no_sending_if_speaking(mocker):
"""
Tests the successful sending of audio data over a ZeroMQ socket.
"""
mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic")
mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L}
mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state")
mock_state.exit_event.is_set.side_effect = [False, True]
mock_zmq_context = mock.Mock()
send_socket = mock.Mock()
mock_state.is_speaking = True
# If there's something wrong with the microphone, it will raise an IOError when `read`ing.
stream = mock.Mock()
stream.read = _fake_read
sender = AudioSender(mock_zmq_context)
sender.socket.send = send_socket
sender.audio.open = mock.Mock()
sender.audio.open.return_value = stream
sender.start()
sender.wait_until_done()
send_socket.assert_not_called()
def _fake_read_error(num_frames):
"""
Helper function to simulate an I/O error during microphone stream reading.

View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""
This program has been developed by students from the bachelor Computer Science at Utrecht
University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences)
"""
from __future__ import unicode_literals
import json
import mock
import pytest
from robot_interface.endpoints.face_detector import FaceDetectionSender
from robot_interface.state import state
@pytest.fixture(autouse=True)
def initialized_state(monkeypatch):
"""
Fully initialize global state so __getattribute__ allows access.
"""
# Bypass the initialization guard
monkeypatch.setattr(state, "is_initialized", True, raising=False)
# Install a controllable exit_event
exit_event = mock.Mock()
exit_event.is_set = mock.Mock(return_value=True)
monkeypatch.setattr(state, "exit_event", exit_event, raising=False)
# Default qi_session is None unless overridden
monkeypatch.setattr(state, "qi_session", None, raising=False)
yield
def test_start_face_detection_no_qi_session():
"""
Returns early when qi_session is None.
"""
sender = FaceDetectionSender(mock.Mock())
sender.start_face_detection()
assert sender._face_thread is None
assert sender._face_service is None
assert sender._memory_service is None
def test_start_face_detection_happy_path(mocker):
"""
Initializes services and starts background thread.
"""
mock_face = mock.Mock()
mock_memory = mock.Mock()
mock_qi = mock.Mock()
mock_qi.service.side_effect = lambda name: {
"ALFaceDetection": mock_face,
"ALMemory": mock_memory,
}[name]
state.qi_session = mock_qi
fake_thread = mock.Mock()
mocker.patch("threading.Thread", return_value=fake_thread)
sender = FaceDetectionSender(mock.Mock())
sender.start_face_detection()
mock_face.setTrackingEnabled.assert_called_with(False)
mock_face.setRecognitionEnabled.assert_called_with(False)
mock_face.subscribe.assert_called_once()
fake_thread.start.assert_called_once()
def test_face_loop_face_detected_true(mocker):
"""
Sends face_detected=True when face data exists.
"""
sender = FaceDetectionSender(mock.Mock())
sender._memory_service = mock.Mock()
sender._memory_service.getData.return_value = [0, [[1]]]
sender.socket = mock.Mock()
mocker.patch("time.sleep")
state.exit_event.is_set.side_effect = [False, True]
sender._face_loop()
sent = sender.socket.send.call_args[0][0]
payload = json.loads(sent.decode("utf-8"))
assert payload["face_detected"] is True
def test_face_loop_face_detected_false(mocker):
"""
Sends face_detected=False when no face data exists.
"""
sender = FaceDetectionSender(mock.Mock())
sender._memory_service = mock.Mock()
sender._memory_service.getData.return_value = []
sender.socket = mock.Mock()
mocker.patch("time.sleep")
state.exit_event.is_set.side_effect = [False, True]
sender._face_loop()
sent = sender.socket.send.call_args[0][0]
payload = json.loads(sent.decode("utf-8"))
assert not payload["face_detected"]
def test_face_loop_handles_exception(mocker):
"""
Exceptions inside loop are swallowed.
"""
sender = FaceDetectionSender(mock.Mock())
sender._memory_service = mock.Mock()
sender._memory_service.getData.side_effect = Exception("boom")
sender.socket = mock.Mock()
mocker.patch("time.sleep")
state.exit_event.is_set.side_effect = [False, True]
# Must not raise
sender._face_loop()
def test_stop_face_detection_happy_path():
"""
Unsubscribes and disables tracking.
"""
sender = FaceDetectionSender(mock.Mock())
mock_face = mock.Mock()
sender._face_service = mock_face
sender.stop_face_detection()
mock_face.unsubscribe.assert_called_once()
mock_face.setTrackingEnabled.assert_called_with(False)
def test_stop_face_detection_exception():
"""
stop_face_detection swallows service exceptions.
"""
sender = FaceDetectionSender(mock.Mock())
mock_face = mock.Mock()
mock_face.unsubscribe.side_effect = Exception("fail")
sender._face_service = mock_face
sender.stop_face_detection()
def test_close_calls_stop_face_detection(mocker):
"""
close() calls parent close and stop_face_detection().
"""
sender = FaceDetectionSender(mock.Mock())
mocker.patch.object(sender, "stop_face_detection")
mocker.patch(
"robot_interface.endpoints.face_detector.SocketBase.close"
)
sender.close()
sender.stop_face_detection.assert_called_once()

View File

@@ -55,6 +55,9 @@ class DummySender:
def start(self):
self.called = True
def start_face_detection(self):
self.called = True
def close(self):
pass
@@ -108,11 +111,13 @@ def patched_main_components(monkeypatch, fake_sockets, fake_poll):
fake_act = FakeReceiver(act_sock)
video_sender = DummySender()
audio_sender = DummySender()
face_sender = DummySender()
monkeypatch.setattr(main_mod, "MainReceiver", lambda ctx: fake_main)
monkeypatch.setattr(main_mod, "ActuationReceiver", lambda ctx: fake_act)
monkeypatch.setattr(main_mod, "VideoSender", lambda ctx: video_sender)
monkeypatch.setattr(main_mod, "AudioSender", lambda ctx: audio_sender)
monkeypatch.setattr(main_mod, "FaceDetectionSender", lambda ctx: face_sender)
# Register sockets for the fake poller
fake_poll.registered = {main_sock: zmq.POLLIN, act_sock: zmq.POLLIN}