From c6916470e997757c32c619c600a12b36b0d83a79 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:06:27 +0200 Subject: [PATCH 01/11] feat: implement negotiation By implementing SocketBase and adding the socket to the state, the negotiation will automatically give the right endpoints. ref: N25B-168 --- .../endpoints/main_receiver.py | 19 +++++++-- .../endpoints/receiver_base.py | 6 +-- src/robot_interface/endpoints/socket_base.py | 41 +++++++++++++++++-- src/robot_interface/main.py | 8 +++- src/robot_interface/utils.py | 25 +++++++++++ 5 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 src/robot_interface/utils.py diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index 4ad7846..befa617 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -1,6 +1,7 @@ import zmq from robot_interface.endpoints.receiver_base import ReceiverBase +from robot_interface.state import state class MainReceiver(ReceiverBase): @@ -14,14 +15,20 @@ class MainReceiver(ReceiverBase): :param port: The port to use. :type port: int """ - super(MainReceiver, self).__init__("main") - self.create_socket(zmq_context, zmq.REP, port) + super(MainReceiver, self).__init__("main", "json") + self.create_socket(zmq_context, zmq.REP, port, bind=False) @staticmethod def _handle_ping(message): """A simple ping endpoint. Returns the provided data.""" return {"endpoint": "ping", "data": message.get("data")} + @staticmethod + def _handle_port_negotiation(message): + endpoints = [socket.endpoint_description() for socket in state.sockets] + + return {"endpoint": "negotiation/ports", "data": endpoints} + @staticmethod def _handle_negotiation(message): """ @@ -33,7 +40,11 @@ class MainReceiver(ReceiverBase): :return: A response dictionary with a 'ports' key containing a list of ports and their function. :rtype: dict[str, list[dict]] """ - # TODO: .../error on all endpoints? + # In the future, the sender could send information like the robot's IP address, etc. + + if message["endpoint"] == "negotiation/ports": + return MainReceiver._handle_port_negotiation(message) + return {"endpoint": "negotiation/error", "data": "The requested endpoint is not implemented."} def handle_message(self, message): @@ -42,7 +53,7 @@ class MainReceiver(ReceiverBase): if message["endpoint"] == "ping": return self._handle_ping(message) - elif message["endpoint"] == "negotiation": + elif message["endpoint"].startswith("negotiation"): return self._handle_negotiation(message) return {"endpoint": "error", "data": "The requested endpoint is not supported."} diff --git a/src/robot_interface/endpoints/receiver_base.py b/src/robot_interface/endpoints/receiver_base.py index b3183f7..3d42ec8 100644 --- a/src/robot_interface/endpoints/receiver_base.py +++ b/src/robot_interface/endpoints/receiver_base.py @@ -15,7 +15,7 @@ class ReceiverBase(SocketBase, object): :param message: The message to handle. :type message: dict - :return: A response message. - :rtype: dict + :return: A response message or None if this type of receiver doesn't publish. + :rtype: dict | None """ - return {"endpoint": "error", "data": "The requested receiver is not implemented."} + raise NotImplementedError() diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index a5124f6..27cb1e7 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -1,5 +1,9 @@ from abc import ABCMeta +import zmq + +from robot_interface.utils import zmq_socket_type_int_to_str, zmq_socket_type_complement + class SocketBase(object): __metaclass__ = ABCMeta @@ -7,15 +11,21 @@ class SocketBase(object): name = None socket = None - def __init__(self, name): + def __init__(self, name, data_type): """ :param name: The name of the endpoint. :type name: str + + :param data_type: The data type of the endpoint, e.g. "json", "binary", "text", etc. + :type data_type: str """ self.name = name - self.socket = None + self.data_type = data_type + self.port = None # Set later by `create_socket` + self.socket = None # Set later by `create_socket` + self.bound = None # Set later by `create_socket` - def create_socket(self, zmq_context, socket_type, port): + def create_socket(self, zmq_context, socket_type, port, bind=True): """ Create a ZeroMQ socket. @@ -27,12 +37,35 @@ class SocketBase(object): :param port: The port to use. :type port: int + + :param bind: Whether to bind the socket or connect to it. + :type bind: bool """ + self.port = port self.socket = zmq_context.socket(socket_type) - self.socket.connect("tcp://localhost:{}".format(port)) + self.bound = bind + if bind: + self.socket.bind("tcp://*:{}".format(port)) + else: + self.socket.connect("tcp://localhost:{}".format(port)) def close(self): """Close the ZeroMQ socket.""" if not self.socket: return self.socket.close() self.socket = None + + def endpoint_description(self): + """ + Description of the endpoint. Used for negotiation. + + :return: A dictionary with the following keys: name, port, pattern, data_type. See https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication#negotiation + :rtype: dict + """ + return { + "name": self.name, + "port": self.port, + "pattern": zmq_socket_type_int_to_str[zmq_socket_type_complement[self.socket.getsockopt(zmq.TYPE)]], + "data_type": self.data_type, + "bind": not self.bound + } diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index a203357..aba6f72 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -1,4 +1,5 @@ import logging +logging.basicConfig(level=logging.DEBUG) import time import zmq @@ -25,6 +26,8 @@ def main_loop(context): for receiver in receivers: poller.register(receiver.socket, zmq.POLLIN) + logging.debug("Starting main loop.") + while True: if state.exit_event.is_set(): break socks = dict(poller.poll(100)) @@ -36,7 +39,8 @@ def main_loop(context): message = receiver.socket.recv_json() response = receiver.handle_message(message) - receiver.socket.send_json(response) + if receiver.socket.getsockopt(zmq.TYPE) == zmq.REP: + receiver.socket.send_json(response) time_spent_ms = (time.time() - start_time) * 1000 if time_spent_ms > 50: @@ -51,7 +55,7 @@ def main(): try: main_loop(context) except KeyboardInterrupt: - print("User interrupted.") + logging.info("User interrupted.") finally: state.deinitialize() context.term() diff --git a/src/robot_interface/utils.py b/src/robot_interface/utils.py new file mode 100644 index 0000000..f2a74bd --- /dev/null +++ b/src/robot_interface/utils.py @@ -0,0 +1,25 @@ +zmq_socket_type_complement = { + 0: 0, # PAIR - PAIR + 1: 2, # PUB - SUB + 2: 1, # SUB - PUB + 3: 4, # REQ - REP + 4: 3, # REP - REQ + 5: 6, # DEALER - ROUTER + 6: 5, # ROUTER - DEALER + 7: 8, # PULL - PUSH + 8: 7, # PUSH - PULL +} + +zmq_socket_type_int_to_str = { + 0: "PAIR", + 1: "PUB", + 2: "SUB", + 3: "REQ", + 4: "REP", + 5: "DEALER", + 6: "ROUTER", + 7: "PULL", + 8: "PUSH", +} + +zmq_socket_type_str_to_int = {value: key for key, value in zmq_socket_type_int_to_str.items()} From ff6abbfea12d5d000a36435031ce13f00023c43a Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:08:43 +0200 Subject: [PATCH 02/11] feat: implement actuation receiver The ActuationReceiver connects to the Pepper robot using the Qi library. The endpoint is automatically negotiated. ref: N25B-168 --- .../endpoints/actuation_receiver.py | 68 +++++++++++++++++++ src/robot_interface/main.py | 5 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/robot_interface/endpoints/actuation_receiver.py diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py new file mode 100644 index 0000000..722bdf1 --- /dev/null +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -0,0 +1,68 @@ +import logging +import sys + +import zmq + +from robot_interface.endpoints.receiver_base import ReceiverBase + + +class ActuationReceiver(ReceiverBase): + def __init__(self, zmq_context, port=5556): + """ + The actuation receiver endpoint, responsible for handling speech and gesture requests. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use. + :type port: int + """ + super(ActuationReceiver, self).__init__("actuation", "json") + self.create_socket(zmq_context, zmq.SUB, port) + self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Subscribe to all topics + self._qi_session = self._get_session() + self._tts_service = None + + @staticmethod + def _get_session(): + if "--qi-url" not in sys.argv: + logging.info("No Qi URL argument given. Running in stand-alone mode.") + return None + + try: + import qi + except ImportError: + logging.info("Unable to import qi. Running in stand-alone mode.") + return None + + try: + app = qi.Application() + app.start() + return app.session + except RuntimeError: + logging.info("Unable to connect to the robot. Running in stand-alone mode.") + return None + + def _handle_speech(self, message): + if not self._qi_session: return + + if not self._tts_service: + self._tts_service = self._qi_session.service("ALTextToSpeech") + + text = message.get("data") + if not text: + logging.warn("Received message to speak, but it lacks data.") + return + + logging.debug("Speaking received message: {}".format(text)) + + self._tts_service.say(text) + + def handle_message(self, message): + if "endpoint" not in message: + return {"endpoint": "error", "data": "No endpoint provided."} + + if message["endpoint"] == "actuate/speech": + self._handle_speech(message) + + return {"endpoint": "error", "data": "The requested endpoint is not supported."} diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index aba6f72..dacb1ce 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -4,6 +4,7 @@ import time import zmq +from robot_interface.endpoints.actuation_receiver import ActuationReceiver from robot_interface.endpoints.main_receiver import MainReceiver from robot_interface.state import state @@ -18,9 +19,11 @@ def main_loop(context): # When creating sockets, remember to add them to the `sockets` list of the state to ensure they're deinitialized main_receiver = MainReceiver(context) state.sockets.append(main_receiver) + actuation_receiver = ActuationReceiver(context) + state.sockets.append(actuation_receiver) # Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most). - receivers = [main_receiver] + receivers = [main_receiver, actuation_receiver] poller = zmq.Poller() for receiver in receivers: From df985a8cbc80af4ae2c1a8908429b889bfd6dd81 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:58:31 +0200 Subject: [PATCH 03/11] fix: log speech commands even when Pepper SDK is not connected Previously, the `_handle_speech` function had an early return when no Pepper session was available, causing incoming messages not to get logged. Now messages are logged even when there is no session with the Pepper SDK. ref: N25B-168 --- src/robot_interface/endpoints/actuation_receiver.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 722bdf1..79a38ef 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -44,17 +44,17 @@ class ActuationReceiver(ReceiverBase): return None def _handle_speech(self, message): - if not self._qi_session: return - - if not self._tts_service: - self._tts_service = self._qi_session.service("ALTextToSpeech") - text = message.get("data") if not text: logging.warn("Received message to speak, but it lacks data.") return - logging.debug("Speaking received message: {}".format(text)) + logging.debug("Received message to speak: {}".format(text)) + + if not self._qi_session: return + + if not self._tts_service: + self._tts_service = self._qi_session.service("ALTextToSpeech") self._tts_service.say(text) From 308a19bff2ea7d4b2c9f0b354001405ad2ea5572 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:02:01 +0200 Subject: [PATCH 04/11] fix: correct negotiate endpoint name Was previously "negotiation/", but the API document described it as "negotiate/". It is now "negotiate/" in the implementation as well. ref: N25B-168 --- src/robot_interface/endpoints/main_receiver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index befa617..0ebd01a 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -27,7 +27,7 @@ class MainReceiver(ReceiverBase): def _handle_port_negotiation(message): endpoints = [socket.endpoint_description() for socket in state.sockets] - return {"endpoint": "negotiation/ports", "data": endpoints} + return {"endpoint": "negotiate/ports", "data": endpoints} @staticmethod def _handle_negotiation(message): @@ -42,10 +42,10 @@ class MainReceiver(ReceiverBase): """ # In the future, the sender could send information like the robot's IP address, etc. - if message["endpoint"] == "negotiation/ports": + if message["endpoint"] == "negotiate/ports": return MainReceiver._handle_port_negotiation(message) - return {"endpoint": "negotiation/error", "data": "The requested endpoint is not implemented."} + return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."} def handle_message(self, message): if "endpoint" not in message: From 23c3379bfb9e12054b602543d5930c9ebd3b4ba4 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:22:04 +0200 Subject: [PATCH 05/11] refactor: use new port negotiation style As changed in the API document, this now uses the new port negotiation style. ref: N25B-168 --- .../endpoints/actuation_receiver.py | 27 ++----------------- .../endpoints/main_receiver.py | 2 +- src/robot_interface/endpoints/socket_base.py | 18 ++++--------- src/robot_interface/endpoints/video_sender.py | 10 +++---- src/robot_interface/state.py | 5 ++++ src/robot_interface/utils.py | 25 ----------------- src/robot_interface/utils/__init__.py | 0 src/robot_interface/utils/qi_utils.py | 25 +++++++++++++++++ 8 files changed, 43 insertions(+), 69 deletions(-) delete mode 100644 src/robot_interface/utils.py create mode 100644 src/robot_interface/utils/__init__.py create mode 100644 src/robot_interface/utils/qi_utils.py diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 79a38ef..ca004f6 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -1,5 +1,4 @@ import logging -import sys import zmq @@ -17,32 +16,10 @@ class ActuationReceiver(ReceiverBase): :param port: The port to use. :type port: int """ - super(ActuationReceiver, self).__init__("actuation", "json") - self.create_socket(zmq_context, zmq.SUB, port) - self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Subscribe to all topics - self._qi_session = self._get_session() + super(ActuationReceiver, self).__init__("actuation") + self.create_socket(zmq_context, zmq.SUB, port, options=[(zmq.SUBSCRIBE, u"")]) self._tts_service = None - @staticmethod - def _get_session(): - if "--qi-url" not in sys.argv: - logging.info("No Qi URL argument given. Running in stand-alone mode.") - return None - - try: - import qi - except ImportError: - logging.info("Unable to import qi. Running in stand-alone mode.") - return None - - try: - app = qi.Application() - app.start() - return app.session - except RuntimeError: - logging.info("Unable to connect to the robot. Running in stand-alone mode.") - return None - def _handle_speech(self, message): text = message.get("data") if not text: diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index 0ebd01a..cce4f96 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -15,7 +15,7 @@ class MainReceiver(ReceiverBase): :param port: The port to use. :type port: int """ - super(MainReceiver, self).__init__("main", "json") + super(MainReceiver, self).__init__("main") self.create_socket(zmq_context, zmq.REP, port, bind=False) @staticmethod diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index 3419349..fc351ad 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -2,8 +2,6 @@ from abc import ABCMeta import zmq -from robot_interface.utils import zmq_socket_type_int_to_str, zmq_socket_type_complement - class SocketBase(object): __metaclass__ = ABCMeta @@ -11,16 +9,12 @@ class SocketBase(object): name = None socket = None - def __init__(self, name, data_type): + def __init__(self, identifier): """ - :param name: The name of the endpoint. - :type name: str - - :param data_type: The data type of the endpoint, e.g. "json", "binary", "text", etc. - :type data_type: str + :param identifier: The identifier of the endpoint. + :type identifier: str """ - self.name = name - self.data_type = data_type + self.identifier = identifier self.port = None # Set later by `create_socket` self.socket = None # Set later by `create_socket` self.bound = None # Set later by `create_socket` @@ -71,9 +65,7 @@ class SocketBase(object): :rtype: dict """ return { - "name": self.name, + "id": self.identifier, "port": self.port, - "pattern": zmq_socket_type_int_to_str[zmq_socket_type_complement[self.socket.getsockopt(zmq.TYPE)]], - "data_type": self.data_type, "bind": not self.bound } diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 793385b..41a9e6e 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -15,12 +15,12 @@ class VideoSender(SocketBase): def start_video_rcv(self): """ Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. - """ - app = qi.Application() - app.start() - session = app.session + """ + if not state.qi_session: + logging.info("No QI session available, not starting video loop") + return - video = session.service("ALVideoDevice") + video = state.session.service("ALVideoDevice") camera_index = 0 kQVGA = 2 diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index d10cf77..b6f8ce1 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -2,6 +2,8 @@ import logging import signal import threading +from robot_interface.utils.qi_utils import get_qi_session + class State(object): """ @@ -15,6 +17,7 @@ class State(object): self.is_initialized = False self.exit_event = None self.sockets = [] # type: List[SocketBase] + self.qi_session = None # type: None | ssl.SSLSession def initialize(self): if self.is_initialized: @@ -28,6 +31,8 @@ class State(object): signal.signal(signal.SIGINT, handle_exit) signal.signal(signal.SIGTERM, handle_exit) + self.qi_session = get_qi_session() + self.is_initialized = True def deinitialize(self): diff --git a/src/robot_interface/utils.py b/src/robot_interface/utils.py deleted file mode 100644 index f2a74bd..0000000 --- a/src/robot_interface/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -zmq_socket_type_complement = { - 0: 0, # PAIR - PAIR - 1: 2, # PUB - SUB - 2: 1, # SUB - PUB - 3: 4, # REQ - REP - 4: 3, # REP - REQ - 5: 6, # DEALER - ROUTER - 6: 5, # ROUTER - DEALER - 7: 8, # PULL - PUSH - 8: 7, # PUSH - PULL -} - -zmq_socket_type_int_to_str = { - 0: "PAIR", - 1: "PUB", - 2: "SUB", - 3: "REQ", - 4: "REP", - 5: "DEALER", - 6: "ROUTER", - 7: "PULL", - 8: "PUSH", -} - -zmq_socket_type_str_to_int = {value: key for key, value in zmq_socket_type_int_to_str.items()} diff --git a/src/robot_interface/utils/__init__.py b/src/robot_interface/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robot_interface/utils/qi_utils.py b/src/robot_interface/utils/qi_utils.py new file mode 100644 index 0000000..fc7640b --- /dev/null +++ b/src/robot_interface/utils/qi_utils.py @@ -0,0 +1,25 @@ +import logging +import sys + +try: + import qi +except ImportError: + qi = None + + +def get_qi_session(): + if qi is None: + logging.info("Unable to import qi. Running in stand-alone mode.") + return None + + if "--qi-url" not in sys.argv: + logging.info("No Qi URL argument given. Running in stand-alone mode.") + return None + + try: + app = qi.Application() + app.start() + return app.session + except RuntimeError: + logging.info("Unable to connect to the robot. Running in stand-alone mode.") + return None From c10fbc7c90086fad87356dda4e53b5afd22485cc Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 17:37:01 +0200 Subject: [PATCH 06/11] fix: use different port, fix endpoint name matching ref: N25B-168 --- src/robot_interface/endpoints/actuation_receiver.py | 5 +++-- src/robot_interface/endpoints/main_receiver.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index ca004f6..528cbb4 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -6,7 +6,7 @@ from robot_interface.endpoints.receiver_base import ReceiverBase class ActuationReceiver(ReceiverBase): - def __init__(self, zmq_context, port=5556): + def __init__(self, zmq_context, port=5557): """ The actuation receiver endpoint, responsible for handling speech and gesture requests. @@ -17,7 +17,8 @@ class ActuationReceiver(ReceiverBase): :type port: int """ super(ActuationReceiver, self).__init__("actuation") - self.create_socket(zmq_context, zmq.SUB, port, options=[(zmq.SUBSCRIBE, u"")]) + self.create_socket(zmq_context, zmq.SUB, port) + self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") self._tts_service = None def _handle_speech(self, message): diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index cce4f96..4589aed 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -53,7 +53,7 @@ class MainReceiver(ReceiverBase): if message["endpoint"] == "ping": return self._handle_ping(message) - elif message["endpoint"].startswith("negotiation"): + elif message["endpoint"].startswith("negotiate"): return self._handle_negotiation(message) return {"endpoint": "error", "data": "The requested endpoint is not supported."} From 55483808fff45299b49b68e6d62a1ab8ef833c53 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:09:01 +0200 Subject: [PATCH 07/11] fix: use qi session from state in actuation receiver ref: N25B-168 --- src/robot_interface/endpoints/actuation_receiver.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 528cbb4..c7bfe91 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -3,6 +3,7 @@ import logging import zmq from robot_interface.endpoints.receiver_base import ReceiverBase +from robot_interface.state import state class ActuationReceiver(ReceiverBase): @@ -18,7 +19,7 @@ class ActuationReceiver(ReceiverBase): """ super(ActuationReceiver, self).__init__("actuation") self.create_socket(zmq_context, zmq.SUB, port) - self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") + self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Can this not be given in the options? self._tts_service = None def _handle_speech(self, message): @@ -29,10 +30,10 @@ class ActuationReceiver(ReceiverBase): logging.debug("Received message to speak: {}".format(text)) - if not self._qi_session: return + if not state.qi_session: return if not self._tts_service: - self._tts_service = self._qi_session.service("ALTextToSpeech") + self._tts_service = state.qi_session.service("ALTextToSpeech") self._tts_service.say(text) From 56c804b7eb909ce1df36768e91f7b8c5e8339b68 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:43:24 +0200 Subject: [PATCH 08/11] test: add unit tests for main and actuation receivers Exhaustive test cases for both classes, with 100% coverage. Adds `mock` dependency. Tests for actuation receiver do not yet pass. ref: N25B-168 --- README.md | 29 ++++++- requirements.txt | 1 + .../endpoints/receiver_base.py | 2 +- src/robot_interface/endpoints/video_sender.py | 2 +- test/unit/__init__.py | 0 test/unit/test_actuation_receiver.py | 69 ++++++++++++++++ test/unit/test_main_receiver.py | 79 +++++++++++++++++++ 7 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 test/unit/__init__.py create mode 100644 test/unit/test_actuation_receiver.py create mode 100644 test/unit/test_main_receiver.py diff --git a/README.md b/README.md index e7cfd21..6fffab0 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,32 @@ Then resume the steps from above. ## Usage +On Linux and macOS: + ```shell -cd src -python -m robot_interface.main +PYTHONPATH=src python -m robot_interface.main +``` + +On Windows: + +```shell +$env:PYTHONPATH="src"; python -m robot_interface.main +``` + +With both, if you want to connect to the actual robot (or simulator), pass the `--qi-url` argument. + + + +## Testing + +To run the unit tests, on Linux and macOS: + +```shell +PYTHONPATH=src python -m unittest discover -s test -p "test_*.py" -v +``` + +On Windows: + +```shell +$env:PYTHONPATH="src"; python -m unittest discover -s test -p "test_*.py" -v ``` diff --git a/requirements.txt b/requirements.txt index aee002a..84b4d20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyzmq<16 pyaudio<=0.2.11 +mock~=3.0.5 diff --git a/src/robot_interface/endpoints/receiver_base.py b/src/robot_interface/endpoints/receiver_base.py index 3d42ec8..498d38b 100644 --- a/src/robot_interface/endpoints/receiver_base.py +++ b/src/robot_interface/endpoints/receiver_base.py @@ -12,7 +12,7 @@ class ReceiverBase(SocketBase, object): """ Handle a message with the receiver. - :param message: The message to handle. + :param message: The message to handle, must contain properties "endpoint" and "data". :type message: dict :return: A response message or None if this type of receiver doesn't publish. diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 41a9e6e..19d58a1 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -20,7 +20,7 @@ class VideoSender(SocketBase): logging.info("No QI session available, not starting video loop") return - video = state.session.service("ALVideoDevice") + video = state.qi_session.service("ALVideoDevice") camera_index = 0 kQVGA = 2 diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py new file mode 100644 index 0000000..11039e6 --- /dev/null +++ b/test/unit/test_actuation_receiver.py @@ -0,0 +1,69 @@ +import sys +import unittest + +import mock +import zmq + +from robot_interface.endpoints.actuation_receiver import ActuationReceiver + + +class TestActuationReceiver(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.context = zmq.Context() + + def test_handle_unimplemented_endpoint(self): + receiver = ActuationReceiver(self.context) + # Should not error + receiver.handle_message({ + "endpoint": "some_endpoint_that_definitely_does_not_exist", + "data": None, + }) + + @mock.patch("logging.warn") + def test_speech_message_no_data(self, mock_warn): + receiver = ActuationReceiver(self.context) + receiver.handle_message({"endpoint": "actuate/speech", "data": ""}) + + mock_warn.assert_called_with(mock.ANY) + + @mock.patch("logging.warn") + def test_speech_message_invalid_data(self, mock_warn): + receiver = ActuationReceiver(self.context) + receiver.handle_message({"endpoint": "actuate/speech", "data": True}) + + mock_warn.assert_called_with(mock.ANY) + + @mock.patch("robot_interface.endpoints.actuation_receiver.state") + def test_speech_no_qi(self, mock_state): + mock_qi_session = mock.PropertyMock(return_value=None) + type(mock_state).qi_session = mock_qi_session + + receiver = ActuationReceiver(self.context) + receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) + + mock_qi_session.assert_called() + + @mock.patch("robot_interface.endpoints.actuation_receiver.state") + def test_speech(self, mock_state): + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + mock_tts_service = mock.Mock() + mock_state.qi_session = mock.Mock() + mock_state.qi_session.service.return_value = mock_tts_service + + receiver = ActuationReceiver(self.context) + receiver._tts_service = None + receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) + + mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech") + + mock_qi.async.assert_called_once() + call_args = mock_qi.async.call_args[0] + self.assertEqual(call_args[0], mock_tts_service.say) + self.assertEqual(call_args[1], "Some message to speak.") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit/test_main_receiver.py b/test/unit/test_main_receiver.py new file mode 100644 index 0000000..31c34cc --- /dev/null +++ b/test/unit/test_main_receiver.py @@ -0,0 +1,79 @@ +import unittest + +import mock +import zmq + +from robot_interface.endpoints.main_receiver import MainReceiver + + +class TestMainReceiver(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.context = zmq.Context() + + def test_handle_ping(self): + receiver = MainReceiver(self.context) + response = receiver.handle_message({"endpoint": "ping", "data": "pong"}) + + self.assertIn("endpoint", response) + self.assertEqual(response["endpoint"], "ping") + self.assertIn("data", response) + self.assertEqual(response["data"], "pong") + + def test_handle_ping_none(self): + receiver = MainReceiver(self.context) + response = receiver.handle_message({"endpoint": "ping", "data": None}) + + self.assertIn("endpoint", response) + self.assertEqual(response["endpoint"], "ping") + self.assertIn("data", response) + self.assertEqual(response["data"], None) + + @mock.patch("robot_interface.endpoints.main_receiver.state") + def test_handle_negotiate_ports(self, mock_state): + receiver = MainReceiver(self.context) + mock_state.sockets = [receiver] + + response = receiver.handle_message({"endpoint": "negotiate/ports", "data": None}) + + self.assertIn("endpoint", response) + self.assertEqual(response["endpoint"], "negotiate/ports") + self.assertIn("data", response) + self.assertIsInstance(response["data"], list) + for port in response["data"]: + self.assertIn("id", port) + self.assertIsInstance(port["id"], str) + self.assertIn("port", port) + self.assertIsInstance(port["port"], int) + self.assertIn("bind", port) + self.assertIsInstance(port["bind"], bool) + + self.assertTrue(any(port["id"] == "main" for port in response["data"])) + + def test_handle_unimplemented_endpoint(self): + receiver = MainReceiver(self.context) + response = receiver.handle_message({ + "endpoint": "some_endpoint_that_definitely_does_not_exist", + "data": None, + }) + + self.assertIn("endpoint", response) + self.assertEqual(response["endpoint"], "error") + self.assertIn("data", response) + self.assertIsInstance(response["data"], str) + + def test_handle_unimplemented_negotiation_endpoint(self): + receiver = MainReceiver(self.context) + response = receiver.handle_message({ + "endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist", + "data": None, + }) + + self.assertIn("endpoint", response) + self.assertEqual(response["endpoint"], "negotiate/error") + self.assertIn("data", response) + self.assertIsInstance(response["data"], str) + + +if __name__ == "__main__": + unittest.main() From 4c3aa3a91102de83a866eb35a407a8dad64ee272 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:46:46 +0200 Subject: [PATCH 09/11] feat: adapt actuation receiver to state's qi_session Makes actuation tests pass. In main, the timing of the socket no longer contains the time to receive and send data, but only the processing time of the message handler. ref: N25B-168 --- .../endpoints/actuation_receiver.py | 14 ++++--- .../endpoints/main_receiver.py | 3 -- src/robot_interface/main.py | 20 ++++++---- src/robot_interface/utils/timeblock.py | 31 ++++++++++++++ test/unit/test_time_block.py | 40 +++++++++++++++++++ 5 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 src/robot_interface/utils/timeblock.py create mode 100644 test/unit/test_time_block.py diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index c7bfe91..0eb9077 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -28,20 +28,22 @@ class ActuationReceiver(ReceiverBase): logging.warn("Received message to speak, but it lacks data.") return + if not isinstance(text, (str, unicode)): + logging.warn("Received message to speak but it is not a string.") + return + logging.debug("Received message to speak: {}".format(text)) if not state.qi_session: return + # If state has a qi_session, we know that we can import qi + import qi # Takes a while only the first time it's imported if not self._tts_service: self._tts_service = state.qi_session.service("ALTextToSpeech") - self._tts_service.say(text) + # Returns instantly. Messages received while speaking will be queued. + qi.async(self._tts_service.say, text) def handle_message(self, message): - if "endpoint" not in message: - return {"endpoint": "error", "data": "No endpoint provided."} - if message["endpoint"] == "actuate/speech": self._handle_speech(message) - - return {"endpoint": "error", "data": "The requested endpoint is not supported."} diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index 4589aed..0ce9711 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -48,9 +48,6 @@ class MainReceiver(ReceiverBase): return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."} def handle_message(self, message): - if "endpoint" not in message: - return {"endpoint": "error", "data": "No endpoint provided."} - if message["endpoint"] == "ping": return self._handle_ping(message) elif message["endpoint"].startswith("negotiate"): diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 8176740..934dfd3 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -1,6 +1,5 @@ import logging logging.basicConfig(level=logging.DEBUG) -import time import zmq @@ -8,6 +7,7 @@ from robot_interface.endpoints.actuation_receiver import ActuationReceiver from robot_interface.endpoints.main_receiver import MainReceiver from robot_interface.endpoints.video_sender import VideoSender from robot_interface.state import state +from robot_interface.utils.timeblock import TimeBlock def main_loop(context): @@ -44,17 +44,21 @@ def main_loop(context): for receiver in receivers: if receiver.socket not in socks: continue - start_time = time.time() - message = receiver.socket.recv_json() - response = receiver.handle_message(message) + if not isinstance(message, dict) or "endpoint" not in message or "data" not in message: + logging.error("Received message of unexpected format: {}".format(message)) + continue + + def overtime_callback(time_ms): + logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", + message["endpoint"], time_ms) + + with TimeBlock(overtime_callback, 50): + response = receiver.handle_message(message) + if receiver.socket.getsockopt(zmq.TYPE) == zmq.REP: receiver.socket.send_json(response) - time_spent_ms = (time.time() - start_time) * 1000 - if time_spent_ms > 50: - logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", receiver.name, time_spent_ms) - def main(): context = zmq.Context() diff --git a/src/robot_interface/utils/timeblock.py b/src/robot_interface/utils/timeblock.py new file mode 100644 index 0000000..23f1c85 --- /dev/null +++ b/src/robot_interface/utils/timeblock.py @@ -0,0 +1,31 @@ +import time + + +class TimeBlock(object): + """ + A context manager that times the execution of the block it contains. If execution exceeds the + limit, or if no limit is given, the callback will be called with the time that the block took. + """ + def __init__(self, callback, limit_ms=None): + """ + :param callback: The callback function that is called when the block of code is over, + unless the code block did not exceed the time limit. + :type callback: Callable[[float], None] + + :param limit_ms: The number of milliseconds the block of code is allowed to take. If it + exceeds this time, or if it's None, the callback function will be called with the time the + block took. + :type limit_ms: int | None + """ + self.limit_ms = float(limit_ms) if limit_ms is not None else None + self.callback = callback + self.start = None + + def __enter__(self): + self.start = time.time() + return self + + def __exit__(self, exc_type, exc_value, traceback): + elapsed = (time.time() - self.start) * 1000.0 # ms + if self.limit_ms is None or elapsed > self.limit_ms: + self.callback(elapsed) diff --git a/test/unit/test_time_block.py b/test/unit/test_time_block.py new file mode 100644 index 0000000..b6cabbc --- /dev/null +++ b/test/unit/test_time_block.py @@ -0,0 +1,40 @@ +import unittest + +import mock + +from robot_interface.utils.timeblock import TimeBlock + + +class AnyFloat(object): + def __eq__(self, other): + return isinstance(other, float) + + +class TestTimeBlock(unittest.TestCase): + def test_no_limit(self): + callback = mock.Mock() + + with TimeBlock(callback): + pass + + callback.assert_called_once_with(AnyFloat()) + + def test_exceed_limit(self): + callback = mock.Mock() + + with TimeBlock(callback, 0): + pass + + callback.assert_called_once_with(AnyFloat()) + + def test_within_limit(self): + callback = mock.Mock() + + with TimeBlock(callback, 5): + pass + + callback.assert_not_called() + + +if __name__ == '__main__': + unittest.main() From 45be0366ba267e516f4459951512119391f6839a Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 16 Oct 2025 22:03:50 +0200 Subject: [PATCH 10/11] style: correct and clarify docs and comments ref: N25B-168 --- src/robot_interface/endpoints/actuation_receiver.py | 2 +- src/robot_interface/endpoints/socket_base.py | 3 ++- src/robot_interface/endpoints/video_sender.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 0eb9077..7fe16b7 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -19,7 +19,7 @@ class ActuationReceiver(ReceiverBase): """ super(ActuationReceiver, self).__init__("actuation") self.create_socket(zmq_context, zmq.SUB, port) - self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Can this not be given in the options? + self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options self._tts_service = None def _handle_speech(self, message): diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index fc351ad..d08c360 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -61,7 +61,8 @@ class SocketBase(object): """ Description of the endpoint. Used for negotiation. - :return: A dictionary with the following keys: name, port, pattern, data_type. See https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication#negotiation + :return: A dictionary with the following keys: id, port, bind. See API specification at: + https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication#negotiation :rtype: dict """ return { diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 19d58a1..c46b768 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -17,7 +17,7 @@ class VideoSender(SocketBase): Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. """ if not state.qi_session: - logging.info("No QI session available, not starting video loop") + logging.info("No Qi session available. Not starting video loop.") return video = state.qi_session.service("ALVideoDevice") From 5631a55697a815b102e239bef0f5bdf0f40ea5ca Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:55:06 +0200 Subject: [PATCH 11/11] test: convert to pytest Instead of built-in `unittest`, now use `pytest`. Find versions that work, convert tests. ref: N25B-168 --- README.md | 8 +- requirements.txt | 4 +- test/unit/test_actuation_receiver.py | 123 ++++++++++++----------- test/unit/test_main_receiver.py | 142 +++++++++++++-------------- test/unit/test_time_block.py | 47 +++++---- 5 files changed, 166 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 6fffab0..5a10267 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,15 @@ With both, if you want to connect to the actual robot (or simulator), pass the ` To run the unit tests, on Linux and macOS: ```shell -PYTHONPATH=src python -m unittest discover -s test -p "test_*.py" -v +PYTHONPATH=src pytest test/ ``` On Windows: ```shell -$env:PYTHONPATH="src"; python -m unittest discover -s test -p "test_*.py" -v +$env:PYTHONPATH="src"; pytest test/ ``` + +### Coverage + +For coverage, add `--cov=robot_interface` as an argument to `pytest`. diff --git a/requirements.txt b/requirements.txt index 84b4d20..f93c70d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ pyzmq<16 pyaudio<=0.2.11 -mock~=3.0.5 +pytest<5 +pytest-mock<3.0.0 +pytest-cov<3.0.0 diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py index 11039e6..da70964 100644 --- a/test/unit/test_actuation_receiver.py +++ b/test/unit/test_actuation_receiver.py @@ -1,69 +1,74 @@ import sys -import unittest import mock +import pytest import zmq from robot_interface.endpoints.actuation_receiver import ActuationReceiver -class TestActuationReceiver(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.context = zmq.Context() - - def test_handle_unimplemented_endpoint(self): - receiver = ActuationReceiver(self.context) - # Should not error - receiver.handle_message({ - "endpoint": "some_endpoint_that_definitely_does_not_exist", - "data": None, - }) - - @mock.patch("logging.warn") - def test_speech_message_no_data(self, mock_warn): - receiver = ActuationReceiver(self.context) - receiver.handle_message({"endpoint": "actuate/speech", "data": ""}) - - mock_warn.assert_called_with(mock.ANY) - - @mock.patch("logging.warn") - def test_speech_message_invalid_data(self, mock_warn): - receiver = ActuationReceiver(self.context) - receiver.handle_message({"endpoint": "actuate/speech", "data": True}) - - mock_warn.assert_called_with(mock.ANY) - - @mock.patch("robot_interface.endpoints.actuation_receiver.state") - def test_speech_no_qi(self, mock_state): - mock_qi_session = mock.PropertyMock(return_value=None) - type(mock_state).qi_session = mock_qi_session - - receiver = ActuationReceiver(self.context) - receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) - - mock_qi_session.assert_called() - - @mock.patch("robot_interface.endpoints.actuation_receiver.state") - def test_speech(self, mock_state): - mock_qi = mock.Mock() - sys.modules["qi"] = mock_qi - - mock_tts_service = mock.Mock() - mock_state.qi_session = mock.Mock() - mock_state.qi_session.service.return_value = mock_tts_service - - receiver = ActuationReceiver(self.context) - receiver._tts_service = None - receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) - - mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech") - - mock_qi.async.assert_called_once() - call_args = mock_qi.async.call_args[0] - self.assertEqual(call_args[0], mock_tts_service.say) - self.assertEqual(call_args[1], "Some message to speak.") +@pytest.fixture +def zmq_context(): + context = zmq.Context() + yield context -if __name__ == "__main__": - unittest.main() +def test_handle_unimplemented_endpoint(zmq_context): + receiver = ActuationReceiver(zmq_context) + # Should not error + receiver.handle_message({ + "endpoint": "some_endpoint_that_definitely_does_not_exist", + "data": None, + }) + + +def test_speech_message_no_data(zmq_context, mocker): + mock_warn = mocker.patch("logging.warn") + + receiver = ActuationReceiver(zmq_context) + receiver.handle_message({"endpoint": "actuate/speech", "data": ""}) + + mock_warn.assert_called_with(mock.ANY) + + +def test_speech_message_invalid_data(zmq_context, mocker): + mock_warn = mocker.patch("logging.warn") + + receiver = ActuationReceiver(zmq_context) + receiver.handle_message({"endpoint": "actuate/speech", "data": True}) + + mock_warn.assert_called_with(mock.ANY) + + +def test_speech_no_qi(zmq_context, mocker): + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + mock_qi_session = mock.PropertyMock(return_value=None) + type(mock_state).qi_session = mock_qi_session + + receiver = ActuationReceiver(zmq_context) + receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) + + mock_qi_session.assert_called() + + +def test_speech(zmq_context, mocker): + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + mock_tts_service = mock.Mock() + mock_state.qi_session = mock.Mock() + mock_state.qi_session.service.return_value = mock_tts_service + + receiver = ActuationReceiver(zmq_context) + receiver._tts_service = None + receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) + + mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech") + + mock_qi.async.assert_called_once() + call_args = mock_qi.async.call_args[0] + assert call_args[0] == mock_tts_service.say + assert call_args[1] == "Some message to speak." diff --git a/test/unit/test_main_receiver.py b/test/unit/test_main_receiver.py index 31c34cc..4ded502 100644 --- a/test/unit/test_main_receiver.py +++ b/test/unit/test_main_receiver.py @@ -1,79 +1,79 @@ -import unittest - import mock +import pytest import zmq from robot_interface.endpoints.main_receiver import MainReceiver -class TestMainReceiver(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.context = zmq.Context() - - def test_handle_ping(self): - receiver = MainReceiver(self.context) - response = receiver.handle_message({"endpoint": "ping", "data": "pong"}) - - self.assertIn("endpoint", response) - self.assertEqual(response["endpoint"], "ping") - self.assertIn("data", response) - self.assertEqual(response["data"], "pong") - - def test_handle_ping_none(self): - receiver = MainReceiver(self.context) - response = receiver.handle_message({"endpoint": "ping", "data": None}) - - self.assertIn("endpoint", response) - self.assertEqual(response["endpoint"], "ping") - self.assertIn("data", response) - self.assertEqual(response["data"], None) - - @mock.patch("robot_interface.endpoints.main_receiver.state") - def test_handle_negotiate_ports(self, mock_state): - receiver = MainReceiver(self.context) - mock_state.sockets = [receiver] - - response = receiver.handle_message({"endpoint": "negotiate/ports", "data": None}) - - self.assertIn("endpoint", response) - self.assertEqual(response["endpoint"], "negotiate/ports") - self.assertIn("data", response) - self.assertIsInstance(response["data"], list) - for port in response["data"]: - self.assertIn("id", port) - self.assertIsInstance(port["id"], str) - self.assertIn("port", port) - self.assertIsInstance(port["port"], int) - self.assertIn("bind", port) - self.assertIsInstance(port["bind"], bool) - - self.assertTrue(any(port["id"] == "main" for port in response["data"])) - - def test_handle_unimplemented_endpoint(self): - receiver = MainReceiver(self.context) - response = receiver.handle_message({ - "endpoint": "some_endpoint_that_definitely_does_not_exist", - "data": None, - }) - - self.assertIn("endpoint", response) - self.assertEqual(response["endpoint"], "error") - self.assertIn("data", response) - self.assertIsInstance(response["data"], str) - - def test_handle_unimplemented_negotiation_endpoint(self): - receiver = MainReceiver(self.context) - response = receiver.handle_message({ - "endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist", - "data": None, - }) - - self.assertIn("endpoint", response) - self.assertEqual(response["endpoint"], "negotiate/error") - self.assertIn("data", response) - self.assertIsInstance(response["data"], str) +@pytest.fixture +def zmq_context(): + context = zmq.Context() + yield context -if __name__ == "__main__": - unittest.main() +def test_handle_ping(zmq_context): + receiver = MainReceiver(zmq_context) + response = receiver.handle_message({"endpoint": "ping", "data": "pong"}) + + assert "endpoint" in response + assert response["endpoint"] == "ping" + assert "data" in response + assert response["data"] == "pong" + + +def test_handle_ping_none(zmq_context): + receiver = MainReceiver(zmq_context) + response = receiver.handle_message({"endpoint": "ping", "data": None}) + + assert "endpoint" in response + assert response["endpoint"] == "ping" + assert "data" in response + assert response["data"] == None + + +@mock.patch("robot_interface.endpoints.main_receiver.state") +def test_handle_negotiate_ports(mock_state, zmq_context): + receiver = MainReceiver(zmq_context) + mock_state.sockets = [receiver] + + response = receiver.handle_message({"endpoint": "negotiate/ports", "data": None}) + + assert "endpoint" in response + assert response["endpoint"] == "negotiate/ports" + assert "data" in response + assert isinstance(response["data"], list) + for port in response["data"]: + assert "id" in port + assert isinstance(port["id"], str) + assert "port" in port + assert isinstance(port["port"], int) + assert "bind" in port + assert isinstance(port["bind"], bool) + + assert any(port["id"] == "main" for port in response["data"]) + + +def test_handle_unimplemented_endpoint(zmq_context): + receiver = MainReceiver(zmq_context) + response = receiver.handle_message({ + "endpoint": "some_endpoint_that_definitely_does_not_exist", + "data": None, + }) + + assert "endpoint" in response + assert response["endpoint"] == "error" + assert "data" in response + assert isinstance(response["data"], str) + + +def test_handle_unimplemented_negotiation_endpoint(zmq_context): + receiver = MainReceiver(zmq_context) + response = receiver.handle_message({ + "endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist", + "data": None, + }) + + assert "endpoint" in response + assert response["endpoint"] == "negotiate/error" + assert "data" in response + assert isinstance(response["data"], str) diff --git a/test/unit/test_time_block.py b/test/unit/test_time_block.py index b6cabbc..eabc91b 100644 --- a/test/unit/test_time_block.py +++ b/test/unit/test_time_block.py @@ -1,4 +1,4 @@ -import unittest +import time import mock @@ -10,31 +10,28 @@ class AnyFloat(object): return isinstance(other, float) -class TestTimeBlock(unittest.TestCase): - def test_no_limit(self): - callback = mock.Mock() +def test_no_limit(): + callback = mock.Mock() - with TimeBlock(callback): - pass + with TimeBlock(callback): + pass - callback.assert_called_once_with(AnyFloat()) - - def test_exceed_limit(self): - callback = mock.Mock() - - with TimeBlock(callback, 0): - pass - - callback.assert_called_once_with(AnyFloat()) - - def test_within_limit(self): - callback = mock.Mock() - - with TimeBlock(callback, 5): - pass - - callback.assert_not_called() + callback.assert_called_once_with(AnyFloat()) -if __name__ == '__main__': - unittest.main() +def test_exceed_limit(): + callback = mock.Mock() + + with TimeBlock(callback, 0): + time.sleep(0.001) + + callback.assert_called_once_with(AnyFloat()) + + +def test_within_limit(): + callback = mock.Mock() + + with TimeBlock(callback, 5): + pass + + callback.assert_not_called()