From eb091968a6651f463dd885867bcab019fb9a8334 Mon Sep 17 00:00:00 2001 From: 2584433 Date: Tue, 7 Oct 2025 14:55:04 +0000 Subject: [PATCH 01/23] Added githooks --- .githooks/commit-msg | 17 +++++++++++++++++ .githooks/pre-commit | 18 ++++++++++++++++++ .githooks/prepare-commit-msg | 9 +++++++++ README.md | 14 ++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 .githooks/commit-msg create mode 100644 .githooks/pre-commit create mode 100644 .githooks/prepare-commit-msg diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 0000000..dd14401 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,17 @@ +#!/bin/sh + +commit_msg_file=$1 +commit_msg=$(cat "$commit_msg_file") + +if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then + if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then + echo "🎉 commit message is Valid" + exit 0 + else + echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000" + exit 1 + fi +else + echo "❌ Commit message invalid! Must start with : " + exit 1 +fi \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..391d279 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,18 @@ +#!/bin/sh + +# Get current branch +branch=$(git rev-parse --abbrev-ref HEAD) + +if echo "$branch" | grep -Eq "(dev|main)"; then + echo 0 +fi + +# allowed pattern +if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+-\w+-\w+"; then + echo "✅ Branch name valid: $branch" + exit 0 +else + echo "❌ Invalid branch name: $branch" + echo "Branch must be named / (must have 2 stipes - -)" + exit 1 +fi \ No newline at end of file diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100644 index 0000000..5b706c1 --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,9 @@ +#!/bin/sh + +echo "#: + +#[optional body] + +#[optional footer(s)] + +#[ref/close]: " > $1 \ No newline at end of file diff --git a/README.md b/README.md index 9a91839..396501f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ # PepperPlus-RI +## GitHooks + +To activate automatic commits/branch name checks run: + +```shell +git config --local core.hooksPath .githooks +``` + +If your commit fails its either: +branch name != /description-of-branch +commit name != : description of the commit. + : N25B-Num's + + ## Getting started From 99776480e80dbd9cda15a7d1e340c0dc9fd4a7d2 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:25:51 +0200 Subject: [PATCH 02/23] chore: correct commit hook regex Previously all branch names had to have two dashes. Now it can have one to six words. ref: N25B-89 --- .githooks/pre-commit | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 391d279..ed801d8 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -8,11 +8,11 @@ if echo "$branch" | grep -Eq "(dev|main)"; then fi # allowed pattern -if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+-\w+-\w+"; then +if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then echo "✅ Branch name valid: $branch" exit 0 else echo "❌ Invalid branch name: $branch" - echo "Branch must be named / (must have 2 stipes - -)" + echo "Branch must be named / (must have one to six words separated by a dash)" exit 1 fi \ No newline at end of file From c4530f0c3a4f38444bc271458d2abaef8b55487a Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:54:34 +0200 Subject: [PATCH 03/23] feat: basic implementation of standardized CB2RI communication API Based on the N25B-A-14 article, this is a stub implementation of the RI2CB communication API. It implements the ping endpoint and provides a stub for the negotiation endpoint. ref: N25B-168 --- .gitignore | 216 ++++++++++++++++++++++++++++++++ README.md | 106 +--------------- requirements.txt | 2 + src/robot_interface/__init__.py | 0 src/robot_interface/main.py | 67 ++++++++++ 5 files changed, 287 insertions(+), 104 deletions(-) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 src/robot_interface/__init__.py create mode 100644 src/robot_interface/main.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3002d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,216 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/README.md b/README.md index 396501f..508934e 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,5 @@ # PepperPlus-RI -## GitHooks +The robot interface is a high-level API for controlling the robot. It implements the API as designed: https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication. -To activate automatic commits/branch name checks run: - -```shell -git config --local core.hooksPath .githooks -``` - -If your commit fails its either: -branch name != /description-of-branch -commit name != : description of the commit. - : N25B-Num's - - - - -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ri.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](https://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ri/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README - -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +This is an implementation for the Pepper robot, using the Pepper SDK and Python 2.7 as required by the SDK. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aee002a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyzmq<16 +pyaudio<=0.2.11 diff --git a/src/robot_interface/__init__.py b/src/robot_interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py new file mode 100644 index 0000000..9ea734f --- /dev/null +++ b/src/robot_interface/main.py @@ -0,0 +1,67 @@ +import zmq + + +def handle_ping(message): + """A simple ping endpoint. Returns the provided data.""" + return {"endpoint": "ping", "data": message.get("data")} + + +def handle_negotiation(message): + """ + Handle a negotiation request. Will respond with ports that can be used to connect to the robot. + + :param message: The negotiation request message. + :type message: dict + + :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? + return {"endpoint": "negotiation/error", "data": "The requested endpoint is not implemented."} + + +def route_request(message): + """ + Handle a request message. + + :param message: The request message. + :type message: dict + + :return: A response message. + :rtype: dict + """ + print("Received request: {}".format(message)) + + if "endpoint" not in message: + return {"endpoint": "error", "data": "No endpoint provided."} + if message["endpoint"] == "ping": + return handle_ping(message) + elif message["endpoint"] == "negotiation": + return handle_negotiation(message) + + return {"endpoint": "error", "data": "The requested endpoint is not implemented."} + + +def main_loop(socket): + while True: + request = socket.recv_json() + response = route_request(request) + socket.send_json(response) + + +def main(): + context = zmq.Context() + socket = context.socket(zmq.REP) + socket.connect("tcp://localhost:5555") + + try: + main_loop(socket) + except KeyboardInterrupt: + print("User interrupted.") + finally: + socket.close() + context.term() + + +if __name__ == "__main__": + main() From 23805812d58fcb50e9f8031bfe131175ce5ff2d7 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:04:18 +0200 Subject: [PATCH 04/23] feat: abstract base classes for endpoints Introduces EndpointBase and ReceiverBase abstract base classes. Implements a ReceiverBase with the MainReceiver. ref: N25B-168 --- src/robot_interface/endpoints/__init__.py | 0 .../endpoints/endpoint_base.py | 38 +++++++++ .../endpoints/main_receiver.py | 48 ++++++++++++ .../endpoints/receiver_base.py | 21 +++++ src/robot_interface/main.py | 78 +++++++++---------- src/robot_interface/state.py | 58 ++++++++++++++ 6 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 src/robot_interface/endpoints/__init__.py create mode 100644 src/robot_interface/endpoints/endpoint_base.py create mode 100644 src/robot_interface/endpoints/main_receiver.py create mode 100644 src/robot_interface/endpoints/receiver_base.py create mode 100644 src/robot_interface/state.py diff --git a/src/robot_interface/endpoints/__init__.py b/src/robot_interface/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robot_interface/endpoints/endpoint_base.py b/src/robot_interface/endpoints/endpoint_base.py new file mode 100644 index 0000000..54ebf1c --- /dev/null +++ b/src/robot_interface/endpoints/endpoint_base.py @@ -0,0 +1,38 @@ +from abc import ABCMeta + + +class EndpointBase(object): + __metaclass__ = ABCMeta + + name = None + socket = None + + def __init__(self, name): + """ + :param name: The name of the endpoint. + :type name: str + """ + self.name = name + self.socket = None + + def create_socket(self, zmq_context, socket_type, port): + """ + Create a ZeroMQ socket. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param socket_type: The type of socket to create. Use zmq constants, e.g. zmq.SUB or zmq.REP. + :type socket_type: int + + :param port: The port to use. + :type port: int + """ + self.socket = zmq_context.socket(socket_type) + 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 diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py new file mode 100644 index 0000000..4ad7846 --- /dev/null +++ b/src/robot_interface/endpoints/main_receiver.py @@ -0,0 +1,48 @@ +import zmq + +from robot_interface.endpoints.receiver_base import ReceiverBase + + +class MainReceiver(ReceiverBase): + def __init__(self, zmq_context, port=5555): + """ + The main receiver endpoint, responsible for handling ping and negotiation requests. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use. + :type port: int + """ + super(MainReceiver, self).__init__("main") + self.create_socket(zmq_context, zmq.REP, port) + + @staticmethod + def _handle_ping(message): + """A simple ping endpoint. Returns the provided data.""" + return {"endpoint": "ping", "data": message.get("data")} + + @staticmethod + def _handle_negotiation(message): + """ + Handle a negotiation request. Will respond with ports that can be used to connect to the robot. + + :param message: The negotiation request message. + :type message: dict + + :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? + return {"endpoint": "negotiation/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"] == "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 new file mode 100644 index 0000000..35f933e --- /dev/null +++ b/src/robot_interface/endpoints/receiver_base.py @@ -0,0 +1,21 @@ +from abc import ABCMeta, abstractmethod + +from robot_interface.endpoints.endpoint_base import EndpointBase + + +class ReceiverBase(EndpointBase, object): + """Associated with a ZeroMQ socket.""" + __metaclass__ = ABCMeta + + @abstractmethod + def handle_message(self, message): + """ + Handle a message with the receiver. + + :param message: The message to handle. + :type message: dict + + :return: A response message. + :rtype: dict + """ + return {"endpoint": "error", "data": "The requested receiver is not implemented."} diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 9ea734f..11cb761 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -1,65 +1,59 @@ +import logging +import time + import zmq - -def handle_ping(message): - """A simple ping endpoint. Returns the provided data.""" - return {"endpoint": "ping", "data": message.get("data")} +from robot_interface.endpoints.main_receiver import MainReceiver +from robot_interface.state import state -def handle_negotiation(message): +def main_loop(context): """ - Handle a negotiation request. Will respond with ports that can be used to connect to the robot. + Run the main loop, handling all incoming requests like pings, negotiation, actuation, etc. - :param message: The negotiation request message. - :type message: dict - - :return: A response dictionary with a 'ports' key containing a list of ports and their function. - :rtype: dict[str, list[dict]] + :param context: The ZeroMQ context to use. + :type context: zmq.Context """ - # TODO: .../error on all endpoints? - return {"endpoint": "negotiation/error", "data": "The requested endpoint is not implemented."} + # When creating endpoints, remember to add them to the endpoint list of the state to ensure they're deinitialized + main_receiver = MainReceiver(context) + state.endpoints.append(main_receiver) + # Define endpoints that can run on the main thread. These endpoints should not block for long (say 50 ms at most). + receivers = [main_receiver] -def route_request(message): - """ - Handle a request message. + poller = zmq.Poller() + for receiver in receivers: + poller.register(receiver.socket, zmq.POLLIN) - :param message: The request message. - :type message: dict - - :return: A response message. - :rtype: dict - """ - print("Received request: {}".format(message)) - - if "endpoint" not in message: - return {"endpoint": "error", "data": "No endpoint provided."} - if message["endpoint"] == "ping": - return handle_ping(message) - elif message["endpoint"] == "negotiation": - return handle_negotiation(message) - - return {"endpoint": "error", "data": "The requested endpoint is not implemented."} - - -def main_loop(socket): while True: - request = socket.recv_json() - response = route_request(request) - socket.send_json(response) + if state.exit_event.is_set(): break + socks = dict(poller.poll(100)) + + 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) + 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() - socket = context.socket(zmq.REP) - socket.connect("tcp://localhost:5555") + + state.initialize() try: - main_loop(socket) + main_loop(context) except KeyboardInterrupt: print("User interrupted.") finally: - socket.close() + state.deinitialize() context.term() diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py new file mode 100644 index 0000000..30d6a0f --- /dev/null +++ b/src/robot_interface/state.py @@ -0,0 +1,58 @@ +import logging +import signal +import threading + + +class State(object): + """ + Do not create an instance of this class directly: use the instance `state` below. This state must be initiated once, + probably when your program starts. + + This class is used to share state between threads. For example, when the program is quit, that all threads can + detect this via the `exit_event` property being set. + """ + def __init__(self): + self.is_initialized = False + self.exit_event = None + self.endpoints = [] # type: List[EndpointBase] + + def initialize(self): + if self.is_initialized: + logging.warn("Already initialized") + return + + self.exit_event = threading.Event() + def handle_exit(_, __): + logging.info("Exiting.") + self.exit_event.set() + signal.signal(signal.SIGINT, handle_exit) + signal.signal(signal.SIGTERM, handle_exit) + + self.is_initialized = True + + def deinitialize(self): + if not self.is_initialized: return + + for endpoint in self.endpoints: + endpoint.close() + + self.is_initialized = False + + def __getattribute__(self, name): + # Enforce that the state is initialized before accessing any property (aside from the basic ones) + if name in ("initialize", "deinitialize", "is_initialized", "__dict__", "__class__"): + return object.__getattribute__(self, name) + + if not object.__getattribute__(self, "is_initialized"): + # Special case for the exit_event: if the event is set, return it without an error + if name == "exit_event": + exit_event = object.__getattribute__(self, "exit_event") + if exit_event and exit_event.is_set(): return exit_event + + raise RuntimeError("State must be initialized before accessing '%s'" % name) + + return object.__getattribute__(self, name) + + +# Must call `.initialize` before use +state = State() From e9c6b918e0a950a26df3475cface051786f10b70 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:24:31 +0200 Subject: [PATCH 05/23] refactor: rename EndpointBase to SocketBase Because 'endpoint' is also used in the messages, the name 'socket' is more descriptive. ref: N25B-168 --- src/robot_interface/endpoints/receiver_base.py | 4 ++-- .../endpoints/{endpoint_base.py => socket_base.py} | 2 +- src/robot_interface/main.py | 4 ++-- src/robot_interface/state.py | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/robot_interface/endpoints/{endpoint_base.py => socket_base.py} (97%) diff --git a/src/robot_interface/endpoints/receiver_base.py b/src/robot_interface/endpoints/receiver_base.py index 35f933e..b3183f7 100644 --- a/src/robot_interface/endpoints/receiver_base.py +++ b/src/robot_interface/endpoints/receiver_base.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from robot_interface.endpoints.endpoint_base import EndpointBase +from robot_interface.endpoints.socket_base import SocketBase -class ReceiverBase(EndpointBase, object): +class ReceiverBase(SocketBase, object): """Associated with a ZeroMQ socket.""" __metaclass__ = ABCMeta diff --git a/src/robot_interface/endpoints/endpoint_base.py b/src/robot_interface/endpoints/socket_base.py similarity index 97% rename from src/robot_interface/endpoints/endpoint_base.py rename to src/robot_interface/endpoints/socket_base.py index 54ebf1c..a5124f6 100644 --- a/src/robot_interface/endpoints/endpoint_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -1,7 +1,7 @@ from abc import ABCMeta -class EndpointBase(object): +class SocketBase(object): __metaclass__ = ABCMeta name = None diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 11cb761..a295484 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -14,11 +14,11 @@ def main_loop(context): :param context: The ZeroMQ context to use. :type context: zmq.Context """ - # When creating endpoints, remember to add them to the endpoint list of the state to ensure they're deinitialized + # When creating sockets, remember to add them to the `sockets` list of the state to ensure they're deinitialized main_receiver = MainReceiver(context) state.endpoints.append(main_receiver) - # Define endpoints that can run on the main thread. These endpoints should not block for long (say 50 ms at most). + # Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most). receivers = [main_receiver] poller = zmq.Poller() diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index 30d6a0f..d10cf77 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -14,7 +14,7 @@ class State(object): def __init__(self): self.is_initialized = False self.exit_event = None - self.endpoints = [] # type: List[EndpointBase] + self.sockets = [] # type: List[SocketBase] def initialize(self): if self.is_initialized: @@ -33,8 +33,8 @@ class State(object): def deinitialize(self): if not self.is_initialized: return - for endpoint in self.endpoints: - endpoint.close() + for socket in self.sockets: + socket.close() self.is_initialized = False From c95d4abd7717a7f1f833571477d80ad6db05002b Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:28:03 +0200 Subject: [PATCH 06/23] chore: re-add the installation instructions These installation instructions come from the feat/cb2ri-communication branch which has been replaced by this branch. ref: N25B-168 --- README.md | 74 +++++++++++++++++++++++++++++++++++++ src/robot_interface/main.py | 2 +- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 508934e..4699645 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,77 @@ The robot interface is a high-level API for controlling the robot. It implements the API as designed: https://utrechtuniversity.youtrack.cloud/articles/N25B-A-14/RI-CB-Communication. This is an implementation for the Pepper robot, using the Pepper SDK and Python 2.7 as required by the SDK. + +## Installation + +### Linux (or WSL) + +Start off by installing [Pyenv](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation) and walk through the steps outlined there (be sure to also add it to PATH). Also install the [Python build requirements](https://github.com/pyenv/pyenv/wiki#suggested-build-environment). Afterwards, install Python 2.7 and activate it for your current shell: + +```bash +pyenv install 2.7 +pyenv shell 2.7 +``` + +You can check that this worked by typing + +```bash +python -V +``` + +Which should return `Python 2.7.18`. + +Next, `cd` into this repository and create (and activate) a virtual environment: + +```bash +cd / +python -m pip install virtualenv +python -m virtualenv .venv +source .venv/bin/activate +``` + +Install the required packages with + +```bash +pip install -r requirements.txt +``` + +Now we need to install the NaoQi SDK into our virtual environment, which we need to do manually. Begin by downloading the SDK: + +```bash +wget https://community-static.aldebaran.com/resources/2.5.10/Python%20SDK/pynaoqi-python2.7-2.5.7.1-linux64.tar.gz +``` + +Next, move into the `site-packages` directory and extract the file you just downloaded: + +```bash +cd .venv/lib/python2.7/site-packages/ +tar xvfz /pynaoqi-python2.7-2.5.7.1-linux64.tar.gz +rm /pynaoqi-python2.7-2.5.7.1-linux64.tar.gz +``` + +Lastly, we need to inform our virtual environment where to find our newly installed package: + +```bash +echo /.venv/lib/python2.7/site-packages/pynaoqi-python2.7-2.5.7.1-linux64/lib/python2.7/site-packages/ > pynaoqi-python2.7.pth +``` + +That's it! Verify that it works with + +```bash +python -c "import qi; print(qi)" +``` + +You should now be able to run this project. + +### macOS + +Similar to Linux, but don't bother installing `pyenv` as it won't be able to install Python 2 on Apple Silicon. Instead, install Python 2.7.18 from the [Python website](https://www.python.org/downloads/release/python-2718/). + +Create the virtual environment as described above in the Linux section. Stop at the point where it shows you how to download the NaoQi SDK. Instead, use: + +```shell +curl -OL https://community-static.aldebaran.com/resources/2.5.10/Python%20SDK/pynaoqi-python2.7-2.5.7.1-mac64.tar.gz +``` + +Then resume the steps from above. diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index a295484..a203357 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -16,7 +16,7 @@ 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.endpoints.append(main_receiver) + state.sockets.append(main_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] From 7cfa6b44e8da8700dfda8946591134961806ed3f Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:36:25 +0200 Subject: [PATCH 07/23] chore: add usage instructions Describes how to run the main program. ref: N25B-168 --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 4699645..e7cfd21 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ The robot interface is a high-level API for controlling the robot. It implements This is an implementation for the Pepper robot, using the Pepper SDK and Python 2.7 as required by the SDK. + + ## Installation ### Linux (or WSL) @@ -77,3 +79,12 @@ curl -OL https://community-static.aldebaran.com/resources/2.5.10/Python%20SDK/py ``` Then resume the steps from above. + + + +## Usage + +```shell +cd src +python -m robot_interface.main +``` 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 08/23] 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 09/23] 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 10/23] 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 e3663e1327227239b4627a1aeee35156d79a9dee Mon Sep 17 00:00:00 2001 From: Storm Date: Wed, 15 Oct 2025 17:52:59 +0200 Subject: [PATCH 11/23] feat: implemented receiving video image from robot The functionality is implemented in main.py in the functions start_video_rcv and video_rcv_loop. close: N25B-171 --- .gitignore | 3 + src/robot_interface/endpoints/video_sender.py | 8 +++ src/robot_interface/main.py | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 src/robot_interface/endpoints/video_sender.py diff --git a/.gitignore b/.gitignore index c3002d8..9a65a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +.DS_Store + diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py new file mode 100644 index 0000000..d0b7ca4 --- /dev/null +++ b/src/robot_interface/endpoints/video_sender.py @@ -0,0 +1,8 @@ +import zmq + +from robot_interface.endpoints.socket_base import SocketBase + +class VideoSender(SocketBase): + def __init__(self, zmq_context, port=5556): + super(VideoSender, self).__init__("video") + self.create_socket(zmq_context, zmq.PUB, port) \ No newline at end of file diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index a203357..3272ef4 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -1,9 +1,12 @@ import logging import time +import qi +import threading import zmq from robot_interface.endpoints.main_receiver import MainReceiver +from robot_interface.endpoints.video_sender import VideoSender from robot_interface.state import state @@ -18,6 +21,15 @@ def main_loop(context): main_receiver = MainReceiver(context) state.sockets.append(main_receiver) + video_sender = VideoSender(context) + state.sockets.append(video_sender) + + # ip address of robot + robot_ip = "10.211.55.3" + # port of robot + port = 54321 + start_video_rcv(robot_ip, port, video_sender) + # Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most). receivers = [main_receiver] @@ -42,6 +54,55 @@ def main_loop(context): if time_spent_ms > 50: logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", receiver.name, time_spent_ms) +def start_video_rcv(robot_ip, port, socket): + """ + Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. + + :param robot_ip: The ip address of the robot to connect with. + :type robot_ip: String + + :param port: The port of the robot. + :type port: int + + :param socket: The ZMQ socket to send the video images over. + :type: Socket + """ + socket.socket.setsockopt(zmq.CONFLATE,1) + + app = qi.Application(["cam", "--qi-url", "tcp://{}:{}".format(robot_ip, port)]) + app.start() + session = app.session + + video = session.service("ALVideoDevice") + + camera_index = 0 + kQVGA = 2 + kRGB = 11 + FPS = 15 + vid_stream_name = video.subscribeCamera("Pepper Video", camera_index, kQVGA, kRGB, FPS) + thread = threading.Thread(target=video_rcv_loop, args=(video, vid_stream_name, socket)) + thread.start() + +def video_rcv_loop(vid_service, vid_stream_name, socket): + """ + The main loop of retrieving video images from the robot. + + :param vid_stream: The name of a camera subscription on the video service object vid_service + :type vid_stream: String + + :param vid_service: The video service object that the active Qi session is connected to. + :type vid_service: Object (Qi service object) + + :param socket: The ZMQ socket to send the video images over. + :type: Socket + """ + while not state.exit_event.is_set(): + try: + img = vid_service.getImageRemote(vid_stream_name) + #Possibly limit images sent if queuing issues arise + socket.socket.send(img[6]) + except: + logging.warn("Failed to retrieve video image from robot.") def main(): context = zmq.Context() From a408fafc7cd843cb2838f762bd95e0a204eb3db1 Mon Sep 17 00:00:00 2001 From: Storm Date: Wed, 15 Oct 2025 17:55:29 +0200 Subject: [PATCH 12/23] docs: minor type correction in documentation start_video_rcv and video_rcv_loop --- src/robot_interface/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 3272ef4..5ab5266 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -65,7 +65,7 @@ def start_video_rcv(robot_ip, port, socket): :type port: int :param socket: The ZMQ socket to send the video images over. - :type: Socket + :type: VideoSender """ socket.socket.setsockopt(zmq.CONFLATE,1) @@ -94,7 +94,7 @@ def video_rcv_loop(vid_service, vid_stream_name, socket): :type vid_service: Object (Qi service object) :param socket: The ZMQ socket to send the video images over. - :type: Socket + :type: VideoSender """ while not state.exit_event.is_set(): try: From 0c5b47ae166f168b1107d588018e697110693228 Mon Sep 17 00:00:00 2001 From: Storm Date: Thu, 16 Oct 2025 14:57:53 +0200 Subject: [PATCH 13/23] refactor: removed hardcoded IP and port and moved video functions from main to the VideoSender class ref: N25B-171 --- src/robot_interface/endpoints/socket_base.py | 10 +++- src/robot_interface/endpoints/video_sender.py | 55 +++++++++++++++++- src/robot_interface/main.py | 57 +------------------ 3 files changed, 64 insertions(+), 58 deletions(-) diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index a5124f6..7ca3289 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -15,7 +15,7 @@ class SocketBase(object): self.name = name self.socket = None - def create_socket(self, zmq_context, socket_type, port): + def create_socket(self, zmq_context, socket_type, port, options=[]): """ Create a ZeroMQ socket. @@ -27,8 +27,16 @@ class SocketBase(object): :param port: The port to use. :type port: int + + :param options: A list of options to be set on the socket. The list contains tuples where the first element contains the option + and the second the value, for example (zmq.CONFLATE, 1). + :type options: [(zmq socket option, option value)] """ self.socket = zmq_context.socket(socket_type) + + for option, arg in options: + self.socket.setsockopt(option,arg) + self.socket.connect("tcp://localhost:{}".format(port)) def close(self): diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index d0b7ca4..2413281 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -1,8 +1,61 @@ import zmq +import threading +import qi +import logging from robot_interface.endpoints.socket_base import SocketBase +from robot_interface.state import state + class VideoSender(SocketBase): def __init__(self, zmq_context, port=5556): super(VideoSender, self).__init__("video") - self.create_socket(zmq_context, zmq.PUB, port) \ No newline at end of file + self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)]) + + def start_video_rcv(self): + """ + Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. + + :param robot_ip: The ip address of the robot to connect with. + :type robot_ip: String + + :param port: The port of the robot. + :type port: int + + :param socket: The ZMQ socket to send the video images over. + :type: VideoSender + """ + app = qi.Application() + app.start() + session = app.session + + video = session.service("ALVideoDevice") + + camera_index = 0 + kQVGA = 2 + kRGB = 11 + FPS = 15 + vid_stream_name = video.subscribeCamera("Pepper Video", camera_index, kQVGA, kRGB, FPS) + thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name)) + thread.start() + + def video_rcv_loop(self, vid_service, vid_stream_name): + """ + The main loop of retrieving video images from the robot. + + :param vid_stream: The name of a camera subscription on the video service object vid_service + :type vid_stream: String + + :param vid_service: The video service object that the active Qi session is connected to. + :type vid_service: Object (Qi service object) + + :param socket: The ZMQ socket to send the video images over. + :type: VideoSender + """ + while not state.exit_event.is_set(): + try: + img = vid_service.getImageRemote(vid_stream_name) + #Possibly limit images sent if queuing issues arise + self.socket.send(img[6]) + except: + logging.warn("Failed to retrieve video image from robot.") diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 5ab5266..fb48040 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -1,7 +1,5 @@ import logging import time -import qi -import threading import zmq @@ -24,11 +22,7 @@ def main_loop(context): video_sender = VideoSender(context) state.sockets.append(video_sender) - # ip address of robot - robot_ip = "10.211.55.3" - # port of robot - port = 54321 - start_video_rcv(robot_ip, port, video_sender) + video_sender.start_video_rcv() # Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most). receivers = [main_receiver] @@ -54,55 +48,6 @@ def main_loop(context): if time_spent_ms > 50: logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", receiver.name, time_spent_ms) -def start_video_rcv(robot_ip, port, socket): - """ - Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. - - :param robot_ip: The ip address of the robot to connect with. - :type robot_ip: String - - :param port: The port of the robot. - :type port: int - - :param socket: The ZMQ socket to send the video images over. - :type: VideoSender - """ - socket.socket.setsockopt(zmq.CONFLATE,1) - - app = qi.Application(["cam", "--qi-url", "tcp://{}:{}".format(robot_ip, port)]) - app.start() - session = app.session - - video = session.service("ALVideoDevice") - - camera_index = 0 - kQVGA = 2 - kRGB = 11 - FPS = 15 - vid_stream_name = video.subscribeCamera("Pepper Video", camera_index, kQVGA, kRGB, FPS) - thread = threading.Thread(target=video_rcv_loop, args=(video, vid_stream_name, socket)) - thread.start() - -def video_rcv_loop(vid_service, vid_stream_name, socket): - """ - The main loop of retrieving video images from the robot. - - :param vid_stream: The name of a camera subscription on the video service object vid_service - :type vid_stream: String - - :param vid_service: The video service object that the active Qi session is connected to. - :type vid_service: Object (Qi service object) - - :param socket: The ZMQ socket to send the video images over. - :type: VideoSender - """ - while not state.exit_event.is_set(): - try: - img = vid_service.getImageRemote(vid_stream_name) - #Possibly limit images sent if queuing issues arise - socket.socket.send(img[6]) - except: - logging.warn("Failed to retrieve video image from robot.") def main(): context = zmq.Context() 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 14/23] 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 0b55d5c221dcbb69de1188fe1969f10302bd28ec Mon Sep 17 00:00:00 2001 From: "Luijkx,S.O.H. (Storm)" Date: Thu, 16 Oct 2025 14:06:31 +0000 Subject: [PATCH 15/23] style: fixed docstrings close: N25B-171 --- src/robot_interface/endpoints/socket_base.py | 2 +- src/robot_interface/endpoints/video_sender.py | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index 7ca3289..f86b3ec 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -30,7 +30,7 @@ class SocketBase(object): :param options: A list of options to be set on the socket. The list contains tuples where the first element contains the option and the second the value, for example (zmq.CONFLATE, 1). - :type options: [(zmq socket option, option value)] + :type options: list[tuple[int, int]] """ self.socket = zmq_context.socket(socket_type) diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 2413281..793385b 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -15,15 +15,6 @@ class VideoSender(SocketBase): def start_video_rcv(self): """ Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. - - :param robot_ip: The ip address of the robot to connect with. - :type robot_ip: String - - :param port: The port of the robot. - :type port: int - - :param socket: The ZMQ socket to send the video images over. - :type: VideoSender """ app = qi.Application() app.start() @@ -43,14 +34,11 @@ class VideoSender(SocketBase): """ The main loop of retrieving video images from the robot. - :param vid_stream: The name of a camera subscription on the video service object vid_service - :type vid_stream: String - :param vid_service: The video service object that the active Qi session is connected to. :type vid_service: Object (Qi service object) - :param socket: The ZMQ socket to send the video images over. - :type: VideoSender + :param vid_stream_name: The name of a camera subscription on the video service object vid_service + :type vid_stream_name: String """ while not state.exit_event.is_set(): try: 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 16/23] 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 17/23] 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 18/23] 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 19/23] 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 20/23] 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 21/23] 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 670d1f0a6aa59b9ed1d69a658be860f2a1143ff2 Mon Sep 17 00:00:00 2001 From: 2584433 Date: Fri, 17 Oct 2025 14:27:58 +0000 Subject: [PATCH 22/23] fix: fixed githooks --- .githooks/commit-msg | 1 - .githooks/pre-commit | 1 - README.md | 13 +++++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.githooks/commit-msg b/.githooks/commit-msg index dd14401..41992ad 100644 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -5,7 +5,6 @@ commit_msg=$(cat "$commit_msg_file") if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then - echo "🎉 commit message is Valid" exit 0 else echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ed801d8..7e94937 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,7 +9,6 @@ fi # allowed pattern if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then - echo "✅ Branch name valid: $branch" exit 0 else echo "❌ Invalid branch name: $branch" diff --git a/README.md b/README.md index e7cfd21..eff576a 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,16 @@ Then resume the steps from above. cd src python -m robot_interface.main ``` + +## GitHooks + +To activate automatic commits/branch name checks run: + +```shell +git config --local core.hooksPath .githooks +``` + +If your commit fails its either: +branch name != /description-of-branch , +commit name != : description of the commit. + : N25B-Num's 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 23/23] 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()