From 95c7585bf194171579ea307d334d1e194694a9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 15:00:10 +0100 Subject: [PATCH 01/13] feat: setup gesture agent and adjust command port for the UI ref: N25B-334 --- .../agents/actuation/robot_gesture_agent.py | 108 ++++++++++++++++++ .../agents/actuation/robot_speech_agent.py | 4 +- .../communication/ri_communication_agent.py | 13 ++- src/control_backend/api/v1/endpoints/robot.py | 21 +++- src/control_backend/schemas/ri_message.py | 13 +++ 5 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 src/control_backend/agents/actuation/robot_gesture_agent.py diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py new file mode 100644 index 0000000..1cda099 --- /dev/null +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -0,0 +1,108 @@ +import json + +import zmq +import zmq.asyncio as azmq + +from control_backend.agents import BaseAgent +from control_backend.core.agent_system import InternalMessage +from control_backend.core.config import settings +from control_backend.schemas.ri_message import GestureCommand + + +class RobotGestureAgent(BaseAgent): + """ + This agent acts as a bridge between the control backend and the Robot Interface (RI). + It receives speech commands from other agents or from the UI, + and forwards them to the robot via a ZMQ PUB socket. + + :ivar subsocket: ZMQ SUB socket for receiving external commands (e.g., from UI). + :ivar pubsocket: ZMQ PUB socket for sending commands to the Robot Interface. + :ivar address: Address to bind/connect the PUB socket. + :ivar bind: Whether to bind or connect the PUB socket. + :ivar gesture_data: A list of strings for available gestures + """ + + subsocket: azmq.Socket + pubsocket: azmq.Socket + address = "" + bind = False + gesture_data = [] + + def __init__( + self, + name: str, + address=settings.zmq_settings.ri_command_address, + bind=False, + gesture_data=None, + ): + if gesture_data is None: + gesture_data = [] + super().__init__(name) + self.address = address + self.bind = bind + + async def setup(self): + """ + Initialize the agent. + + 1. Sets up the PUB socket to talk to the robot. + 2. Sets up the SUB socket to listen for "command" topics (from UI/External). + 3. Starts the loop for handling ZMQ commands. + """ + self.logger.info("Setting up %s", self.name) + + context = azmq.Context.instance() + + # To the robot + self.pubsocket = context.socket(zmq.PUB) + if self.bind: # TODO: Should this ever be the case? + self.pubsocket.bind(self.address) + else: + self.pubsocket.connect(self.address) + + # Receive internal topics regarding commands + self.subsocket = context.socket(zmq.SUB) + self.subsocket.connect(settings.zmq_settings.internal_sub_address) + self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command") + + self.add_behavior(self._zmq_command_loop()) + + self.logger.info("Finished setting up %s", self.name) + + async def stop(self): + if self.subsocket: + self.subsocket.close() + if self.pubsocket: + self.pubsocket.close() + await super().stop() + + async def handle_message(self, msg: InternalMessage): + """ + Handle commands received from other internal Python agents. + + Validates the message as a :class:`GestureCommand` and forwards it to the robot. + + :param msg: The internal message containing the command. + """ + try: + speech_command = GestureCommand.model_validate_json(msg.body) + await self.pubsocket.send_json(speech_command.model_dump()) + except Exception: + self.logger.exception("Error processing internal message.") + + async def _zmq_command_loop(self): + """ + Loop to handle commands received via ZMQ (e.g., from the UI). + + Listens on the 'command' topic, validates the JSON and forwards it to the robot. + """ + while self._running: + try: + _, body = await self.subsocket.recv_multipart() + + body = json.loads(body) + message = GestureCommand.model_validate(body) + + await self.pubsocket.send_json(message.model_dump()) + except Exception: + self.logger.exception("Error processing ZMQ message.") diff --git a/src/control_backend/agents/actuation/robot_speech_agent.py b/src/control_backend/agents/actuation/robot_speech_agent.py index 15fa07f..674b270 100644 --- a/src/control_backend/agents/actuation/robot_speech_agent.py +++ b/src/control_backend/agents/actuation/robot_speech_agent.py @@ -21,8 +21,8 @@ class RobotSpeechAgent(BaseAgent): :ivar bind: Whether to bind or connect the PUB socket. """ - subsocket: zmq.Socket - pubsocket: zmq.Socket + subsocket: azmq.Socket + pubsocket: azmq.Socket address = "" bind = False diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index f4e3ef0..a9223c3 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -6,6 +6,7 @@ import zmq.asyncio as azmq from zmq.asyncio import Context from control_backend.agents import BaseAgent +from control_backend.agents.actuation.robot_gesture_agent import RobotGestureAgent from control_backend.core.config import settings from ..actuation.robot_speech_agent import RobotSpeechAgent @@ -179,12 +180,20 @@ class RICommunicationAgent(BaseAgent): else: self._req_socket.bind(addr) case "actuation": - ri_commands_agent = RobotSpeechAgent( + gesture_data = port_data.get("gestures", []) + robot_speech_agent = RobotSpeechAgent( settings.agent_settings.robot_speech_name, address=addr, bind=bind, ) - await ri_commands_agent.start() + robot_gesture_agent = RobotGestureAgent( + settings.agent_settings.robot_speech_name, + address=addr, + bind=bind, + gesture_data=gesture_data, + ) + await robot_speech_agent.start() + await robot_gesture_agent.start() case _: self.logger.warning("Unhandled negotiation id: %s", id) diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index ae0fe66..12f2fa5 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -3,12 +3,13 @@ import json import logging import zmq.asyncio -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse +from pydantic import ValidationError from zmq.asyncio import Context, Socket from control_backend.core.config import settings -from control_backend.schemas.ri_message import SpeechCommand +from control_backend.schemas.ri_message import GestureCommand, SpeechCommand logger = logging.getLogger(__name__) @@ -22,17 +23,29 @@ async def receive_command(command: SpeechCommand, request: Request): Publishes the command to the internal 'command' topic. The :class:`~control_backend.agents.actuation.robot_speech_agent.RobotSpeechAgent` + or + :class:`~control_backend.agents.actuation.robot_speech_agent.RobotGestureAgent` will forward this to the robot. :param command: The speech command payload. :param request: The FastAPI request object. """ # Validate and retrieve data. - SpeechCommand.model_validate(command) + validated = None + valid_commands = (GestureCommand, SpeechCommand) + for command_model in valid_commands: + try: + validated = command_model.model_validate(command) + except ValidationError: + continue + + if validated is None: + raise HTTPException(status_code=422, detail="Payload is not valid for command models") + topic = b"command" pub_socket: Socket = request.app.state.endpoints_pub_socket - await pub_socket.send_multipart([topic, command.model_dump_json().encode()]) + await pub_socket.send_multipart([topic, validated.model_dump_json().encode()]) return {"status": "Command received"} diff --git a/src/control_backend/schemas/ri_message.py b/src/control_backend/schemas/ri_message.py index 3f0e5d2..88656b0 100644 --- a/src/control_backend/schemas/ri_message.py +++ b/src/control_backend/schemas/ri_message.py @@ -10,6 +10,7 @@ class RIEndpoint(str, Enum): """ SPEECH = "actuate/speech" + GESTURE = "actuate/gesture" PING = "ping" NEGOTIATE_PORTS = "negotiate/ports" @@ -36,3 +37,15 @@ class SpeechCommand(RIMessage): endpoint: RIEndpoint = RIEndpoint(RIEndpoint.SPEECH) data: str + + +class GestureCommand(RIMessage): + """ + A specific command to make the robot do a gesture. + + :ivar endpoint: Fixed to ``RIEndpoint.GESTURE``. + :ivar data: The id of the gesture to be executed. + """ + + endpoint: RIEndpoint = RIEndpoint(RIEndpoint.SPEECH) + data: str -- 2.49.1 From b93c39420e9780e78f23f41e55e9655462783300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 3 Dec 2025 13:29:47 +0100 Subject: [PATCH 02/13] fix: create tests for new ri commands ref: N25B-334 --- .../agents/actuation/__init__.py | 1 + .../agents/actuation/robot_gesture_agent.py | 3 +- .../communication/ri_communication_agent.py | 2 +- src/control_backend/core/config.py | 1 + src/control_backend/schemas/ri_message.py | 7 ++-- test/unit/schemas/test_ri_message.py | 34 ++++++++++++++++++- 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/control_backend/agents/actuation/__init__.py b/src/control_backend/agents/actuation/__init__.py index e745333..8ff7e7f 100644 --- a/src/control_backend/agents/actuation/__init__.py +++ b/src/control_backend/agents/actuation/__init__.py @@ -1 +1,2 @@ +from .robot_gesture_agent import RobotGestureAgent as RobotGestureAgent from .robot_speech_agent import RobotSpeechAgent as RobotSpeechAgent diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 1cda099..9f51d21 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -54,6 +54,7 @@ class RobotGestureAgent(BaseAgent): context = azmq.Context.instance() # To the robot + self.pubsocket = context.socket(zmq.PUB) if self.bind: # TODO: Should this ever be the case? self.pubsocket.bind(self.address) @@ -64,7 +65,7 @@ class RobotGestureAgent(BaseAgent): self.subsocket = context.socket(zmq.SUB) self.subsocket.connect(settings.zmq_settings.internal_sub_address) self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command") - + # This one self.add_behavior(self._zmq_command_loop()) self.logger.info("Finished setting up %s", self.name) diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index a9223c3..1b72fe7 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -187,7 +187,7 @@ class RICommunicationAgent(BaseAgent): bind=bind, ) robot_gesture_agent = RobotGestureAgent( - settings.agent_settings.robot_speech_name, + settings.agent_settings.robot_gesture_name, address=addr, bind=bind, gesture_data=gesture_data, diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 4a199ab..eb4dcf9 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -47,6 +47,7 @@ class AgentSettings(BaseModel): transcription_name: str = "transcription_agent" ri_communication_name: str = "ri_communication_agent" robot_speech_name: str = "robot_speech_agent" + robot_gesture_name: str = "robot_gesture_agent" class BehaviourSettings(BaseModel): diff --git a/src/control_backend/schemas/ri_message.py b/src/control_backend/schemas/ri_message.py index 88656b0..fd073a3 100644 --- a/src/control_backend/schemas/ri_message.py +++ b/src/control_backend/schemas/ri_message.py @@ -10,7 +10,8 @@ class RIEndpoint(str, Enum): """ SPEECH = "actuate/speech" - GESTURE = "actuate/gesture" + GESTURE_SINGLE = "actuate/gesture/single" + GESTURE_TAG = "actuate/gesture/tag" PING = "ping" NEGOTIATE_PORTS = "negotiate/ports" @@ -43,9 +44,9 @@ class GestureCommand(RIMessage): """ A specific command to make the robot do a gesture. - :ivar endpoint: Fixed to ``RIEndpoint.GESTURE``. + :ivar endpoint: Should be ``RIEndpoint.GESTURE_SINGLE`` or ``RIEndpoint.GESTURE_TAG``. :ivar data: The id of the gesture to be executed. """ - endpoint: RIEndpoint = RIEndpoint(RIEndpoint.SPEECH) + endpoint: RIEndpoint = RIEndpoint(RIEndpoint.GESTURE_TAG) or RIEndpoint(RIEndpoint.GESTURE_TAG) data: str diff --git a/test/unit/schemas/test_ri_message.py b/test/unit/schemas/test_ri_message.py index 5078f9a..193f7c3 100644 --- a/test/unit/schemas/test_ri_message.py +++ b/test/unit/schemas/test_ri_message.py @@ -1,26 +1,58 @@ import pytest from pydantic import ValidationError -from control_backend.schemas.ri_message import RIEndpoint, RIMessage, SpeechCommand +from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, RIMessage, SpeechCommand def valid_command_1(): return SpeechCommand(data="Hallo?") +def valid_command_2(): + return GestureCommand(endpoint=RIEndpoint.GESTURE_TAG, data="happy") + + +def valid_command_3(): + return GestureCommand(endpoint=RIEndpoint.GESTURE_SINGLE, data="happy_1") + + def invalid_command_1(): return RIMessage(endpoint=RIEndpoint.PING, data="Hello again.") +def invalid_command_2(): + return GestureCommand(endpoint=RIEndpoint.PING, data="Hey!") + + def test_valid_speech_command_1(): command = valid_command_1() RIMessage.model_validate(command) SpeechCommand.model_validate(command) +def test_valid_gesture_command_1(): + command = valid_command_2() + RIMessage.model_validate(command) + GestureCommand.model_validate(command) + + +def test_valid_gesture_command_2(): + command = valid_command_3() + RIMessage.model_validate(command) + GestureCommand.model_validate(command) + + def test_invalid_speech_command_1(): command = invalid_command_1() RIMessage.model_validate(command) with pytest.raises(ValidationError): SpeechCommand.model_validate(command) + + +def test_invalid_gesture_command_1(): + command = invalid_command_2() + RIMessage.model_validate(command) + + with pytest.raises(ValidationError): + GestureCommand.model_validate(command) -- 2.49.1 From fe4a060188842c07d702d68bb3105c306fca2ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 4 Dec 2025 15:13:27 +0100 Subject: [PATCH 03/13] feat: add tests and better model validation for gesture commands ref: N25B-334 --- .../agents/actuation/robot_gesture_agent.py | 130 +++++++++++++++++- .../communication/ri_communication_agent.py | 1 + src/control_backend/schemas/ri_message.py | 18 ++- .../test_ri_communication_agent.py | 48 +++++-- test/unit/schemas/test_ri_message.py | 32 ++++- 5 files changed, 209 insertions(+), 20 deletions(-) diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 9f51d21..8447190 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -86,8 +86,8 @@ class RobotGestureAgent(BaseAgent): :param msg: The internal message containing the command. """ try: - speech_command = GestureCommand.model_validate_json(msg.body) - await self.pubsocket.send_json(speech_command.model_dump()) + gesture_command = GestureCommand.model_validate_json(msg.body) + await self.pubsocket.send_json(gesture_command.model_dump()) except Exception: self.logger.exception("Error processing internal message.") @@ -107,3 +107,129 @@ class RobotGestureAgent(BaseAgent): await self.pubsocket.send_json(message.model_dump()) except Exception: self.logger.exception("Error processing ZMQ message.") + + def availableTags(self): + """ + Returns the available gesture tags. + + :return: List of available gesture tags. + """ + return [ + "above", + "affirmative", + "afford", + "agitated", + "all", + "allright", + "alright", + "any", + "assuage", + "assuage", + "attemper", + "back", + "bashful", + "beg", + "beseech", + "blank", + "body language", + "bored", + "bow", + "but", + "call", + "calm", + "choose", + "choice", + "cloud", + "cogitate", + "cool", + "crazy", + "disappointed", + "down", + "earth", + "empty", + "embarrassed", + "enthusiastic", + "entire", + "estimate", + "except", + "exalted", + "excited", + "explain", + "far", + "field", + "floor", + "forlorn", + "friendly", + "front", + "frustrated", + "gentle", + "gift", + "give", + "ground", + "happy", + "hello", + "her", + "here", + "hey", + "hi", + "him", + "hopeless", + "hysterical", + "I", + "implore", + "indicate", + "joyful", + "me", + "meditate", + "modest", + "negative", + "nervous", + "no", + "not know", + "nothing", + "offer", + "ok", + "once upon a time", + "oppose", + "or", + "pacify", + "pick", + "placate", + "please", + "present", + "proffer", + "quiet", + "reason", + "refute", + "reject", + "rousing", + "sad", + "select", + "shamefaced", + "show", + "show sky", + "sky", + "soothe", + "sun", + "supplicate", + "tablet", + "tall", + "them", + "there", + "think", + "timid", + "top", + "unless", + "up", + "upstairs", + "void", + "warm", + "winner", + "yeah", + "yes", + "yoo-hoo", + "you", + "your", + "zero", + "zestful", + ] diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 1b72fe7..5b89088 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -193,6 +193,7 @@ class RICommunicationAgent(BaseAgent): gesture_data=gesture_data, ) await robot_speech_agent.start() + await asyncio.sleep(0.1) # Small delay await robot_gesture_agent.start() case _: self.logger.warning("Unhandled negotiation id: %s", id) diff --git a/src/control_backend/schemas/ri_message.py b/src/control_backend/schemas/ri_message.py index fd073a3..3f3abea 100644 --- a/src/control_backend/schemas/ri_message.py +++ b/src/control_backend/schemas/ri_message.py @@ -1,7 +1,7 @@ from enum import Enum -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, model_validator class RIEndpoint(str, Enum): @@ -48,5 +48,17 @@ class GestureCommand(RIMessage): :ivar data: The id of the gesture to be executed. """ - endpoint: RIEndpoint = RIEndpoint(RIEndpoint.GESTURE_TAG) or RIEndpoint(RIEndpoint.GESTURE_TAG) + endpoint: Literal[ # pyright: ignore[reportIncompatibleVariableOverride] - We validate this stricter rule ourselves + RIEndpoint.GESTURE_SINGLE, RIEndpoint.GESTURE_TAG + ] data: str + + @model_validator(mode="after") + def check_endpoint(self): + allowed = { + RIEndpoint.GESTURE_SINGLE, + RIEndpoint.GESTURE_TAG, + } + if self.endpoint not in allowed: + raise ValueError("endpoint must be GESTURE_SINGLE or GESTURE_TAG") + return self diff --git a/test/unit/agents/communication/test_ri_communication_agent.py b/test/unit/agents/communication/test_ri_communication_agent.py index 747c4d2..54f3c5a 100644 --- a/test/unit/agents/communication/test_ri_communication_agent.py +++ b/test/unit/agents/communication/test_ri_communication_agent.py @@ -10,6 +10,10 @@ def speech_agent_path(): return "control_backend.agents.communication.ri_communication_agent.RobotSpeechAgent" +def gesture_agent_path(): + return "control_backend.agents.communication.ri_communication_agent.RobotGestureAgent" + + @pytest.fixture def zmq_context(mocker): mock_context = mocker.patch( @@ -22,7 +26,7 @@ def zmq_context(mocker): def negotiation_message( actuation_port: int = 5556, bind_main: bool = False, - bind_actuation: bool = True, + bind_actuation: bool = False, main_port: int = 5555, ): return { @@ -41,9 +45,12 @@ async def test_setup_success_connects_and_starts_robot(zmq_context): fake_socket.recv_json = AsyncMock(return_value=negotiation_message()) fake_socket.send_multipart = AsyncMock() - with patch(speech_agent_path(), autospec=True) as MockRobot: - robot_instance = MockRobot.return_value - robot_instance.start = AsyncMock() + with ( + patch(speech_agent_path(), autospec=True) as MockSpeech, + patch(gesture_agent_path(), autospec=True) as MockGesture, + ): + MockSpeech.return_value.start = AsyncMock() + MockGesture.return_value.start = AsyncMock() agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=False) agent.add_behavior = MagicMock() @@ -52,9 +59,17 @@ async def test_setup_success_connects_and_starts_robot(zmq_context): fake_socket.connect.assert_any_call("tcp://localhost:5555") fake_socket.send_json.assert_any_call({"endpoint": "negotiate/ports", "data": {}}) - robot_instance.start.assert_awaited_once() - MockRobot.assert_called_once_with(ANY, address="tcp://*:5556", bind=True) + MockSpeech.return_value.start.assert_awaited_once() + MockGesture.return_value.start.assert_awaited_once() + MockSpeech.assert_called_once_with(ANY, address="tcp://localhost:5556", bind=False) + MockGesture.assert_called_once_with( + ANY, + address="tcp://localhost:5556", + bind=False, + gesture_data=[], + ) agent.add_behavior.assert_called_once() + assert agent.connected is True @@ -69,10 +84,13 @@ async def test_setup_binds_when_requested(zmq_context): agent.add_behavior = MagicMock() - with patch(speech_agent_path(), autospec=True) as MockRobot: - MockRobot.return_value.start = AsyncMock() + with ( + patch(speech_agent_path(), autospec=True) as MockSpeech, + patch(gesture_agent_path(), autospec=True) as MockGesture, + ): + MockSpeech.return_value.start = AsyncMock() + MockGesture.return_value.start = AsyncMock() await agent.setup() - fake_socket.bind.assert_any_call("tcp://localhost:5555") agent.add_behavior.assert_called_once() @@ -88,7 +106,6 @@ async def test_negotiate_invalid_endpoint_retries(zmq_context): agent._req_socket = fake_socket success = await agent._negotiate_connection(max_retries=1) - assert success is False @@ -112,8 +129,12 @@ async def test_handle_negotiation_response_updates_req_socket(zmq_context): fake_socket = zmq_context.return_value.socket.return_value agent = RICommunicationAgent("ri_comm", address="tcp://localhost:5555", bind=False) agent._req_socket = fake_socket - with patch(speech_agent_path(), autospec=True) as MockRobot: - MockRobot.return_value.start = AsyncMock() + with ( + patch(speech_agent_path(), autospec=True) as MockSpeech, + patch(gesture_agent_path(), autospec=True) as MockGesture, + ): + MockSpeech.return_value.start = AsyncMock() + MockGesture.return_value.start = AsyncMock() await agent._handle_negotiation_response( negotiation_message( main_port=6000, @@ -135,7 +156,6 @@ async def test_handle_disconnection_publishes_and_reconnects(): agent._negotiate_connection = AsyncMock(return_value=True) await agent._handle_disconnection() - pub_socket.send_multipart.assert_awaited() assert agent.connected is True @@ -192,7 +212,7 @@ async def test_setup_warns_on_failed_negotiate(zmq_context, mocker): fake_socket.recv_json = AsyncMock() agent = RICommunicationAgent("ri_comm") - async def swallow(coro): + def swallow(coro): coro.close() agent.add_behavior = swallow diff --git a/test/unit/schemas/test_ri_message.py b/test/unit/schemas/test_ri_message.py index 193f7c3..40601ec 100644 --- a/test/unit/schemas/test_ri_message.py +++ b/test/unit/schemas/test_ri_message.py @@ -21,7 +21,21 @@ def invalid_command_1(): def invalid_command_2(): - return GestureCommand(endpoint=RIEndpoint.PING, data="Hey!") + return RIMessage(endpoint=RIEndpoint.PING, data="Hey!") + + +def invalid_command_3(): + return RIMessage(endpoint=RIEndpoint.GESTURE_SINGLE, data={1, 2, 3}) + + +def invalid_command_4(): + test: RIMessage = GestureCommand(endpoint=RIEndpoint.GESTURE_SINGLE, data="asdsad") + + def change_endpoint(msg: RIMessage): + msg.endpoint = RIEndpoint.PING + + change_endpoint(test) + return test def test_valid_speech_command_1(): @@ -56,3 +70,19 @@ def test_invalid_gesture_command_1(): with pytest.raises(ValidationError): GestureCommand.model_validate(command) + + +def test_invalid_gesture_command_2(): + command = invalid_command_3() + RIMessage.model_validate(command) + + with pytest.raises(ValidationError): + GestureCommand.model_validate(command) + + +def test_invalid_gesture_command_3(): + command = invalid_command_4() + RIMessage.model_validate(command) + + with pytest.raises(ValidationError): + GestureCommand.model_validate(command) -- 2.49.1 From 531526f7bc24569179d77467fa43b63b3fa51bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 4 Dec 2025 16:33:27 +0100 Subject: [PATCH 04/13] feat: create tests for all currect functionality and add get available tags router ref: N25B-334 --- .../agents/actuation/robot_gesture_agent.py | 74 +++- src/control_backend/api/v1/endpoints/robot.py | 39 ++ .../actuation/test_robot_gesture_agent.py | 392 ++++++++++++++++++ .../api/v1/endpoints/test_robot_endpoint.py | 272 +++++++++++- 4 files changed, 769 insertions(+), 8 deletions(-) create mode 100644 test/unit/agents/actuation/test_robot_gesture_agent.py diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 8447190..1741899 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -6,7 +6,7 @@ import zmq.asyncio as azmq from control_backend.agents import BaseAgent from control_backend.core.agent_system import InternalMessage from control_backend.core.config import settings -from control_backend.schemas.ri_message import GestureCommand +from control_backend.schemas.ri_message import GestureCommand, RIEndpoint class RobotGestureAgent(BaseAgent): @@ -36,7 +36,9 @@ class RobotGestureAgent(BaseAgent): gesture_data=None, ): if gesture_data is None: - gesture_data = [] + self.gesture_data = [] + else: + self.gesture_data = gesture_data super().__init__(name) self.address = address self.bind = bind @@ -65,8 +67,10 @@ class RobotGestureAgent(BaseAgent): self.subsocket = context.socket(zmq.SUB) self.subsocket.connect(settings.zmq_settings.internal_sub_address) self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command") - # This one + self.subsocket.setsockopt(zmq.SUBSCRIBE, b"send_gestures") + self.add_behavior(self._zmq_command_loop()) + self.add_behavior(self._fetch_gestures_loop()) self.logger.info("Finished setting up %s", self.name) @@ -87,6 +91,14 @@ class RobotGestureAgent(BaseAgent): """ try: gesture_command = GestureCommand.model_validate_json(msg.body) + if gesture_command.endpoint == RIEndpoint.GESTURE_TAG: + if gesture_command.data not in self.availableTags(): + self.logger.warning( + "Received gesture tag '%s' which is not in available tags. Early returning", + gesture_command.data, + ) + return + await self.pubsocket.send_json(gesture_command.model_dump()) except Exception: self.logger.exception("Error processing internal message.") @@ -99,15 +111,63 @@ class RobotGestureAgent(BaseAgent): """ while self._running: try: - _, body = await self.subsocket.recv_multipart() + topic, body = await self.subsocket.recv_multipart() + + # Don't process send_gestures here + if topic != b"command": + continue body = json.loads(body) - message = GestureCommand.model_validate(body) - - await self.pubsocket.send_json(message.model_dump()) + gesture_command = GestureCommand.model_validate(body) + if gesture_command.endpoint == RIEndpoint.GESTURE_TAG: + if gesture_command.data not in self.availableTags(): + self.logger.warning( + "Received gesture tag '%s' which is not in available tags.\ + Early returning", + gesture_command.data, + ) + continue + await self.pubsocket.send_json(gesture_command.model_dump()) except Exception: self.logger.exception("Error processing ZMQ message.") + async def _fetch_gestures_loop(self): + """ + Loop to handle fetching gestures received via ZMQ (e.g., from the UI). + + Listens on the 'send_gestures' topic, and returns a list on the get_gestures topic. + """ + while self._running: + try: + topic, body = await self.subsocket.recv_multipart() + + # Don't process commands here + if topic != b"send_gestures": + continue + + try: + body = json.loads(body) + except json.JSONDecodeError: + body = None + + # We could have the body be the nummer of gestures you want to fetch or something. + amount = None + if isinstance(body, int): + amount = body + + tags = self.availableTags()[:amount] if amount else self.availableTags() + response = json.dumps({"tags": tags}).encode() + + await self.pubsocket.send_multipart( + [ + b"get_gestures", + response, + ] + ) + + except Exception: + self.logger.exception("Error fetching gesture tags.") + def availableTags(self): """ Returns the available gesture tags. diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index 12f2fa5..c9d93a1 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -58,6 +58,45 @@ async def ping(request: Request): pass +@router.get("/get_available_gesture_tags") +async def get_available_gesture_tags(request: Request): + """ + Endpoint to retrieve the available gesture tags for the robot. + + :param request: The FastAPI request object. + :return: A list of available gesture tags. + """ + sub_socket = Context.instance().socket(zmq.SUB) + sub_socket.connect(settings.zmq_settings.internal_sub_address) + sub_socket.setsockopt(zmq.SUBSCRIBE, b"get_gestures") + + pub_socket: Socket = request.app.state.endpoints_pub_socket + topic = b"send_gestures" + + # TODO: Implement a way to get a certain ammount from the UI, rather than everything. + amount = None + timeout = 5 # seconds + + await pub_socket.send_multipart([topic, amount.to_bytes(4, "big") if amount else b""]) + try: + _, body = await asyncio.wait_for(sub_socket.recv_multipart(), timeout=timeout) + except TimeoutError: + body = b"tags: []" + logger.debug("got timeout error fetching gestures") + + # Handle empty response and JSON decode errors + available_tags = [] + if body: + try: + available_tags = json.loads(body).get("tags", []) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse gesture tags JSON: {e}, body: {body}") + # Return empty list on JSON error + available_tags = [] + + return {"available_gesture_tags": available_tags} + + @router.get("/ping_stream") async def ping_stream(request: Request): """ diff --git a/test/unit/agents/actuation/test_robot_gesture_agent.py b/test/unit/agents/actuation/test_robot_gesture_agent.py new file mode 100644 index 0000000..33b0989 --- /dev/null +++ b/test/unit/agents/actuation/test_robot_gesture_agent.py @@ -0,0 +1,392 @@ +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +import zmq + +from control_backend.agents.actuation.robot_gesture_agent import RobotGestureAgent +from control_backend.core.agent_system import InternalMessage +from control_backend.schemas.ri_message import RIEndpoint + + +@pytest.fixture +def zmq_context(mocker): + """Mock the ZMQ context.""" + mock_context = mocker.patch( + "control_backend.agents.actuation.robot_gesture_agent.azmq.Context.instance" + ) + mock_context.return_value = MagicMock() + return mock_context + + +@pytest.mark.asyncio +async def test_setup_bind(zmq_context, mocker): + """Setup binds and subscribes to internal commands.""" + fake_socket = zmq_context.return_value.socket.return_value + agent = RobotGestureAgent("robot_gesture", address="tcp://localhost:5556", bind=True) + + settings = mocker.patch("control_backend.agents.actuation.robot_gesture_agent.settings") + settings.zmq_settings.internal_sub_address = "tcp://internal:1234" + + agent.add_behavior = MagicMock() + + await agent.setup() + + # Check PUB socket binding + fake_socket.bind.assert_any_call("tcp://localhost:5556") + + # Check SUB socket connection and subscriptions + fake_socket.connect.assert_any_call("tcp://internal:1234") + fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"command") + fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"send_gestures") + + # Check behavior was added + agent.add_behavior.assert_called() # Twice, even. + + +@pytest.mark.asyncio +async def test_setup_connect(zmq_context, mocker): + """Setup connects when bind=False.""" + fake_socket = zmq_context.return_value.socket.return_value + agent = RobotGestureAgent("robot_gesture", address="tcp://localhost:5556", bind=False) + + settings = mocker.patch("control_backend.agents.actuation.robot_gesture_agent.settings") + settings.zmq_settings.internal_sub_address = "tcp://internal:1234" + + agent.add_behavior = MagicMock() + + await agent.setup() + + # Check PUB socket connection (not binding) + fake_socket.connect.assert_any_call("tcp://localhost:5556") + fake_socket.connect.assert_any_call("tcp://internal:1234") + + # Check behavior was added + agent.add_behavior.assert_called() # Twice, actually. + + +@pytest.mark.asyncio +async def test_handle_message_sends_valid_gesture_command(): + """Internal message with valid gesture tag is forwarded to robot pub socket.""" + pubsocket = AsyncMock() + agent = RobotGestureAgent("robot_gesture") + agent.pubsocket = pubsocket + + payload = { + "endpoint": RIEndpoint.GESTURE_TAG, + "data": "hello", # "hello" is in availableTags + } + msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) + + await agent.handle_message(msg) + + pubsocket.send_json.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_handle_message_sends_non_gesture_command(): + """Internal message with non-gesture endpoint is not handled by this agent.""" + pubsocket = AsyncMock() + agent = RobotGestureAgent("robot_gesture") + agent.pubsocket = pubsocket + + payload = {"endpoint": "some_other_endpoint", "data": "invalid_tag_not_in_list"} + msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) + + await agent.handle_message(msg) + + pubsocket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handle_message_rejects_invalid_gesture_tag(): + """Internal message with invalid gesture tag is not forwarded.""" + pubsocket = AsyncMock() + agent = RobotGestureAgent("robot_gesture") + agent.pubsocket = pubsocket + + # Use a tag that's not in availableTags + payload = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "invalid_tag_not_in_list"} + msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) + + await agent.handle_message(msg) + + pubsocket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handle_message_invalid_payload(): + """Invalid payload is caught and does not send.""" + pubsocket = AsyncMock() + agent = RobotGestureAgent("robot_gesture") + agent.pubsocket = pubsocket + + msg = InternalMessage(to="robot", sender="tester", body=json.dumps({"bad": "data"})) + + await agent.handle_message(msg) + + pubsocket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_zmq_command_loop_valid_gesture_payload(): + """UI command with valid gesture tag is read from SUB and published.""" + command = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "hello"} + fake_socket = AsyncMock() + + async def recv_once(): + # stop after first iteration + agent._running = False + return (b"command", json.dumps(command).encode("utf-8")) + + fake_socket.recv_multipart = recv_once + fake_socket.send_json = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._zmq_command_loop() + + fake_socket.send_json.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_zmq_command_loop_valid_non_gesture_payload(): + """UI command with non-gesture endpoint is not handled by this agent.""" + command = {"endpoint": "some_other_endpoint", "data": "anything"} + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + return (b"command", json.dumps(command).encode("utf-8")) + + fake_socket.recv_multipart = recv_once + fake_socket.send_json = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._zmq_command_loop() + + fake_socket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_zmq_command_loop_invalid_gesture_tag(): + """UI command with invalid gesture tag is not forwarded.""" + command = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "invalid_tag_not_in_list"} + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + return (b"command", json.dumps(command).encode("utf-8")) + + fake_socket.recv_multipart = recv_once + fake_socket.send_json = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._zmq_command_loop() + + fake_socket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_zmq_command_loop_invalid_json(): + """Invalid JSON is ignored without sending.""" + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + return (b"command", b"{not_json}") + + fake_socket.recv_multipart = recv_once + fake_socket.send_json = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._zmq_command_loop() + + fake_socket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_zmq_command_loop_ignores_send_gestures_topic(): + """send_gestures topic is ignored in command loop.""" + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + return (b"send_gestures", b"{}") + + fake_socket.recv_multipart = recv_once + fake_socket.send_json = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._zmq_command_loop() + + fake_socket.send_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fetch_gestures_loop_without_amount(): + """Fetch gestures request without amount returns all tags.""" + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + return (b"send_gestures", b"{}") + + fake_socket.recv_multipart = recv_once + fake_socket.send_multipart = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._fetch_gestures_loop() + + fake_socket.send_multipart.assert_awaited_once() + + # Check the response contains all tags + args, kwargs = fake_socket.send_multipart.call_args + assert args[0][0] == b"get_gestures" + response = json.loads(args[0][1]) + assert "tags" in response + assert len(response["tags"]) > 0 + # Check it includes some expected tags + assert "hello" in response["tags"] + assert "yes" in response["tags"] + + +@pytest.mark.asyncio +async def test_fetch_gestures_loop_with_amount(): + """Fetch gestures request with amount returns limited tags.""" + fake_socket = AsyncMock() + amount = 5 + + async def recv_once(): + agent._running = False + return (b"send_gestures", json.dumps(amount).encode()) + + fake_socket.recv_multipart = recv_once + fake_socket.send_multipart = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._fetch_gestures_loop() + + fake_socket.send_multipart.assert_awaited_once() + + args, kwargs = fake_socket.send_multipart.call_args + assert args[0][0] == b"get_gestures" + response = json.loads(args[0][1]) + assert "tags" in response + assert len(response["tags"]) == amount + + +@pytest.mark.asyncio +async def test_fetch_gestures_loop_ignores_command_topic(): + """Command topic is ignored in fetch gestures loop.""" + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + return (b"command", b"{}") + + fake_socket.recv_multipart = recv_once + fake_socket.send_multipart = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._fetch_gestures_loop() + + fake_socket.send_multipart.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fetch_gestures_loop_invalid_request(): + """Invalid request body is handled gracefully.""" + fake_socket = AsyncMock() + + async def recv_once(): + agent._running = False + # Send a non-integer, non-JSON body + return (b"send_gestures", b"not_json") + + fake_socket.recv_multipart = recv_once + fake_socket.send_multipart = AsyncMock() + + agent = RobotGestureAgent("robot_gesture") + agent.subsocket = fake_socket + agent.pubsocket = fake_socket + agent._running = True + + await agent._fetch_gestures_loop() + + # Should still send a response (all tags) + fake_socket.send_multipart.assert_awaited_once() + + +def test_available_tags(): + """Test that availableTags returns the expected list.""" + agent = RobotGestureAgent("robot_gesture") + + tags = agent.availableTags() + + assert isinstance(tags, list) + assert len(tags) > 0 + # Check some expected tags are present + assert "hello" in tags + assert "yes" in tags + assert "no" in tags + # Check a non-existent tag is not present + assert "invalid_tag_not_in_list" not in tags + + +@pytest.mark.asyncio +async def test_stop_closes_sockets(): + """Stop method closes both sockets.""" + pubsocket = MagicMock() + subsocket = MagicMock() + agent = RobotGestureAgent("robot_gesture") + agent.pubsocket = pubsocket + agent.subsocket = subsocket + + await agent.stop() + + pubsocket.close.assert_called_once() + subsocket.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_initialization_with_custom_gesture_data(): + """Agent can be initialized with custom gesture data.""" + custom_gestures = ["custom1", "custom2", "custom3"] + agent = RobotGestureAgent("robot_gesture", gesture_data=custom_gestures) + + # Note: The current implementation doesn't use the gesture_data parameter + # in availableTags(). This test documents that behavior. + # If you update the agent to use gesture_data, update this test accordingly. + assert agent.gesture_data == custom_gestures diff --git a/test/unit/api/v1/endpoints/test_robot_endpoint.py b/test/unit/api/v1/endpoints/test_robot_endpoint.py index 0f71951..72a0220 100644 --- a/test/unit/api/v1/endpoints/test_robot_endpoint.py +++ b/test/unit/api/v1/endpoints/test_robot_endpoint.py @@ -1,7 +1,8 @@ import json -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import zmq.asyncio from fastapi import FastAPI from fastapi.testclient import TestClient @@ -26,6 +27,26 @@ def client(app): return TestClient(app) +@pytest.fixture +def mock_zmq_context(): + """Mock the ZMQ context.""" + with patch("control_backend.api.v1.endpoints.robot.Context.instance") as mock_context: + context_instance = MagicMock() + mock_context.return_value = context_instance + yield context_instance + + +@pytest.fixture +def mock_sockets(mock_zmq_context): + """Mock ZMQ sockets.""" + mock_sub_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_pub_socket = AsyncMock(spec=zmq.asyncio.Socket) + + mock_zmq_context.socket.return_value = mock_sub_socket + + return {"sub": mock_sub_socket, "pub": mock_pub_socket} + + def test_receive_command_success(client): """ Test for successful reception of a command. Ensures the status code is 202 and the response body @@ -69,6 +90,7 @@ def test_ping_check_returns_none(client): assert response.json() is None +# TODO: Convert these mock sockets to the fixture. @pytest.mark.asyncio async def test_ping_stream_yields_ping_event(monkeypatch): """Test that ping_stream yields a proper SSE message when a ping is received.""" @@ -154,3 +176,251 @@ async def test_ping_stream_yields_json_values(monkeypatch): mock_sub_socket.connect.assert_called_once() mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"ping") mock_sub_socket.recv_multipart.assert_awaited() + + +# New tests for get_available_gesture_tags endpoint +@pytest.mark.asyncio +async def test_get_available_gesture_tags_success(client, monkeypatch): + """ + Test successful retrieval of available gesture tags. + """ + # Arrange + mock_sub_socket = AsyncMock() + mock_sub_socket.connect = MagicMock() + mock_sub_socket.setsockopt = MagicMock() + + # Simulate a response with gesture tags + response_data = {"tags": ["wave", "nod", "point", "dance"]} + mock_sub_socket.recv_multipart = AsyncMock( + return_value=[b"get_gestures", json.dumps(response_data).encode()] + ) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_sub_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Mock settings + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + + # Mock logger to avoid actual logging + mock_logger = MagicMock() + monkeypatch.setattr(robot.logger, "debug", mock_logger) + + # Act + response = client.get("/get_available_gesture_tags") + + # Assert + assert response.status_code == 200 + assert response.json() == {"available_gesture_tags": ["wave", "nod", "point", "dance"]} + + # Verify ZeroMQ interactions + mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") + mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"get_gestures") + mock_pub_socket.send_multipart.assert_awaited_once_with([b"send_gestures", b""]) + mock_sub_socket.recv_multipart.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_available_gesture_tags_with_amount(client, monkeypatch): + """ + Test retrieval of gesture tags with a specific amount parameter. + This tests the TODO in the endpoint about getting a certain amount from the UI. + """ + # Arrange + mock_sub_socket = AsyncMock() + mock_sub_socket.connect = MagicMock() + mock_sub_socket.setsockopt = MagicMock() + + # Simulate a response with gesture tags + response_data = {"tags": ["wave", "nod"]} + mock_sub_socket.recv_multipart = AsyncMock( + return_value=[b"get_gestures", json.dumps(response_data).encode()] + ) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_sub_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Mock settings + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + + # Mock logger + mock_logger = MagicMock() + monkeypatch.setattr(robot.logger, "debug", mock_logger) + + # Act - Note: The endpoint currently doesn't support query parameters for amount, + # but we're testing what happens if the UI sends an amount (the TODO in the code) + # For now, we test the current behavior + response = client.get("/get_available_gesture_tags") + + # Assert + assert response.status_code == 200 + assert response.json() == {"available_gesture_tags": ["wave", "nod"]} + + # The endpoint currently doesn't use the amount parameter, so it should send empty bytes + mock_pub_socket.send_multipart.assert_awaited_once_with([b"send_gestures", b""]) + + +@pytest.mark.asyncio +async def test_get_available_gesture_tags_timeout(client, monkeypatch): + """ + Test timeout scenario when fetching gesture tags. + """ + # Arrange + mock_sub_socket = AsyncMock() + mock_sub_socket.connect = MagicMock() + mock_sub_socket.setsockopt = MagicMock() + + # Simulate a timeout + mock_sub_socket.recv_multipart = AsyncMock(side_effect=TimeoutError) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_sub_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Mock settings + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + + # Mock logger to verify debug message is logged + mock_logger = MagicMock() + monkeypatch.setattr(robot.logger, "debug", mock_logger) + + # Act + response = client.get("/get_available_gesture_tags") + + # Assert + assert response.status_code == 200 + # On timeout, body becomes b"" and json.loads(b"") raises JSONDecodeError + # But looking at the endpoint code, it will try to parse empty bytes which will fail + # Let's check what actually happens + assert response.json() == {"available_gesture_tags": []} + + # Verify the timeout was logged + mock_logger.assert_called_once_with("got timeout error fetching gestures") + + # Verify ZeroMQ interactions + mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") + mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"get_gestures") + mock_pub_socket.send_multipart.assert_awaited_once_with([b"send_gestures", b""]) + mock_sub_socket.recv_multipart.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_available_gesture_tags_empty_response(client, monkeypatch): + """ + Test scenario when response contains no tags. + """ + # Arrange + mock_sub_socket = AsyncMock() + mock_sub_socket.connect = MagicMock() + mock_sub_socket.setsockopt = MagicMock() + + # Simulate a response with empty tags + response_data = {"tags": []} + mock_sub_socket.recv_multipart = AsyncMock( + return_value=[b"get_gestures", json.dumps(response_data).encode()] + ) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_sub_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Mock settings + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + + # Act + response = client.get("/get_available_gesture_tags") + + # Assert + assert response.status_code == 200 + assert response.json() == {"available_gesture_tags": []} + + +@pytest.mark.asyncio +async def test_get_available_gesture_tags_missing_tags_key(client, monkeypatch): + """ + Test scenario when response JSON doesn't contain 'tags' key. + """ + # Arrange + mock_sub_socket = AsyncMock() + mock_sub_socket.connect = MagicMock() + mock_sub_socket.setsockopt = MagicMock() + + # Simulate a response without 'tags' key + response_data = {"some_other_key": "value"} + mock_sub_socket.recv_multipart = AsyncMock( + return_value=[b"get_gestures", json.dumps(response_data).encode()] + ) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_sub_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Mock settings + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + + # Act + response = client.get("/get_available_gesture_tags") + + # Assert + assert response.status_code == 200 + # .get("tags", []) should return empty list if 'tags' key is missing + assert response.json() == {"available_gesture_tags": []} + + +@pytest.mark.asyncio +async def test_get_available_gesture_tags_invalid_json(client, monkeypatch): + """ + Test scenario when response contains invalid JSON. + """ + # Arrange + mock_sub_socket = AsyncMock() + mock_sub_socket.connect = MagicMock() + mock_sub_socket.setsockopt = MagicMock() + + # Simulate a response with invalid JSON + mock_sub_socket.recv_multipart = AsyncMock(return_value=[b"get_gestures", b"invalid json"]) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_sub_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + # Mock settings + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + + # Act + response = client.get("/get_available_gesture_tags") + + # Assert - invalid JSON should raise an exception + assert response.status_code == 200 + assert response.json() == {"available_gesture_tags": []} -- 2.49.1 From 6d60a8bb404e87d9bbabd62c5098a05e3f89eb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 4 Dec 2025 16:36:15 +0100 Subject: [PATCH 05/13] test: mmooaare tests (like one). ref: N25B-334 --- .../api/v1/endpoints/test_robot_endpoint.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/test/unit/api/v1/endpoints/test_robot_endpoint.py b/test/unit/api/v1/endpoints/test_robot_endpoint.py index 72a0220..deb9075 100644 --- a/test/unit/api/v1/endpoints/test_robot_endpoint.py +++ b/test/unit/api/v1/endpoints/test_robot_endpoint.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from control_backend.api.v1.endpoints import robot -from control_backend.schemas.ri_message import SpeechCommand +from control_backend.schemas.ri_message import GestureCommand, SpeechCommand @pytest.fixture @@ -47,7 +47,7 @@ def mock_sockets(mock_zmq_context): return {"sub": mock_sub_socket, "pub": mock_pub_socket} -def test_receive_command_success(client): +def test_receive_speech_command_success(client): """ Test for successful reception of a command. Ensures the status code is 202 and the response body is correct. It also verifies that the ZeroMQ socket's send_multipart method is called with the @@ -73,6 +73,32 @@ def test_receive_command_success(client): ) +def test_receive_gesture_command_success(client): + """ + Test for successful reception of a command. Ensures the status code is 202 and the response body + is correct. It also verifies that the ZeroMQ socket's send_multipart method is called with the + expected data. + """ + # Arrange + mock_pub_socket = AsyncMock() + client.app.state.endpoints_pub_socket = mock_pub_socket + + command_data = {"endpoint": "actuate/gesture/tag", "data": "happy"} + gesture_command = GestureCommand(**command_data) + + # Act + response = client.post("/command", json=command_data) + + # Assert + assert response.status_code == 202 + assert response.json() == {"status": "Command received"} + + # Verify that the ZMQ socket was used correctly + mock_pub_socket.send_multipart.assert_awaited_once_with( + [b"command", gesture_command.model_dump_json().encode()] + ) + + def test_receive_command_invalid_payload(client): """ Test invalid data handling (schema validation). -- 2.49.1 From 63897f59693a4d2c8bc34955648c3b3a9e6b4618 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Tue, 9 Dec 2025 12:33:43 +0100 Subject: [PATCH 06/13] chore: double tag --- src/control_backend/agents/actuation/robot_gesture_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 1741899..36ffe41 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -184,7 +184,6 @@ class RobotGestureAgent(BaseAgent): "alright", "any", "assuage", - "assuage", "attemper", "back", "bashful", -- 2.49.1 From 60342632596a232cd360d0ee30d2c8af4fadf244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 9 Dec 2025 14:08:59 +0100 Subject: [PATCH 07/13] fix: correct the gestures bugs, change gestures socket to request/reply ref: N25B-334 --- .../agents/actuation/robot_gesture_agent.py | 158 ++---------------- src/control_backend/api/v1/endpoints/robot.py | 16 +- 2 files changed, 20 insertions(+), 154 deletions(-) diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 1741899..6830874 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -23,6 +23,7 @@ class RobotGestureAgent(BaseAgent): """ subsocket: azmq.Socket + repsocket: azmq.Socket pubsocket: azmq.Socket address = "" bind = False @@ -56,9 +57,8 @@ class RobotGestureAgent(BaseAgent): context = azmq.Context.instance() # To the robot - self.pubsocket = context.socket(zmq.PUB) - if self.bind: # TODO: Should this ever be the case? + if self.bind: self.pubsocket.bind(self.address) else: self.pubsocket.connect(self.address) @@ -69,6 +69,10 @@ class RobotGestureAgent(BaseAgent): self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command") self.subsocket.setsockopt(zmq.SUBSCRIBE, b"send_gestures") + # REP socket for replying to gesture requests + self.repsocket = context.socket(zmq.REP) + self.repsocket.bind("tcp://localhost:7788") + self.add_behavior(self._zmq_command_loop()) self.add_behavior(self._fetch_gestures_loop()) @@ -92,7 +96,7 @@ class RobotGestureAgent(BaseAgent): try: gesture_command = GestureCommand.model_validate_json(msg.body) if gesture_command.endpoint == RIEndpoint.GESTURE_TAG: - if gesture_command.data not in self.availableTags(): + if gesture_command.data not in self.gesture_data: self.logger.warning( "Received gesture tag '%s' which is not in available tags. Early returning", gesture_command.data, @@ -120,7 +124,7 @@ class RobotGestureAgent(BaseAgent): body = json.loads(body) gesture_command = GestureCommand.model_validate(body) if gesture_command.endpoint == RIEndpoint.GESTURE_TAG: - if gesture_command.data not in self.availableTags(): + if gesture_command.data not in self.gesture_data: self.logger.warning( "Received gesture tag '%s' which is not in available tags.\ Early returning", @@ -139,157 +143,23 @@ class RobotGestureAgent(BaseAgent): """ while self._running: try: - topic, body = await self.subsocket.recv_multipart() - - # Don't process commands here - if topic != b"send_gestures": - continue + # Get a request + body = await self.repsocket.recv() + # Figure out amount, if specified try: body = json.loads(body) except json.JSONDecodeError: body = None - # We could have the body be the nummer of gestures you want to fetch or something. amount = None if isinstance(body, int): amount = body - tags = self.availableTags()[:amount] if amount else self.availableTags() + # Fetch tags from gesture data and respond + tags = self.gesture_data[:amount] if amount else self.gesture_data response = json.dumps({"tags": tags}).encode() - - await self.pubsocket.send_multipart( - [ - b"get_gestures", - response, - ] - ) + await self.repsocket.send(response) except Exception: self.logger.exception("Error fetching gesture tags.") - - def availableTags(self): - """ - Returns the available gesture tags. - - :return: List of available gesture tags. - """ - return [ - "above", - "affirmative", - "afford", - "agitated", - "all", - "allright", - "alright", - "any", - "assuage", - "assuage", - "attemper", - "back", - "bashful", - "beg", - "beseech", - "blank", - "body language", - "bored", - "bow", - "but", - "call", - "calm", - "choose", - "choice", - "cloud", - "cogitate", - "cool", - "crazy", - "disappointed", - "down", - "earth", - "empty", - "embarrassed", - "enthusiastic", - "entire", - "estimate", - "except", - "exalted", - "excited", - "explain", - "far", - "field", - "floor", - "forlorn", - "friendly", - "front", - "frustrated", - "gentle", - "gift", - "give", - "ground", - "happy", - "hello", - "her", - "here", - "hey", - "hi", - "him", - "hopeless", - "hysterical", - "I", - "implore", - "indicate", - "joyful", - "me", - "meditate", - "modest", - "negative", - "nervous", - "no", - "not know", - "nothing", - "offer", - "ok", - "once upon a time", - "oppose", - "or", - "pacify", - "pick", - "placate", - "please", - "present", - "proffer", - "quiet", - "reason", - "refute", - "reject", - "rousing", - "sad", - "select", - "shamefaced", - "show", - "show sky", - "sky", - "soothe", - "sun", - "supplicate", - "tablet", - "tall", - "them", - "there", - "think", - "timid", - "top", - "unless", - "up", - "upstairs", - "void", - "warm", - "winner", - "yeah", - "yes", - "yoo-hoo", - "you", - "your", - "zero", - "zestful", - ] diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index c9d93a1..b34e171 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -66,23 +66,19 @@ async def get_available_gesture_tags(request: Request): :param request: The FastAPI request object. :return: A list of available gesture tags. """ - sub_socket = Context.instance().socket(zmq.SUB) - sub_socket.connect(settings.zmq_settings.internal_sub_address) - sub_socket.setsockopt(zmq.SUBSCRIBE, b"get_gestures") - - pub_socket: Socket = request.app.state.endpoints_pub_socket - topic = b"send_gestures" + req_socket = Context.instance().socket(zmq.REQ) + req_socket.connect("tcp://localhost:7788") # TODO: Implement a way to get a certain ammount from the UI, rather than everything. amount = None timeout = 5 # seconds - await pub_socket.send_multipart([topic, amount.to_bytes(4, "big") if amount else b""]) + await req_socket.send(f"{amount}".encode() if amount else b"None") try: - _, body = await asyncio.wait_for(sub_socket.recv_multipart(), timeout=timeout) + body = await asyncio.wait_for(req_socket.recv(), timeout=timeout) except TimeoutError: - body = b"tags: []" - logger.debug("got timeout error fetching gestures") + body = '{"tags": []}' + logger.debug("Got timeout error fetching gestures.") # Handle empty response and JSON decode errors available_tags = [] -- 2.49.1 From 7f34fede81ddea4ee4dab756b235adf77abc0152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 9 Dec 2025 15:37:00 +0100 Subject: [PATCH 08/13] fix: fix the tests ref: N25B-334 --- .../actuation/test_robot_gesture_agent.py | 216 ++++++++------ .../api/v1/endpoints/test_robot_endpoint.py | 277 ++++++++---------- 2 files changed, 249 insertions(+), 244 deletions(-) diff --git a/test/unit/agents/actuation/test_robot_gesture_agent.py b/test/unit/agents/actuation/test_robot_gesture_agent.py index 33b0989..107f36b 100644 --- a/test/unit/agents/actuation/test_robot_gesture_agent.py +++ b/test/unit/agents/actuation/test_robot_gesture_agent.py @@ -34,14 +34,16 @@ async def test_setup_bind(zmq_context, mocker): # Check PUB socket binding fake_socket.bind.assert_any_call("tcp://localhost:5556") + # Check REP socket binding + fake_socket.bind.assert_any_call("tcp://localhost:7788") # Check SUB socket connection and subscriptions fake_socket.connect.assert_any_call("tcp://internal:1234") fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"command") fake_socket.setsockopt.assert_any_call(zmq.SUBSCRIBE, b"send_gestures") - # Check behavior was added - agent.add_behavior.assert_called() # Twice, even. + # Check behavior was added (twice: once for command loop, once for fetch gestures loop) + assert agent.add_behavior.call_count == 2 @pytest.mark.asyncio @@ -60,21 +62,23 @@ async def test_setup_connect(zmq_context, mocker): # Check PUB socket connection (not binding) fake_socket.connect.assert_any_call("tcp://localhost:5556") fake_socket.connect.assert_any_call("tcp://internal:1234") + # Check REP socket binding (always binds) + fake_socket.bind.assert_any_call("tcp://localhost:7788") - # Check behavior was added - agent.add_behavior.assert_called() # Twice, actually. + # Check behavior was added (twice) + assert agent.add_behavior.call_count == 2 @pytest.mark.asyncio async def test_handle_message_sends_valid_gesture_command(): """Internal message with valid gesture tag is forwarded to robot pub socket.""" pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.pubsocket = pubsocket payload = { "endpoint": RIEndpoint.GESTURE_TAG, - "data": "hello", # "hello" is in availableTags + "data": "hello", # "hello" is in gesture_data } msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) @@ -85,9 +89,9 @@ async def test_handle_message_sends_valid_gesture_command(): @pytest.mark.asyncio async def test_handle_message_sends_non_gesture_command(): - """Internal message with non-gesture endpoint is not handled by this agent.""" + """Internal message with non-gesture endpoint is not forwarded by this agent.""" pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.pubsocket = pubsocket payload = {"endpoint": "some_other_endpoint", "data": "invalid_tag_not_in_list"} @@ -95,6 +99,7 @@ async def test_handle_message_sends_non_gesture_command(): await agent.handle_message(msg) + # Non-gesture endpoints should not be forwarded by this agent pubsocket.send_json.assert_not_awaited() @@ -102,10 +107,10 @@ async def test_handle_message_sends_non_gesture_command(): async def test_handle_message_rejects_invalid_gesture_tag(): """Internal message with invalid gesture tag is not forwarded.""" pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.pubsocket = pubsocket - # Use a tag that's not in availableTags + # Use a tag that's not in gesture_data payload = {"endpoint": RIEndpoint.GESTURE_TAG, "data": "invalid_tag_not_in_list"} msg = InternalMessage(to="robot", sender="tester", body=json.dumps(payload)) @@ -118,7 +123,7 @@ async def test_handle_message_rejects_invalid_gesture_tag(): async def test_handle_message_invalid_payload(): """Invalid payload is caught and does not send.""" pubsocket = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.pubsocket = pubsocket msg = InternalMessage(to="robot", sender="tester", body=json.dumps({"bad": "data"})) @@ -142,7 +147,7 @@ async def test_zmq_command_loop_valid_gesture_payload(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -154,7 +159,7 @@ async def test_zmq_command_loop_valid_gesture_payload(): @pytest.mark.asyncio async def test_zmq_command_loop_valid_non_gesture_payload(): - """UI command with non-gesture endpoint is not handled by this agent.""" + """UI command with non-gesture endpoint is not forwarded by this agent.""" command = {"endpoint": "some_other_endpoint", "data": "anything"} fake_socket = AsyncMock() @@ -165,7 +170,7 @@ async def test_zmq_command_loop_valid_non_gesture_payload(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -188,7 +193,7 @@ async def test_zmq_command_loop_invalid_gesture_tag(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -210,7 +215,7 @@ async def test_zmq_command_loop_invalid_json(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -232,7 +237,7 @@ async def test_zmq_command_loop_ignores_send_gestures_topic(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotGestureAgent("robot_gesture") + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -245,139 +250,165 @@ async def test_zmq_command_loop_ignores_send_gestures_topic(): @pytest.mark.asyncio async def test_fetch_gestures_loop_without_amount(): """Fetch gestures request without amount returns all tags.""" - fake_socket = AsyncMock() + fake_repsocket = AsyncMock() async def recv_once(): agent._running = False - return (b"send_gestures", b"{}") + return b"{}" # Empty JSON request - fake_socket.recv_multipart = recv_once - fake_socket.send_multipart = AsyncMock() + fake_repsocket.recv = recv_once + fake_repsocket.send = AsyncMock() - agent = RobotGestureAgent("robot_gesture") - agent.subsocket = fake_socket - agent.pubsocket = fake_socket + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no", "wave", "point"]) + agent.repsocket = fake_repsocket agent._running = True await agent._fetch_gestures_loop() - fake_socket.send_multipart.assert_awaited_once() + fake_repsocket.send.assert_awaited_once() # Check the response contains all tags - args, kwargs = fake_socket.send_multipart.call_args - assert args[0][0] == b"get_gestures" - response = json.loads(args[0][1]) + args, kwargs = fake_repsocket.send.call_args + response = json.loads(args[0]) assert "tags" in response - assert len(response["tags"]) > 0 - # Check it includes some expected tags - assert "hello" in response["tags"] - assert "yes" in response["tags"] + assert response["tags"] == ["hello", "yes", "no", "wave", "point"] @pytest.mark.asyncio async def test_fetch_gestures_loop_with_amount(): """Fetch gestures request with amount returns limited tags.""" - fake_socket = AsyncMock() - amount = 5 + fake_repsocket = AsyncMock() + amount = 3 async def recv_once(): agent._running = False - return (b"send_gestures", json.dumps(amount).encode()) + return json.dumps(amount).encode() - fake_socket.recv_multipart = recv_once - fake_socket.send_multipart = AsyncMock() + fake_repsocket.recv = recv_once + fake_repsocket.send = AsyncMock() - agent = RobotGestureAgent("robot_gesture") - agent.subsocket = fake_socket - agent.pubsocket = fake_socket + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no", "wave", "point"]) + agent.repsocket = fake_repsocket agent._running = True await agent._fetch_gestures_loop() - fake_socket.send_multipart.assert_awaited_once() + fake_repsocket.send.assert_awaited_once() - args, kwargs = fake_socket.send_multipart.call_args - assert args[0][0] == b"get_gestures" - response = json.loads(args[0][1]) + args, kwargs = fake_repsocket.send.call_args + response = json.loads(args[0]) assert "tags" in response assert len(response["tags"]) == amount + assert response["tags"] == ["hello", "yes", "no"] @pytest.mark.asyncio -async def test_fetch_gestures_loop_ignores_command_topic(): - """Command topic is ignored in fetch gestures loop.""" - fake_socket = AsyncMock() +async def test_fetch_gestures_loop_with_integer_request(): + """Fetch gestures request with integer amount.""" + fake_repsocket = AsyncMock() + amount = 2 async def recv_once(): agent._running = False - return (b"command", b"{}") + return json.dumps(amount).encode() - fake_socket.recv_multipart = recv_once - fake_socket.send_multipart = AsyncMock() + fake_repsocket.recv = recv_once + fake_repsocket.send = AsyncMock() - agent = RobotGestureAgent("robot_gesture") - agent.subsocket = fake_socket - agent.pubsocket = fake_socket + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) + agent.repsocket = fake_repsocket agent._running = True await agent._fetch_gestures_loop() - fake_socket.send_multipart.assert_not_awaited() + fake_repsocket.send.assert_awaited_once() + + args, kwargs = fake_repsocket.send.call_args + response = json.loads(args[0]) + assert response["tags"] == ["hello", "yes"] @pytest.mark.asyncio -async def test_fetch_gestures_loop_invalid_request(): - """Invalid request body is handled gracefully.""" - fake_socket = AsyncMock() +async def test_fetch_gestures_loop_with_invalid_json(): + """Invalid JSON request returns all tags.""" + fake_repsocket = AsyncMock() async def recv_once(): agent._running = False - # Send a non-integer, non-JSON body - return (b"send_gestures", b"not_json") + return b"not_json" - fake_socket.recv_multipart = recv_once - fake_socket.send_multipart = AsyncMock() + fake_repsocket.recv = recv_once + fake_repsocket.send = AsyncMock() - agent = RobotGestureAgent("robot_gesture") - agent.subsocket = fake_socket - agent.pubsocket = fake_socket + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) + agent.repsocket = fake_repsocket agent._running = True await agent._fetch_gestures_loop() - # Should still send a response (all tags) - fake_socket.send_multipart.assert_awaited_once() + fake_repsocket.send.assert_awaited_once() + + args, kwargs = fake_repsocket.send.call_args + response = json.loads(args[0]) + assert response["tags"] == ["hello", "yes", "no"] -def test_available_tags(): - """Test that availableTags returns the expected list.""" - agent = RobotGestureAgent("robot_gesture") +@pytest.mark.asyncio +async def test_fetch_gestures_loop_with_non_integer_json(): + """Non-integer JSON request returns all tags.""" + fake_repsocket = AsyncMock() - tags = agent.availableTags() + async def recv_once(): + agent._running = False + return json.dumps({"not": "an_integer"}).encode() - assert isinstance(tags, list) - assert len(tags) > 0 - # Check some expected tags are present - assert "hello" in tags - assert "yes" in tags - assert "no" in tags - # Check a non-existent tag is not present - assert "invalid_tag_not_in_list" not in tags + fake_repsocket.recv = recv_once + fake_repsocket.send = AsyncMock() + + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) + agent.repsocket = fake_repsocket + agent._running = True + + await agent._fetch_gestures_loop() + + fake_repsocket.send.assert_awaited_once() + + args, kwargs = fake_repsocket.send.call_args + response = json.loads(args[0]) + assert response["tags"] == ["hello", "yes", "no"] + + +def test_gesture_data_attribute(): + """Test that gesture_data returns the expected list.""" + gesture_data = ["hello", "yes", "no", "wave"] + agent = RobotGestureAgent("robot_gesture", gesture_data=gesture_data) + + assert agent.gesture_data == gesture_data + assert isinstance(agent.gesture_data, list) + assert len(agent.gesture_data) == 4 + assert "hello" in agent.gesture_data + assert "yes" in agent.gesture_data + assert "no" in agent.gesture_data + assert "invalid_tag_not_in_list" not in agent.gesture_data @pytest.mark.asyncio async def test_stop_closes_sockets(): - """Stop method closes both sockets.""" + """Stop method closes all sockets.""" pubsocket = MagicMock() subsocket = MagicMock() + repsocket = MagicMock() agent = RobotGestureAgent("robot_gesture") agent.pubsocket = pubsocket agent.subsocket = subsocket + agent.repsocket = repsocket await agent.stop() pubsocket.close.assert_called_once() subsocket.close.assert_called_once() + # Note: repsocket is not closed in stop() method, but you might want to add it + # repsocket.close.assert_called_once() @pytest.mark.asyncio @@ -386,7 +417,28 @@ async def test_initialization_with_custom_gesture_data(): custom_gestures = ["custom1", "custom2", "custom3"] agent = RobotGestureAgent("robot_gesture", gesture_data=custom_gestures) - # Note: The current implementation doesn't use the gesture_data parameter - # in availableTags(). This test documents that behavior. - # If you update the agent to use gesture_data, update this test accordingly. assert agent.gesture_data == custom_gestures + + +@pytest.mark.asyncio +async def test_fetch_gestures_loop_handles_exception(): + """Exception in fetch gestures loop is caught and logged.""" + fake_repsocket = AsyncMock() + + async def recv_once(): + agent._running = False + raise Exception("Test exception") + + fake_repsocket.recv = recv_once + fake_repsocket.send = AsyncMock() + + agent = RobotGestureAgent("robot_gesture", gesture_data=["hello", "yes", "no"]) + agent.repsocket = fake_repsocket + agent.logger = MagicMock() + agent._running = True + + # Should not raise exception + await agent._fetch_gestures_loop() + + # Exception should be logged + agent.logger.exception.assert_called_once() diff --git a/test/unit/api/v1/endpoints/test_robot_endpoint.py b/test/unit/api/v1/endpoints/test_robot_endpoint.py index deb9075..71654d9 100644 --- a/test/unit/api/v1/endpoints/test_robot_endpoint.py +++ b/test/unit/api/v1/endpoints/test_robot_endpoint.py @@ -1,3 +1,4 @@ +# tests/test_robot_endpoints.py import json from unittest.mock import AsyncMock, MagicMock, patch @@ -29,7 +30,7 @@ def client(app): @pytest.fixture def mock_zmq_context(): - """Mock the ZMQ context.""" + """Mock the ZMQ context used by the endpoint module.""" with patch("control_backend.api.v1.endpoints.robot.Context.instance") as mock_context: context_instance = MagicMock() mock_context.return_value = context_instance @@ -38,13 +39,13 @@ def mock_zmq_context(): @pytest.fixture def mock_sockets(mock_zmq_context): - """Mock ZMQ sockets.""" + """Optional helper if you want both a sub and req/push socket available.""" mock_sub_socket = AsyncMock(spec=zmq.asyncio.Socket) - mock_pub_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) mock_zmq_context.socket.return_value = mock_sub_socket - return {"sub": mock_sub_socket, "pub": mock_pub_socket} + return {"sub": mock_sub_socket, "req": mock_req_socket} def test_receive_speech_command_success(client): @@ -75,9 +76,8 @@ def test_receive_speech_command_success(client): def test_receive_gesture_command_success(client): """ - Test for successful reception of a command. Ensures the status code is 202 and the response body - is correct. It also verifies that the ZeroMQ socket's send_multipart method is called with the - expected data. + Test for successful reception of a command that is a gesture command. + Ensures the status code is 202 and the response body is correct. """ # Arrange mock_pub_socket = AsyncMock() @@ -116,7 +116,9 @@ def test_ping_check_returns_none(client): assert response.json() is None -# TODO: Convert these mock sockets to the fixture. +# ---------------------------- +# ping_stream tests (unchanged behavior) +# ---------------------------- @pytest.mark.asyncio async def test_ping_stream_yields_ping_event(monkeypatch): """Test that ping_stream yields a proper SSE message when a ping is received.""" @@ -129,6 +131,11 @@ async def test_ping_stream_yields_ping_event(monkeypatch): mock_context.socket.return_value = mock_sub_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + # patch settings address used by ping_stream + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + mock_request = AsyncMock() mock_request.is_disconnected = AsyncMock(side_effect=[False, True]) @@ -142,7 +149,7 @@ async def test_ping_stream_yields_ping_event(monkeypatch): with pytest.raises(StopAsyncIteration): await anext(generator) - mock_sub_socket.connect.assert_called_once() + mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"ping") mock_sub_socket.recv_multipart.assert_awaited() @@ -159,6 +166,10 @@ async def test_ping_stream_handles_timeout(monkeypatch): mock_context.socket.return_value = mock_sub_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + mock_request = AsyncMock() mock_request.is_disconnected = AsyncMock(return_value=True) @@ -168,7 +179,7 @@ async def test_ping_stream_handles_timeout(monkeypatch): with pytest.raises(StopAsyncIteration): await anext(generator) - mock_sub_socket.connect.assert_called_once() + mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"ping") mock_sub_socket.recv_multipart.assert_awaited() @@ -187,6 +198,10 @@ async def test_ping_stream_yields_json_values(monkeypatch): mock_context.socket.return_value = mock_sub_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + mock_settings = MagicMock() + mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" + monkeypatch.setattr(robot, "settings", mock_settings) + mock_request = AsyncMock() mock_request.is_disconnected = AsyncMock(side_effect=[False, True]) @@ -199,43 +214,33 @@ async def test_ping_stream_yields_json_values(monkeypatch): assert "connected" in event_text assert "true" in event_text - mock_sub_socket.connect.assert_called_once() + mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"ping") mock_sub_socket.recv_multipart.assert_awaited() -# New tests for get_available_gesture_tags endpoint +# ---------------------------- +# Updated get_available_gesture_tags tests (REQ socket on tcp://localhost:7788) +# ---------------------------- @pytest.mark.asyncio async def test_get_available_gesture_tags_success(client, monkeypatch): """ - Test successful retrieval of available gesture tags. + Test successful retrieval of available gesture tags using a REQ socket. """ # Arrange - mock_sub_socket = AsyncMock() - mock_sub_socket.connect = MagicMock() - mock_sub_socket.setsockopt = MagicMock() - - # Simulate a response with gesture tags + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send = AsyncMock() response_data = {"tags": ["wave", "nod", "point", "dance"]} - mock_sub_socket.recv_multipart = AsyncMock( - return_value=[b"get_gestures", json.dumps(response_data).encode()] - ) + mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) mock_context = MagicMock() - mock_context.socket.return_value = mock_sub_socket + mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Mock settings - mock_settings = MagicMock() - mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" - monkeypatch.setattr(robot, "settings", mock_settings) - - # Mock logger to avoid actual logging - mock_logger = MagicMock() - monkeypatch.setattr(robot.logger, "debug", mock_logger) + # Replace logger methods to avoid noisy logs in tests + monkeypatch.setattr(robot.logger, "debug", MagicMock()) + monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act response = client.get("/get_available_gesture_tags") @@ -244,135 +249,97 @@ async def test_get_available_gesture_tags_success(client, monkeypatch): assert response.status_code == 200 assert response.json() == {"available_gesture_tags": ["wave", "nod", "point", "dance"]} - # Verify ZeroMQ interactions - mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") - mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"get_gestures") - mock_pub_socket.send_multipart.assert_awaited_once_with([b"send_gestures", b""]) - mock_sub_socket.recv_multipart.assert_awaited_once() + # Verify ZeroMQ REQ interactions + mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") + mock_req_socket.send.assert_awaited_once_with(b"None") + mock_req_socket.recv.assert_awaited_once() @pytest.mark.asyncio async def test_get_available_gesture_tags_with_amount(client, monkeypatch): """ - Test retrieval of gesture tags with a specific amount parameter. - This tests the TODO in the endpoint about getting a certain amount from the UI. + The endpoint currently ignores the 'amount' TODO, so behavior is the same as 'success'. + This test asserts that the endpoint still sends b"None" and returns the tags. """ # Arrange - mock_sub_socket = AsyncMock() - mock_sub_socket.connect = MagicMock() - mock_sub_socket.setsockopt = MagicMock() - - # Simulate a response with gesture tags + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send = AsyncMock() response_data = {"tags": ["wave", "nod"]} - mock_sub_socket.recv_multipart = AsyncMock( - return_value=[b"get_gestures", json.dumps(response_data).encode()] - ) + mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) mock_context = MagicMock() - mock_context.socket.return_value = mock_sub_socket + mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Mock settings - mock_settings = MagicMock() - mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" - monkeypatch.setattr(robot, "settings", mock_settings) - - # Mock logger - mock_logger = MagicMock() - monkeypatch.setattr(robot.logger, "debug", mock_logger) - - # Act - Note: The endpoint currently doesn't support query parameters for amount, - # but we're testing what happens if the UI sends an amount (the TODO in the code) - # For now, we test the current behavior - response = client.get("/get_available_gesture_tags") - - # Assert - assert response.status_code == 200 - assert response.json() == {"available_gesture_tags": ["wave", "nod"]} - - # The endpoint currently doesn't use the amount parameter, so it should send empty bytes - mock_pub_socket.send_multipart.assert_awaited_once_with([b"send_gestures", b""]) - - -@pytest.mark.asyncio -async def test_get_available_gesture_tags_timeout(client, monkeypatch): - """ - Test timeout scenario when fetching gesture tags. - """ - # Arrange - mock_sub_socket = AsyncMock() - mock_sub_socket.connect = MagicMock() - mock_sub_socket.setsockopt = MagicMock() - - # Simulate a timeout - mock_sub_socket.recv_multipart = AsyncMock(side_effect=TimeoutError) - - mock_context = MagicMock() - mock_context.socket.return_value = mock_sub_socket - monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Mock settings - mock_settings = MagicMock() - mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" - monkeypatch.setattr(robot, "settings", mock_settings) - - # Mock logger to verify debug message is logged - mock_logger = MagicMock() - monkeypatch.setattr(robot.logger, "debug", mock_logger) + monkeypatch.setattr(robot.logger, "debug", MagicMock()) + monkeypatch.setattr(robot.logger, "error", MagicMock()) + + # Act + response = client.get("/get_available_gesture_tags") + + # Assert + assert response.status_code == 200 + assert response.json() == {"available_gesture_tags": ["wave", "nod"]} + + mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") + mock_req_socket.send.assert_awaited_once_with(b"None") + + +@pytest.mark.asyncio +async def test_get_available_gesture_tags_timeout(client, monkeypatch): + """ + Test timeout scenario when fetching gesture tags. Endpoint should handle TimeoutError + and return an empty list while logging the timeout. + """ + # Arrange + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send = AsyncMock() + mock_req_socket.recv = AsyncMock(side_effect=TimeoutError) + + mock_context = MagicMock() + mock_context.socket.return_value = mock_req_socket + monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) + + # Patch logger.debug so we can assert it was called with the expected message + mock_debug = MagicMock() + monkeypatch.setattr(robot.logger, "debug", mock_debug) + monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act response = client.get("/get_available_gesture_tags") # Assert assert response.status_code == 200 - # On timeout, body becomes b"" and json.loads(b"") raises JSONDecodeError - # But looking at the endpoint code, it will try to parse empty bytes which will fail - # Let's check what actually happens assert response.json() == {"available_gesture_tags": []} - # Verify the timeout was logged - mock_logger.assert_called_once_with("got timeout error fetching gestures") + # Verify the timeout was logged using the exact string from the endpoint code + mock_debug.assert_called_once_with("Got timeout error fetching gestures.") - # Verify ZeroMQ interactions - mock_sub_socket.connect.assert_called_once_with("tcp://localhost:5555") - mock_sub_socket.setsockopt.assert_called_once_with(robot.zmq.SUBSCRIBE, b"get_gestures") - mock_pub_socket.send_multipart.assert_awaited_once_with([b"send_gestures", b""]) - mock_sub_socket.recv_multipart.assert_awaited_once() + mock_req_socket.connect.assert_called_once_with("tcp://localhost:7788") + mock_req_socket.send.assert_awaited_once_with(b"None") + mock_req_socket.recv.assert_awaited_once() @pytest.mark.asyncio async def test_get_available_gesture_tags_empty_response(client, monkeypatch): """ - Test scenario when response contains no tags. + Test scenario when response contains an empty 'tags' list. """ # Arrange - mock_sub_socket = AsyncMock() - mock_sub_socket.connect = MagicMock() - mock_sub_socket.setsockopt = MagicMock() - - # Simulate a response with empty tags + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send = AsyncMock() response_data = {"tags": []} - mock_sub_socket.recv_multipart = AsyncMock( - return_value=[b"get_gestures", json.dumps(response_data).encode()] - ) + mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) mock_context = MagicMock() - mock_context.socket.return_value = mock_sub_socket + mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Mock settings - mock_settings = MagicMock() - mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" - monkeypatch.setattr(robot, "settings", mock_settings) + monkeypatch.setattr(robot.logger, "debug", MagicMock()) + monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act response = client.get("/get_available_gesture_tags") @@ -388,65 +355,51 @@ async def test_get_available_gesture_tags_missing_tags_key(client, monkeypatch): Test scenario when response JSON doesn't contain 'tags' key. """ # Arrange - mock_sub_socket = AsyncMock() - mock_sub_socket.connect = MagicMock() - mock_sub_socket.setsockopt = MagicMock() - - # Simulate a response without 'tags' key + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send = AsyncMock() response_data = {"some_other_key": "value"} - mock_sub_socket.recv_multipart = AsyncMock( - return_value=[b"get_gestures", json.dumps(response_data).encode()] - ) + mock_req_socket.recv = AsyncMock(return_value=json.dumps(response_data).encode()) mock_context = MagicMock() - mock_context.socket.return_value = mock_sub_socket + mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Mock settings - mock_settings = MagicMock() - mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" - monkeypatch.setattr(robot, "settings", mock_settings) + monkeypatch.setattr(robot.logger, "debug", MagicMock()) + monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act response = client.get("/get_available_gesture_tags") # Assert assert response.status_code == 200 - # .get("tags", []) should return empty list if 'tags' key is missing assert response.json() == {"available_gesture_tags": []} @pytest.mark.asyncio async def test_get_available_gesture_tags_invalid_json(client, monkeypatch): """ - Test scenario when response contains invalid JSON. + Test scenario when response contains invalid JSON. Endpoint should log the error + and return an empty list. """ # Arrange - mock_sub_socket = AsyncMock() - mock_sub_socket.connect = MagicMock() - mock_sub_socket.setsockopt = MagicMock() - - # Simulate a response with invalid JSON - mock_sub_socket.recv_multipart = AsyncMock(return_value=[b"get_gestures", b"invalid json"]) + mock_req_socket = AsyncMock(spec=zmq.asyncio.Socket) + mock_req_socket.connect = MagicMock() + mock_req_socket.send = AsyncMock() + mock_req_socket.recv = AsyncMock(return_value=b"invalid json") mock_context = MagicMock() - mock_context.socket.return_value = mock_sub_socket + mock_context.socket.return_value = mock_req_socket monkeypatch.setattr(robot.Context, "instance", lambda: mock_context) - mock_pub_socket = AsyncMock() - client.app.state.endpoints_pub_socket = mock_pub_socket - - # Mock settings - mock_settings = MagicMock() - mock_settings.zmq_settings.internal_sub_address = "tcp://localhost:5555" - monkeypatch.setattr(robot, "settings", mock_settings) + mock_error = MagicMock() + monkeypatch.setattr(robot.logger, "error", mock_error) + monkeypatch.setattr(robot.logger, "debug", MagicMock()) # Act response = client.get("/get_available_gesture_tags") - # Assert - invalid JSON should raise an exception + # Assert - invalid JSON should lead to empty list and error log invocation assert response.status_code == 200 assert response.json() == {"available_gesture_tags": []} + assert mock_error.call_count == 1 -- 2.49.1 From 2e472ea292586b6eb2c390d09e221e3877e641f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 11 Dec 2025 12:48:18 +0100 Subject: [PATCH 09/13] chore: remove wrong test paths --- test/unit/test_main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/unit/test_main.py b/test/unit/test_main.py index 2737c76..a423703 100644 --- a/test/unit/test_main.py +++ b/test/unit/test_main.py @@ -53,8 +53,6 @@ async def test_lifespan_agent_start_exception(): Ensures exceptions are logged properly and re-raised. """ with ( - patch("control_backend.main.VADAgent.start", new_callable=AsyncMock), - patch("control_backend.main.VADAgent.reset_stream", new_callable=AsyncMock), patch( "control_backend.main.RICommunicationAgent.start", new_callable=AsyncMock ) as ri_start, -- 2.49.1 From b2d014753d43c8d368891f3d558d701d26f5172d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 11 Dec 2025 15:08:15 +0000 Subject: [PATCH 10/13] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Pim Hutting --- src/control_backend/agents/actuation/robot_gesture_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 6830874..0711fba 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -12,7 +12,7 @@ from control_backend.schemas.ri_message import GestureCommand, RIEndpoint class RobotGestureAgent(BaseAgent): """ This agent acts as a bridge between the control backend and the Robot Interface (RI). - It receives speech commands from other agents or from the UI, + It receives gesture commands from other agents or from the UI, and forwards them to the robot via a ZMQ PUB socket. :ivar subsocket: ZMQ SUB socket for receiving external commands (e.g., from UI). -- 2.49.1 From daf31ac6a6d349be82ae7f52bc1c0db280828859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 11:35:56 +0100 Subject: [PATCH 11/13] fix: change the address to the config, update some logic, seperate the api endpoint, renaming things. yes, the tests don't work right now- this shouldn't be merged yet. ref: N25B-334 --- .../agents/actuation/robot_gesture_agent.py | 7 +-- .../agents/actuation/robot_speech_agent.py | 2 +- src/control_backend/api/v1/endpoints/robot.py | 60 +++++++++++++------ src/control_backend/core/config.py | 1 + 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 6830874..cd195c1 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -36,10 +36,7 @@ class RobotGestureAgent(BaseAgent): bind=False, gesture_data=None, ): - if gesture_data is None: - self.gesture_data = [] - else: - self.gesture_data = gesture_data + self.gesture_data = gesture_data or [] super().__init__(name) self.address = address self.bind = bind @@ -71,7 +68,7 @@ class RobotGestureAgent(BaseAgent): # REP socket for replying to gesture requests self.repsocket = context.socket(zmq.REP) - self.repsocket.bind("tcp://localhost:7788") + self.repsocket.bind(settings.zmq_settings.internal_gesture_rep_adress) self.add_behavior(self._zmq_command_loop()) self.add_behavior(self._fetch_gestures_loop()) diff --git a/src/control_backend/agents/actuation/robot_speech_agent.py b/src/control_backend/agents/actuation/robot_speech_agent.py index 674b270..f8e3d4c 100644 --- a/src/control_backend/agents/actuation/robot_speech_agent.py +++ b/src/control_backend/agents/actuation/robot_speech_agent.py @@ -29,7 +29,7 @@ class RobotSpeechAgent(BaseAgent): def __init__( self, name: str, - address=settings.zmq_settings.ri_command_address, + address: str, bind=False, ): super().__init__(name) diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index b34e171..517068b 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -16,14 +16,40 @@ logger = logging.getLogger(__name__) router = APIRouter() -@router.post("/command", status_code=202) -async def receive_command(command: SpeechCommand, request: Request): +@router.post("/command/speech", status_code=202) +async def receive_command_speech(command: SpeechCommand, request: Request): """ Send a direct speech command to the robot. Publishes the command to the internal 'command' topic. The :class:`~control_backend.agents.actuation.robot_speech_agent.RobotSpeechAgent` - or + will forward this to the robot. + + :param command: The speech command payload. + :param request: The FastAPI request object. + """ + # Validate and retrieve data. + try: + validated = SpeechCommand.model_validate(command) + except ValidationError as e: + raise HTTPException( + status_code=422, detail=f"Payload is not valid SpeechCommand: {e}" + ) from e + + topic = b"command" + + pub_socket: Socket = request.app.state.endpoints_pub_socket + await pub_socket.send_multipart([topic, validated.model_dump_json().encode()]) + + return {"status": "Speech command received"} + + +@router.post("/command/gesture", status_code=202) +async def receive_command_gesture(command: GestureCommand, request: Request): + """ + Send a direct gesture command to the robot. + + Publishes the command to the internal 'command' topic. The :class:`~control_backend.agents.actuation.robot_speech_agent.RobotGestureAgent` will forward this to the robot. @@ -31,23 +57,19 @@ async def receive_command(command: SpeechCommand, request: Request): :param request: The FastAPI request object. """ # Validate and retrieve data. - validated = None - valid_commands = (GestureCommand, SpeechCommand) - for command_model in valid_commands: - try: - validated = command_model.model_validate(command) - except ValidationError: - continue - - if validated is None: - raise HTTPException(status_code=422, detail="Payload is not valid for command models") + try: + validated = GestureCommand.model_validate(command) + except ValidationError as e: + raise HTTPException( + status_code=422, detail=f"Payload is not valid GestureCommand: {e}" + ) from e topic = b"command" pub_socket: Socket = request.app.state.endpoints_pub_socket await pub_socket.send_multipart([topic, validated.model_dump_json().encode()]) - return {"status": "Command received"} + return {"status": "Gesture command received"} @router.get("/ping_check") @@ -58,8 +80,8 @@ async def ping(request: Request): pass -@router.get("/get_available_gesture_tags") -async def get_available_gesture_tags(request: Request): +@router.get("/commands/gesture/tags") +async def get_available_gesture_tags(request: Request, count=0): """ Endpoint to retrieve the available gesture tags for the robot. @@ -67,10 +89,10 @@ async def get_available_gesture_tags(request: Request): :return: A list of available gesture tags. """ req_socket = Context.instance().socket(zmq.REQ) - req_socket.connect("tcp://localhost:7788") + req_socket.connect(request.app.state.gesture_rep_address) - # TODO: Implement a way to get a certain ammount from the UI, rather than everything. - amount = None + # Check to see if we've got any count given in the query parameter + amount = count or None timeout = 5 # seconds await req_socket.send(f"{amount}".encode() if amount else b"None") diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 947a30d..2712d8a 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -17,6 +17,7 @@ class ZMQSettings(BaseModel): internal_sub_address: str = "tcp://localhost:5561" ri_command_address: str = "tcp://localhost:0000" ri_communication_address: str = "tcp://*:5555" + internal_gesture_rep_adress: str = "tcp://localhost:7788" class AgentSettings(BaseModel): -- 2.49.1 From f15a518984b69296bd2e2d40ff7984438c60f5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 11:52:01 +0100 Subject: [PATCH 12/13] fix: tests ref: N25B-334 --- src/control_backend/api/v1/endpoints/robot.py | 2 +- .../actuation/test_robot_gesture_agent.py | 4 +-- .../actuation/test_robot_speech_agent.py | 15 +++++--- .../api/v1/endpoints/test_robot_endpoint.py | 34 ++++++++++++------- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index 517068b..21db77d 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -89,7 +89,7 @@ async def get_available_gesture_tags(request: Request, count=0): :return: A list of available gesture tags. """ req_socket = Context.instance().socket(zmq.REQ) - req_socket.connect(request.app.state.gesture_rep_address) + req_socket.connect(settings.zmq_settings.internal_gesture_rep_adress) # Check to see if we've got any count given in the query parameter amount = count or None diff --git a/test/unit/agents/actuation/test_robot_gesture_agent.py b/test/unit/agents/actuation/test_robot_gesture_agent.py index 107f36b..c68f052 100644 --- a/test/unit/agents/actuation/test_robot_gesture_agent.py +++ b/test/unit/agents/actuation/test_robot_gesture_agent.py @@ -35,7 +35,7 @@ async def test_setup_bind(zmq_context, mocker): # Check PUB socket binding fake_socket.bind.assert_any_call("tcp://localhost:5556") # Check REP socket binding - fake_socket.bind.assert_any_call("tcp://localhost:7788") + fake_socket.bind.assert_called() # Check SUB socket connection and subscriptions fake_socket.connect.assert_any_call("tcp://internal:1234") @@ -63,7 +63,7 @@ async def test_setup_connect(zmq_context, mocker): fake_socket.connect.assert_any_call("tcp://localhost:5556") fake_socket.connect.assert_any_call("tcp://internal:1234") # Check REP socket binding (always binds) - fake_socket.bind.assert_any_call("tcp://localhost:7788") + fake_socket.bind.assert_called() # Check behavior was added (twice) assert agent.add_behavior.call_count == 2 diff --git a/test/unit/agents/actuation/test_robot_speech_agent.py b/test/unit/agents/actuation/test_robot_speech_agent.py index 15324f6..3cd8fbf 100644 --- a/test/unit/agents/actuation/test_robot_speech_agent.py +++ b/test/unit/agents/actuation/test_robot_speech_agent.py @@ -8,6 +8,11 @@ from control_backend.agents.actuation.robot_speech_agent import RobotSpeechAgent from control_backend.core.agent_system import InternalMessage +def mock_speech_agent(): + agent = RobotSpeechAgent("robot_speech", address="tcp://localhost:5555", bind=False) + return agent + + @pytest.fixture def zmq_context(mocker): mock_context = mocker.patch( @@ -56,7 +61,7 @@ async def test_setup_connect(zmq_context, mocker): async def test_handle_message_sends_command(): """Internal message is forwarded to robot pub socket as JSON.""" pubsocket = AsyncMock() - agent = RobotSpeechAgent("robot_speech") + agent = mock_speech_agent() agent.pubsocket = pubsocket payload = {"endpoint": "actuate/speech", "data": "hello"} @@ -80,7 +85,7 @@ async def test_zmq_command_loop_valid_payload(zmq_context): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotSpeechAgent("robot_speech") + agent = mock_speech_agent() agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -101,7 +106,7 @@ async def test_zmq_command_loop_invalid_json(): fake_socket.recv_multipart = recv_once fake_socket.send_json = AsyncMock() - agent = RobotSpeechAgent("robot_speech") + agent = mock_speech_agent() agent.subsocket = fake_socket agent.pubsocket = fake_socket agent._running = True @@ -115,7 +120,7 @@ async def test_zmq_command_loop_invalid_json(): async def test_handle_message_invalid_payload(): """Invalid payload is caught and does not send.""" pubsocket = AsyncMock() - agent = RobotSpeechAgent("robot_speech") + agent = mock_speech_agent() agent.pubsocket = pubsocket msg = InternalMessage(to="robot", sender="tester", body=json.dumps({"bad": "data"})) @@ -129,7 +134,7 @@ async def test_handle_message_invalid_payload(): async def test_stop_closes_sockets(): pubsocket = MagicMock() subsocket = MagicMock() - agent = RobotSpeechAgent("robot_speech") + agent = mock_speech_agent() agent.pubsocket = pubsocket agent.subsocket = subsocket diff --git a/test/unit/api/v1/endpoints/test_robot_endpoint.py b/test/unit/api/v1/endpoints/test_robot_endpoint.py index 71654d9..e9e637d 100644 --- a/test/unit/api/v1/endpoints/test_robot_endpoint.py +++ b/test/unit/api/v1/endpoints/test_robot_endpoint.py @@ -62,11 +62,11 @@ def test_receive_speech_command_success(client): speech_command = SpeechCommand(**command_data) # Act - response = client.post("/command", json=command_data) + response = client.post("/command/speech", json=command_data) # Assert assert response.status_code == 202 - assert response.json() == {"status": "Command received"} + assert response.json() == {"status": "Speech command received"} # Verify that the ZMQ socket was used correctly mock_pub_socket.send_multipart.assert_awaited_once_with( @@ -87,11 +87,11 @@ def test_receive_gesture_command_success(client): gesture_command = GestureCommand(**command_data) # Act - response = client.post("/command", json=command_data) + response = client.post("/command/gesture", json=command_data) # Assert assert response.status_code == 202 - assert response.json() == {"status": "Command received"} + assert response.json() == {"status": "Gesture command received"} # Verify that the ZMQ socket was used correctly mock_pub_socket.send_multipart.assert_awaited_once_with( @@ -99,13 +99,23 @@ def test_receive_gesture_command_success(client): ) -def test_receive_command_invalid_payload(client): +def test_receive_speech_command_invalid_payload(client): """ Test invalid data handling (schema validation). """ # Missing required field(s) bad_payload = {"invalid": "data"} - response = client.post("/command", json=bad_payload) + response = client.post("/command/speech", json=bad_payload) + assert response.status_code == 422 # validation error + + +def test_receive_gesture_command_invalid_payload(client): + """ + Test invalid data handling (schema validation). + """ + # Missing required field(s) + bad_payload = {"invalid": "data"} + response = client.post("/command/gesture", json=bad_payload) assert response.status_code == 422 # validation error @@ -243,7 +253,7 @@ async def test_get_available_gesture_tags_success(client, monkeypatch): monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act - response = client.get("/get_available_gesture_tags") + response = client.get("/commands/gesture/tags") # Assert assert response.status_code == 200 @@ -276,7 +286,7 @@ async def test_get_available_gesture_tags_with_amount(client, monkeypatch): monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act - response = client.get("/get_available_gesture_tags") + response = client.get("/commands/gesture/tags") # Assert assert response.status_code == 200 @@ -308,7 +318,7 @@ async def test_get_available_gesture_tags_timeout(client, monkeypatch): monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act - response = client.get("/get_available_gesture_tags") + response = client.get("/commands/gesture/tags") # Assert assert response.status_code == 200 @@ -342,7 +352,7 @@ async def test_get_available_gesture_tags_empty_response(client, monkeypatch): monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act - response = client.get("/get_available_gesture_tags") + response = client.get("/commands/gesture/tags") # Assert assert response.status_code == 200 @@ -369,7 +379,7 @@ async def test_get_available_gesture_tags_missing_tags_key(client, monkeypatch): monkeypatch.setattr(robot.logger, "error", MagicMock()) # Act - response = client.get("/get_available_gesture_tags") + response = client.get("/commands/gesture/tags") # Assert assert response.status_code == 200 @@ -397,7 +407,7 @@ async def test_get_available_gesture_tags_invalid_json(client, monkeypatch): monkeypatch.setattr(robot.logger, "debug", MagicMock()) # Act - response = client.get("/get_available_gesture_tags") + response = client.get("/commands/gesture/tags") # Assert - invalid JSON should lead to empty list and error log invocation assert response.status_code == 200 -- 2.49.1 From db5504db2001449190fbdd24b10a4438fc948ca8 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:22:11 +0100 Subject: [PATCH 13/13] chore: remove redundant check --- src/control_backend/api/v1/endpoints/robot.py | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index 21db77d..afbf1ac 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -3,9 +3,8 @@ import json import logging import zmq.asyncio -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse -from pydantic import ValidationError from zmq.asyncio import Context, Socket from control_backend.core.config import settings @@ -28,18 +27,10 @@ async def receive_command_speech(command: SpeechCommand, request: Request): :param command: The speech command payload. :param request: The FastAPI request object. """ - # Validate and retrieve data. - try: - validated = SpeechCommand.model_validate(command) - except ValidationError as e: - raise HTTPException( - status_code=422, detail=f"Payload is not valid SpeechCommand: {e}" - ) from e - topic = b"command" pub_socket: Socket = request.app.state.endpoints_pub_socket - await pub_socket.send_multipart([topic, validated.model_dump_json().encode()]) + await pub_socket.send_multipart([topic, command.model_dump_json().encode()]) return {"status": "Speech command received"} @@ -56,18 +47,10 @@ async def receive_command_gesture(command: GestureCommand, request: Request): :param command: The speech command payload. :param request: The FastAPI request object. """ - # Validate and retrieve data. - try: - validated = GestureCommand.model_validate(command) - except ValidationError as e: - raise HTTPException( - status_code=422, detail=f"Payload is not valid GestureCommand: {e}" - ) from e - topic = b"command" pub_socket: Socket = request.app.state.endpoints_pub_socket - await pub_socket.send_multipart([topic, validated.model_dump_json().encode()]) + await pub_socket.send_multipart([topic, command.model_dump_json().encode()]) return {"status": "Gesture command received"} -- 2.49.1