From eb091968a6651f463dd885867bcab019fb9a8334 Mon Sep 17 00:00:00 2001 From: 2584433 Date: Tue, 7 Oct 2025 14:55:04 +0000 Subject: [PATCH 01/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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() From 0f60f67ab98d4c790aece28ce48fb4ac8212f3cb Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:44:51 +0200 Subject: [PATCH 24/63] feat: add microphone selection utils Providing two functions, one to choose the default microphone, the other to choose a microphone interactively. With tests. ref: N25B-119 --- src/robot_interface/utils/microphone.py | 54 ++++++++++++ test/unit/test_microphone_utils.py | 107 ++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/robot_interface/utils/microphone.py create mode 100644 test/unit/test_microphone_utils.py diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py new file mode 100644 index 0000000..c8f5ee3 --- /dev/null +++ b/src/robot_interface/utils/microphone.py @@ -0,0 +1,54 @@ +import logging + +logger = logging.getLogger(__name__) + + +def choose_mic_interactive(audio): + """ + Choose a microphone to use, interactively in the CLI. + + :param audio: An instance of PyAudio to use. + :type audio: pyaudio.PyAudio + + :return: A dictionary from PyAudio containing information about the microphone to use, or None + if there is no microphone. + :rtype: dict | None + """ + device_count = audio.get_device_count() + if device_count == 0: return None + + print("Found {} audio devices:".format(device_count)) + for i in range(device_count): + print("- {}: {}".format(i, audio.get_device_info_by_index(i)["name"])) + + microphone_index = None + while microphone_index is None: + chosen = raw_input("Which device would you like to use?\n> ") + try: + chosen = int(chosen) + if chosen < 0 or chosen >= device_count: raise ValueError() + microphone_index = chosen + except ValueError: + print("Please enter a number between 0 and {}".format(device_count-1)) + + chosen_microphone = audio.get_device_info_by_index(microphone_index) + logger.info("Chose microphone \"{}\"".format(chosen_microphone["name"])) + return chosen_microphone + + +def choose_mic_default(audio): + """ + Get the system's default microphone to use. + + :param audio: An instance of PyAudio to use. + :type audio: pyaudio.PyAudio + + :return: A dictionary from PyAudio containing information about the microphone to use, or None + if there is no microphone. + :rtype: dict | None + """ + device_count = audio.get_device_count() + if device_count == 0: return None + + default_device = audio.get_default_input_device_info() + return default_device diff --git a/test/unit/test_microphone_utils.py b/test/unit/test_microphone_utils.py new file mode 100644 index 0000000..0114f73 --- /dev/null +++ b/test/unit/test_microphone_utils.py @@ -0,0 +1,107 @@ +import functools +import random +from StringIO import StringIO +import sys + +import pyaudio +import pytest + +from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive + + +@pytest.fixture +def pyaudio_instance(): + audio = pyaudio.PyAudio() + yield audio + + +def test_choose_mic_default(pyaudio_instance): + """ + The result must contain at least "index", as this is used to identify the microphone. + The "name" is used for logging, so it should also exist. + It must have one or more channels. + Lastly it must be capable of sending at least 16000 samples per second. + """ + result = choose_mic_default(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], long) + + assert "name" in result + assert isinstance(result["name"], (str, unicode)) + + assert "maxInputChannels" in result + assert isinstance(result["maxInputChannels"], long) + assert result["maxInputChannels"] > 0 + + assert "defaultSampleRate" in result + assert isinstance(result["defaultSampleRate"], float) + assert result["defaultSampleRate"] >= 16000 + + +def test_choose_mic_interactive_input_not_int(pyaudio_instance, mocker): + """ + First mock an input that's not an integer, then a valid integer. There should be no errors. + """ + mock_input = mocker.patch("__builtin__.raw_input", side_effect=["not an integer", "0"]) + fake_out = StringIO() + mocker.patch.object(sys, "stdout", fake_out) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == 0 + + assert mock_input.called + + assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + + +def test_choose_mic_interactive_negative_index(pyaudio_instance, mocker): + """ + Make sure that the interactive method does not allow negative integers as input. + """ + mock_input = mocker.patch("__builtin__.raw_input", side_effect=["-1", "0"]) + fake_out = StringIO() + mocker.patch.object(sys, "stdout", fake_out) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == 0 + + assert mock_input.called + + assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + + +def test_choose_mic_interactive_index_too_high(pyaudio_instance, mocker): + """ + Make sure that the interactive method does not allow indices higher than the highest mic index. + """ + real_count = pyaudio_instance.get_device_count() + mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"]) + fake_out = StringIO() + mocker.patch.object(sys, "stdout", fake_out) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == 0 + + assert mock_input.called + + assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + + +def test_choose_mic_interactive_random_index(pyaudio_instance, mocker): + """ + Get a random index from the list of available mics, make sure it's correct. + """ + real_count = pyaudio_instance.get_device_count() + random_index = random.randrange(real_count) + mocker.patch("__builtin__.raw_input", side_effect=[str(random_index)]) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == random_index From 1e3e077029a60febd09e1ea669bde566514bb67f Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:24:46 +0200 Subject: [PATCH 25/63] fix: disallow selecting non-microphone audio device Previously any audio device was allowed to be selected as microphone. Now, only ones with at least one input channel can be selected. ref: N25B-119 --- src/robot_interface/utils/microphone.py | 47 ++++++++++++++++--------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index c8f5ee3..769f9a6 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -1,8 +1,25 @@ +from __future__ import unicode_literals # So that `print` can print the Unicode strings in names import logging logger = logging.getLogger(__name__) +def get_microphones(audio): + """ + Get audio devices which have input channels. + + :param audio: An instance of PyAudio to use. + :type audio: pyaudio.PyAudio + + :return: An interator of PaAudio dicts containing information about the microphone devices. + :rtype: Iterator[dict] + """ + for i in range(audio.get_device_count()): + device = audio.get_device_info_by_index(i) + if device["maxInputChannels"] > 0: + yield device + + def choose_mic_interactive(audio): """ Choose a microphone to use, interactively in the CLI. @@ -14,24 +31,23 @@ def choose_mic_interactive(audio): if there is no microphone. :rtype: dict | None """ - device_count = audio.get_device_count() - if device_count == 0: return None + microphones = list(get_microphones(audio)) + if len(microphones) == 0: return None - print("Found {} audio devices:".format(device_count)) - for i in range(device_count): - print("- {}: {}".format(i, audio.get_device_info_by_index(i)["name"])) + print("Found {} microphones:".format(len(microphones))) + for i, mic in enumerate(microphones): + print("- {}: {}".format(i, mic["name"])) - microphone_index = None - while microphone_index is None: + chosen_microphone = None + while chosen_microphone is None: chosen = raw_input("Which device would you like to use?\n> ") try: chosen = int(chosen) - if chosen < 0 or chosen >= device_count: raise ValueError() - microphone_index = chosen + if chosen < 0 or chosen >= len(microphones): raise ValueError() + chosen_microphone = microphones[chosen] except ValueError: - print("Please enter a number between 0 and {}".format(device_count-1)) + print("Please enter a number between 0 and {}".format(len(microphones)-1)) - chosen_microphone = audio.get_device_info_by_index(microphone_index) logger.info("Chose microphone \"{}\"".format(chosen_microphone["name"])) return chosen_microphone @@ -47,8 +63,7 @@ def choose_mic_default(audio): if there is no microphone. :rtype: dict | None """ - device_count = audio.get_device_count() - if device_count == 0: return None - - default_device = audio.get_default_input_device_info() - return default_device + try: + return audio.get_default_input_device_info() + except IOError: + return None From f8db719bfa5cac2366334e9e6aacd739aa991746 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:27:35 +0200 Subject: [PATCH 26/63] test: unit test mock PyAudio, integration test use real Make unit tests use a mock version of PyAudio, while making integration tests using the real version. If no real microphone is available, these integration tests are skipped. ref: N25B-119 --- test/common/microphone_utils.py | 97 +++++++++++++ test/integration/test_microphone_utils.py | 20 +++ test/unit/test_microphone_utils.py | 160 ++++++++++------------ 3 files changed, 186 insertions(+), 91 deletions(-) create mode 100644 test/common/microphone_utils.py create mode 100644 test/integration/test_microphone_utils.py diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py new file mode 100644 index 0000000..70bcb84 --- /dev/null +++ b/test/common/microphone_utils.py @@ -0,0 +1,97 @@ +import random +import sys +from StringIO import StringIO + +import mock + +from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive, get_microphones + + +class MicrophoneUtils(object): + """Shared tests for any PyAudio-like implementation, e.g. mock and real.""" + + def test_choose_mic_default(self, pyaudio_instance): + """ + The result must contain at least "index", as this is used to identify the microphone. + The "name" is used for logging, so it should also exist. + It must have one or more channels. + Lastly it must be capable of sending at least 16000 samples per second. + """ + result = choose_mic_default(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + + assert "name" in result + assert isinstance(result["name"], (str, unicode)) + + assert "maxInputChannels" in result + assert isinstance(result["maxInputChannels"], (int, long)) + assert result["maxInputChannels"] > 0 + + assert "defaultSampleRate" in result + assert isinstance(result["defaultSampleRate"], float) + assert result["defaultSampleRate"] >= 16000 + + def test_choose_mic_interactive_input_not_int(self, pyaudio_instance, mocker): + """ + First mock an input that's not an integer, then a valid integer. There should be no errors. + """ + mock_input = mocker.patch("__builtin__.raw_input", side_effect=["not an integer", "0"]) + fake_out = StringIO() + mocker.patch.object(sys, "stdout", fake_out) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == 0 + + assert mock_input.called + + assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + + def test_choose_mic_interactive_negative_index(self, pyaudio_instance, mocker): + """ + Make sure that the interactive method does not allow negative integers as input. + """ + mock_input = mocker.patch("__builtin__.raw_input", side_effect=["-1", "0"]) + fake_out = StringIO() + mocker.patch.object(sys, "stdout", fake_out) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == 0 + + assert mock_input.called + + assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + + def test_choose_mic_interactive_index_too_high(self, pyaudio_instance, mocker): + """ + Make sure that the interactive method does not allow indices higher than the highest mic index. + """ + real_count = len(list(get_microphones(pyaudio_instance))) + mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"]) + fake_out = StringIO() + mocker.patch.object(sys, "stdout", fake_out) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + + assert mock_input.called + + assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + + def test_choose_mic_interactive_random_index(self, pyaudio_instance, mocker): + """ + Get a random index from the list of available mics, make sure it's correct. + """ + microphones = list(get_microphones(pyaudio_instance)) + random_index = random.randrange(len(microphones)) + mocker.patch("__builtin__.raw_input", side_effect=[str(random_index)]) + + result = choose_mic_interactive(pyaudio_instance) + assert "index" in result + assert isinstance(result["index"], (int, long)) + assert result["index"] == microphones[random_index]["index"] diff --git a/test/integration/test_microphone_utils.py b/test/integration/test_microphone_utils.py new file mode 100644 index 0000000..a857498 --- /dev/null +++ b/test/integration/test_microphone_utils.py @@ -0,0 +1,20 @@ +import pyaudio + +import pytest + +from common.microphone_utils import MicrophoneUtils + + +@pytest.fixture +def pyaudio_instance(): + audio = pyaudio.PyAudio() + try: + audio.get_default_input_device_info() + return audio + except IOError: + pytest.skip("No microphone available to test with.") + + +class TestAudioIntegration(MicrophoneUtils): + """Run shared audio behavior tests with the mock implementation.""" + pass diff --git a/test/unit/test_microphone_utils.py b/test/unit/test_microphone_utils.py index 0114f73..5ad551d 100644 --- a/test/unit/test_microphone_utils.py +++ b/test/unit/test_microphone_utils.py @@ -1,107 +1,85 @@ -import functools -import random -from StringIO import StringIO -import sys - -import pyaudio +# coding=utf-8 +import mock import pytest +from common.microphone_utils import MicrophoneUtils from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive +class MockPyAudio: + def __init__(self): + # You can predefine fake device info here + self.devices = [ + { + "index": 0, + "name": u"Someone’s Microphone", # Using a Unicode ’ character + "maxInputChannels": 2, + "maxOutputChannels": 0, + "defaultSampleRate": 44100.0, + "defaultLowInputLatency": 0.01, + "defaultLowOutputLatency": 0.01, + "defaultHighInputLatency": 0.1, + "defaultHighOutputLatency": 0.1, + "hostApi": 0, + }, + { + "index": 1, + "name": u"Mock Speaker 1", + "maxInputChannels": 0, + "maxOutputChannels": 2, + "defaultSampleRate": 48000.0, + "defaultLowInputLatency": 0.01, + "defaultLowOutputLatency": 0.01, + "defaultHighInputLatency": 0.1, + "defaultHighOutputLatency": 0.1, + "hostApi": 0, + }, + ] + + def get_device_count(self): + """Return the number of available mock devices.""" + return len(self.devices) + + def get_device_info_by_index(self, index): + """Return information for a given mock device index.""" + if 0 <= index < len(self.devices): + return self.devices[index] + else: + raise IOError("Invalid device index: {}".format(index)) + + def get_default_input_device_info(self): + """Return info for a default mock input device.""" + for device in self.devices: + if device.get("maxInputChannels", 0) > 0: + return device + raise IOError("No default input device found") + + @pytest.fixture def pyaudio_instance(): - audio = pyaudio.PyAudio() - yield audio + return MockPyAudio() -def test_choose_mic_default(pyaudio_instance): - """ - The result must contain at least "index", as this is used to identify the microphone. - The "name" is used for logging, so it should also exist. - It must have one or more channels. - Lastly it must be capable of sending at least 16000 samples per second. - """ - result = choose_mic_default(pyaudio_instance) - assert "index" in result - assert isinstance(result["index"], long) - - assert "name" in result - assert isinstance(result["name"], (str, unicode)) - - assert "maxInputChannels" in result - assert isinstance(result["maxInputChannels"], long) - assert result["maxInputChannels"] > 0 - - assert "defaultSampleRate" in result - assert isinstance(result["defaultSampleRate"], float) - assert result["defaultSampleRate"] >= 16000 +def _raise_io_error(): + raise IOError() -def test_choose_mic_interactive_input_not_int(pyaudio_instance, mocker): - """ - First mock an input that's not an integer, then a valid integer. There should be no errors. - """ - mock_input = mocker.patch("__builtin__.raw_input", side_effect=["not an integer", "0"]) - fake_out = StringIO() - mocker.patch.object(sys, "stdout", fake_out) +class TestAudioUnit(MicrophoneUtils): + """Run shared audio behavior tests with the mock implementation.""" + def test_choose_mic_default_no_mic(self): + mock_pyaudio = mock.Mock() + mock_pyaudio.get_device_count = mock.Mock(return_value=0L) + mock_pyaudio.get_default_input_device_info = _raise_io_error - result = choose_mic_interactive(pyaudio_instance) - assert "index" in result - assert isinstance(result["index"], (int, long)) - assert result["index"] == 0 + result = choose_mic_default(mock_pyaudio) - assert mock_input.called + assert result is None - assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) + def test_choose_mic_interactive_no_mic(self): + mock_pyaudio = mock.Mock() + mock_pyaudio.get_device_count = mock.Mock(return_value=0L) + mock_pyaudio.get_default_input_device_info = _raise_io_error + result = choose_mic_interactive(mock_pyaudio) -def test_choose_mic_interactive_negative_index(pyaudio_instance, mocker): - """ - Make sure that the interactive method does not allow negative integers as input. - """ - mock_input = mocker.patch("__builtin__.raw_input", side_effect=["-1", "0"]) - fake_out = StringIO() - mocker.patch.object(sys, "stdout", fake_out) - - result = choose_mic_interactive(pyaudio_instance) - assert "index" in result - assert isinstance(result["index"], (int, long)) - assert result["index"] == 0 - - assert mock_input.called - - assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) - - -def test_choose_mic_interactive_index_too_high(pyaudio_instance, mocker): - """ - Make sure that the interactive method does not allow indices higher than the highest mic index. - """ - real_count = pyaudio_instance.get_device_count() - mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"]) - fake_out = StringIO() - mocker.patch.object(sys, "stdout", fake_out) - - result = choose_mic_interactive(pyaudio_instance) - assert "index" in result - assert isinstance(result["index"], (int, long)) - assert result["index"] == 0 - - assert mock_input.called - - assert any(p.startswith("Please enter a number") for p in fake_out.getvalue().splitlines()) - - -def test_choose_mic_interactive_random_index(pyaudio_instance, mocker): - """ - Get a random index from the list of available mics, make sure it's correct. - """ - real_count = pyaudio_instance.get_device_count() - random_index = random.randrange(real_count) - mocker.patch("__builtin__.raw_input", side_effect=[str(random_index)]) - - result = choose_mic_interactive(pyaudio_instance) - assert "index" in result - assert isinstance(result["index"], (int, long)) - assert result["index"] == random_index + assert result is None From 0499cd8a24044c6af4192876ff43afea23d99810 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:10:27 +0200 Subject: [PATCH 27/63] feat: send audio AudioSender runs in a separate thread to send audio from the microphone. ref: N25B-119 --- src/robot_interface/endpoints/audio_sender.py | 66 ++++++++++++++++ src/robot_interface/main.py | 6 ++ src/robot_interface/utils/microphone.py | 2 +- test/common/__init__.py | 0 test/common/microphone_utils.py | 2 - test/integration/__init__.py | 0 test/unit/test_audio_sender.py | 77 +++++++++++++++++++ 7 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/robot_interface/endpoints/audio_sender.py create mode 100644 test/common/__init__.py create mode 100644 test/integration/__init__.py create mode 100644 test/unit/test_audio_sender.py diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py new file mode 100644 index 0000000..5cd5a6b --- /dev/null +++ b/src/robot_interface/endpoints/audio_sender.py @@ -0,0 +1,66 @@ +from __future__ import unicode_literals # So that `logging` can use Unicode characters in names +import threading +import logging + +import pyaudio +import zmq + +from robot_interface.endpoints.socket_base import SocketBase +from robot_interface.state import state +from robot_interface.utils.microphone import choose_mic_default + + +logger = logging.getLogger(__name__) + + +class AudioSender(SocketBase): + def __init__(self, zmq_context, port=5558): + super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str + self.create_socket(zmq_context, zmq.PUB, port) + self.audio = pyaudio.PyAudio() + self.microphone = choose_mic_default(self.audio) + self.thread = None + + def start(self): + """ + Start sending audio in a different thread. + """ + if not self.microphone: + logger.info("Not listening: no microphone available.") + return + + logger.info("Listening with microphone \"{}\".".format(self.microphone["name"])) + self.thread = threading.Thread(target=self._stream) + self.thread.start() + + def wait_until_done(self): + """ + Wait until the audio thread is done. Will only be done if `state.exit_event` is set, so + make sure to set that before calling this method or it will block. + """ + if not self.thread: return + self.thread.join() + self.thread = None + + def _stream(self): + chunk = 512 # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD + + # Docs say this only raises an error if neither `input` nor `output` is True + stream = self.audio.open( + format=pyaudio.paFloat32, + channels=1, + rate=16000, + input=True, + input_device_index=self.microphone["index"], + frames_per_buffer=chunk, + ) + + try: + while not state.exit_event.is_set(): + data = stream.read(chunk) + self.socket.send(data) + except IOError as e: + logger.error("Stopped listening: failed to get audio from microphone.", exc_info=e) + finally: + stream.stop_stream() + stream.close() diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 934dfd3..8874f7d 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -1,4 +1,7 @@ import logging + +from robot_interface.endpoints.audio_sender import AudioSender + logging.basicConfig(level=logging.DEBUG) import zmq @@ -25,8 +28,11 @@ def main_loop(context): video_sender = VideoSender(context) state.sockets.append(video_sender) + audio_sender = AudioSender(context) + state.sockets.append(audio_sender) video_sender.start_video_rcv() + audio_sender.start() # Sockets that can run on the main thread. These sockets' endpoints should not block for long (say 50 ms at most). receivers = [main_receiver, actuation_receiver] diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index 769f9a6..c37ed0b 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals # So that `print` can print the Unicode strings in names +from __future__ import unicode_literals # So that `print` can print Unicode characters in names import logging logger = logging.getLogger(__name__) diff --git a/test/common/__init__.py b/test/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py index 70bcb84..7ecbf27 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -2,8 +2,6 @@ import random import sys from StringIO import StringIO -import mock - from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive, get_microphones diff --git a/test/integration/__init__.py b/test/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py new file mode 100644 index 0000000..9aab86f --- /dev/null +++ b/test/unit/test_audio_sender.py @@ -0,0 +1,77 @@ +# coding=utf-8 +import os +import time + +import mock +import pytest +import zmq + +from robot_interface.endpoints.audio_sender import AudioSender + + +@pytest.fixture +def zmq_context(): + context = zmq.Context() + yield context + + +def test_no_microphone(zmq_context, mocker): + mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info") + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic.return_value = None + + sender = AudioSender(zmq_context) + assert sender.microphone is None + + sender.start() + assert sender.thread is None + mock_info_logger.assert_called() + + sender.wait_until_done() # Should return early because we didn't start a thread + + +def test_unicode_mic_name(zmq_context, mocker): + mocker.patch("robot_interface.endpoints.audio_sender.threading") + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic.return_value = {"name": u"• Some Unicode name"} + + sender = AudioSender(zmq_context) + assert sender.microphone is not None + + # `.start()` logs the name of the microphone. It should not give an error if it contains Unicode + # symbols. + sender.start() + assert sender.thread is not None + + sender.wait_until_done() # Should return instantly because we didn't start a real thread + + +def _fake_read(num_frames): + return os.urandom(num_frames * 4) + + +def test_sending_audio(mocker): + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} + + mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") + mock_state.exit_event.is_set.return_value = False + + mock_audio = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio") + mock_audio.PyAudio = mock.Mock() + stream = mock.Mock() + stream.read = _fake_read + mock_audio.PyAudio.open.return_value = stream + + mock_zmq_context = mock.Mock() + send_socket = mock.Mock() + + sender = AudioSender(mock_zmq_context) + sender.socket.send = send_socket + + sender.start() + time.sleep(0.01) + mock_state.exit_event.is_set.return_value = True + sender.wait_until_done() + + send_socket.assert_called() From 230ab5d5cc5946b5deb243bc722ec20c2c37f1ed Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:38:30 +0200 Subject: [PATCH 28/63] test: add case for microphone failure When the microphone fails, it will raise an IOError during the `read`. This is simulated with a new test. ref: N25B-119 --- test/unit/test_audio_sender.py | 45 +++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index 9aab86f..4324cdb 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -55,23 +55,50 @@ def test_sending_audio(mocker): mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") - mock_state.exit_event.is_set.return_value = False - - mock_audio = mocker.patch("robot_interface.endpoints.audio_sender.pyaudio") - mock_audio.PyAudio = mock.Mock() - stream = mock.Mock() - stream.read = _fake_read - mock_audio.PyAudio.open.return_value = stream + mock_state.exit_event.is_set.side_effect = [False, True] mock_zmq_context = mock.Mock() send_socket = mock.Mock() + # If there's something wrong with the microphone, it will raise an IOError when `read`ing. + stream = mock.Mock() + stream.read = _fake_read + sender = AudioSender(mock_zmq_context) sender.socket.send = send_socket + sender.audio.open = mock.Mock() + sender.audio.open.return_value = stream sender.start() - time.sleep(0.01) - mock_state.exit_event.is_set.return_value = True sender.wait_until_done() send_socket.assert_called() + + +def _fake_read_error(num_frames): + raise IOError() + + +def test_break_microphone(mocker): + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} + + mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") + mock_state.exit_event.is_set.side_effect = [False, True] + + mock_zmq_context = mock.Mock() + send_socket = mock.Mock() + + # If there's something wrong with the microphone, it will raise an IOError when `read`ing. + stream = mock.Mock() + stream.read = _fake_read_error + + sender = AudioSender(mock_zmq_context) + sender.socket.send = send_socket + sender.audio.open = mock.Mock() + sender.audio.open.return_value = stream + + sender.start() + sender.wait_until_done() + + send_socket.assert_not_called() From a6a12a5886ff060a30caf72ca52ec5d20b70aa36 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:58:32 +0100 Subject: [PATCH 29/63] fix: remove unused qi import It had already been made so that the VideoSender does not depend on `qi`, but the import was not yet removed. ref: N25B-119 --- src/robot_interface/endpoints/video_sender.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index c46b768..9e75447 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -1,6 +1,5 @@ import zmq import threading -import qi import logging from robot_interface.endpoints.socket_base import SocketBase From 9ea446275ed3e74c14742f04f96afe77819093ca Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sun, 2 Nov 2025 14:59:16 +0100 Subject: [PATCH 30/63] fix: allow speaking text with Unicode characters When speaking, the actuation receiver logs the message to speak. If the message includes Unicode characters, it will now no longer crash. ref: N25B-119 --- src/robot_interface/endpoints/actuation_receiver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 7fe16b7..aa2511a 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals # So that we can log texts with Unicode characters import logging import zmq From 5912ac606a575ada820277c736991ec97304abb2 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:01:18 +0100 Subject: [PATCH 31/63] docs: add installation instructions for the portaudio dependency ref: N25B-119 --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ae5e2b3..0d47f7b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ python -m virtualenv .venv source .venv/bin/activate ``` +We depend on PortAudio for the `pyaudio` package, so install it with: + +```bash +sudo apt install -y portaudio19-dev +``` + Install the required packages with ```bash From fab5127cace233d50f9f5903d6afa4f838e75043 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sun, 2 Nov 2025 16:12:56 +0100 Subject: [PATCH 32/63] feat: add application parameter to choose a custom microphone ref: N25B-119 --- src/robot_interface/endpoints/audio_sender.py | 12 +++- src/robot_interface/utils/microphone.py | 51 ++++++++++++++++ test/common/microphone_utils.py | 59 ++++++++++++++++++- test/unit/test_audio_sender.py | 8 +-- 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index 5cd5a6b..7365816 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -7,7 +7,7 @@ import zmq from robot_interface.endpoints.socket_base import SocketBase from robot_interface.state import state -from robot_interface.utils.microphone import choose_mic_default +from robot_interface.utils.microphone import choose_mic logger = logging.getLogger(__name__) @@ -17,10 +17,16 @@ class AudioSender(SocketBase): def __init__(self, zmq_context, port=5558): super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str self.create_socket(zmq_context, zmq.PUB, port) - self.audio = pyaudio.PyAudio() - self.microphone = choose_mic_default(self.audio) self.thread = None + try: + self.audio = pyaudio.PyAudio() + self.microphone = choose_mic(self.audio) + except IOError as e: + logger.warning("PyAudio is not available.", exc_info=e) + self.audio = None + self.microphone = None + def start(self): """ Start sending audio in a different thread. diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index c37ed0b..877ca3f 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals # So that `print` can print Unicode characters in names import logging +import sys logger = logging.getLogger(__name__) @@ -67,3 +68,53 @@ def choose_mic_default(audio): return audio.get_default_input_device_info() except IOError: return None + + +def choose_mic_arguments(audio): + """ + Get a microphone to use from command line arguments. + + :param audio: An instance of PyAudio to use. + :type audio: pyaudio.PyAudio + + :return: A dictionary from PyAudio containing information about the microphone to use, or None + if there is no microphone satisfied by the arguments. + :rtype: dict | None + """ + microphone_name = None + for i, arg in enumerate(sys.argv): + if arg == "--microphone" and len(sys.argv) > i+1: + microphone_name = sys.argv[i+1].strip() + if arg.startswith("--microphone="): + microphone_name = arg[13:].strip() + + if not microphone_name: return None + + available_mics = list(get_microphones(audio)) + for mic in available_mics: + if mic["name"] == microphone_name: + return mic + + available_mic_names = [mic["name"] for mic in available_mics] + logger.warning("Microphone \"{}\" not found. Choose one of {}" + .format(microphone_name, available_mic_names)) + + return None + + +def choose_mic(audio): + """ + Get a microphone to use. Firstly, tries to see if there's an application argument specifying the + microphone to use. If not, get the default microphone. + + :param audio: An instance of PyAudio to use. + :type audio: pyaudio.PyAudio + + :return: A dictionary from PyAudio containing information about the microphone to use, or None + if there is no microphone. + :rtype: dict | None + """ + chosen_mic = choose_mic_arguments(audio) + if chosen_mic: return chosen_mic + + return choose_mic_default(audio) diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py index 7ecbf27..c82de37 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -1,8 +1,15 @@ +from __future__ import unicode_literals # So that we can format strings with Unicode characters import random import sys from StringIO import StringIO -from robot_interface.utils.microphone import choose_mic_default, choose_mic_interactive, get_microphones +from robot_interface.utils.microphone import ( + choose_mic_default, + choose_mic_interactive, + choose_mic_arguments, + choose_mic, + get_microphones, +) class MicrophoneUtils(object): @@ -93,3 +100,53 @@ class MicrophoneUtils(object): assert "index" in result assert isinstance(result["index"], (int, long)) assert result["index"] == microphones[random_index]["index"] + + def test_choose_mic_no_arguments(self, pyaudio_instance, mocker): + mocker.patch.object(sys, "argv", []) + + result = choose_mic_arguments(pyaudio_instance) + + assert result is None + + def test_choose_mic_arguments(self, pyaudio_instance, mocker): + for mic in get_microphones(pyaudio_instance): + mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) + + result = choose_mic_arguments(pyaudio_instance) + + assert result is not None + assert result == mic + + def test_choose_mic_arguments_eq(self, pyaudio_instance, mocker): + for mic in get_microphones(pyaudio_instance): + mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])]) + + result = choose_mic_arguments(pyaudio_instance) + + assert result is not None + assert result == mic + + def test_choose_mic_arguments_not_exits(self, pyaudio_instance, mocker): + mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"]) + + result = choose_mic_arguments(pyaudio_instance) + + assert result is None + + def test_choose_mic_with_argument(self, pyaudio_instance, mocker): + mic = next(get_microphones(pyaudio_instance)) + mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) + + result = choose_mic(pyaudio_instance) + + assert result is not None + assert result == mic + + def test_choose_mic_no_argument(self, pyaudio_instance, mocker): + default_mic = choose_mic_default(pyaudio_instance) + mocker.patch.object(sys, "argv", []) + + result = choose_mic(pyaudio_instance) + + assert result is not None + assert result == default_mic diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index 4324cdb..fc21805 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -17,7 +17,7 @@ def zmq_context(): def test_no_microphone(zmq_context, mocker): mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info") - mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = None sender = AudioSender(zmq_context) @@ -32,7 +32,7 @@ def test_no_microphone(zmq_context, mocker): def test_unicode_mic_name(zmq_context, mocker): mocker.patch("robot_interface.endpoints.audio_sender.threading") - mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"• Some Unicode name"} sender = AudioSender(zmq_context) @@ -51,7 +51,7 @@ def _fake_read(num_frames): def test_sending_audio(mocker): - mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") @@ -80,7 +80,7 @@ def _fake_read_error(num_frames): def test_break_microphone(mocker): - mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic_default") + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") From 854a14bf0c8395ac0b47b7d661e23e979beed823 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sun, 2 Nov 2025 16:16:43 +0100 Subject: [PATCH 33/63] docs: describe `--microphone` program parameter ref: N25B-119 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0d47f7b..8ef5ce9 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ $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. +There's also a `--microphone` argument that can be used to choose a microphone to use. If not given, the program will try the default microphone. If you don't know the name of the microphone, pass the argument with any value, and it will list the names of available microphones. + ## Testing From 8a095323ec45f98fde38b985a17d5b4fc96e94ce Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sun, 2 Nov 2025 16:35:15 +0100 Subject: [PATCH 34/63] docs: describe extra WSL installation step ref: N25B-119 --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 8ef5ce9..ce43e57 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ We depend on PortAudio for the `pyaudio` package, so install it with: sudo apt install -y portaudio19-dev ``` +On WSL, also install: + +```bash +sudo apt install -y libasound2-plugins +``` + Install the required packages with ```bash From 4402b21a73ff33a137373117ef8a43aa1564554a Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sun, 9 Nov 2025 15:43:22 +0100 Subject: [PATCH 35/63] refactor: added config file and moved constants - Moved hardcoded configuration constants to a dedicated config.py file. - Created VideoConfig, AudioConfig, MainConfig, and Settings classes in config.py ref: N25B-236 --- src/robot_interface/core/__init__.py | 0 src/robot_interface/core/config.py | 46 +++++++++++++++++++ .../endpoints/actuation_receiver.py | 4 +- src/robot_interface/endpoints/audio_sender.py | 11 +++-- .../endpoints/main_receiver.py | 3 +- src/robot_interface/endpoints/video_sender.py | 19 ++++---- src/robot_interface/main.py | 5 +- src/robot_interface/utils/microphone.py | 7 +-- 8 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 src/robot_interface/core/__init__.py create mode 100644 src/robot_interface/core/config.py diff --git a/src/robot_interface/core/__init__.py b/src/robot_interface/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py new file mode 100644 index 0000000..e8489bd --- /dev/null +++ b/src/robot_interface/core/config.py @@ -0,0 +1,46 @@ + +from __future__ import unicode_literals + +import os + +class AgentSettings(object): + """Agent port configuration.""" + def __init__(self, actuating_receiver_port=5557, main_receiver_port=5555,video_sender_port=5556, audio_sender_port=5558): + self.actuating_receiver_port = actuating_receiver_port + self.main_receiver_port = main_receiver_port + self.video_sender_port = video_sender_port + self.audio_sender_port = audio_sender_port + +class VideoConfig(object): + """Video configuration constants.""" + def __init__(self, camera_index=0, resolution=2, color_space=11, fps=15, stream_name="Pepper Video", image_buffer=6): + self.camera_index = camera_index + self.resolution = resolution + self.color_space = color_space + self.fps = fps + self.stream_name = stream_name + self.image_buffer = image_buffer + +class AudioConfig(object): + """Audio configuration constants.""" + def __init__(self, sample_rate=16000, chunk_size=512, channels=1): + self.sample_rate = sample_rate + self.chunk_size = chunk_size + self.channels = channels + +class MainConfig(object): + """Main configuration""" + def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): + self.poll_timeout_ms = poll_timeout_ms + self.max_handler_time_ms = max_handler_time_ms + +class Settings(object): + """Global settings container.""" + def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None): + self.agent_settings = agent_settings or AgentSettings() + self.video_config = video_config or VideoConfig() + self.audio_config = audio_config or AudioConfig() + self.main_config = main_config or MainConfig() + + +settings = Settings() diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index aa2511a..7fc6546 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -6,9 +6,11 @@ import zmq from robot_interface.endpoints.receiver_base import ReceiverBase from robot_interface.state import state +from robot_interface.core.config import settings + class ActuationReceiver(ReceiverBase): - def __init__(self, zmq_context, port=5557): + def __init__(self, zmq_context, port= settings.agent_settings.actuating_receiver_port): """ The actuation receiver endpoint, responsible for handling speech and gesture requests. diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index 7365816..2e1e45e 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -8,13 +8,13 @@ import zmq from robot_interface.endpoints.socket_base import SocketBase from robot_interface.state import state from robot_interface.utils.microphone import choose_mic - +from robot_interface.core.config import settings logger = logging.getLogger(__name__) class AudioSender(SocketBase): - def __init__(self, zmq_context, port=5558): + def __init__(self, zmq_context, port= settings.agent_settings.audio_sender_port): super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str self.create_socket(zmq_context, zmq.PUB, port) self.thread = None @@ -49,13 +49,14 @@ class AudioSender(SocketBase): self.thread = None def _stream(self): - chunk = 512 # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD + audio_settings = settings.audio_config + chunk = audio_settings.chunk_size # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD # Docs say this only raises an error if neither `input` nor `output` is True stream = self.audio.open( format=pyaudio.paFloat32, - channels=1, - rate=16000, + channels=audio_settings.channels, + rate=audio_settings.sample_rate, input=True, input_device_index=self.microphone["index"], frames_per_buffer=chunk, diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index 0ce9711..e5367c6 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -3,9 +3,10 @@ import zmq from robot_interface.endpoints.receiver_base import ReceiverBase from robot_interface.state import state +from robot_interface.core.config import settings class MainReceiver(ReceiverBase): - def __init__(self, zmq_context, port=5555): + def __init__(self, zmq_context, port= settings.agent_settings.main_receiver_port): """ The main receiver endpoint, responsible for handling ping and negotiation requests. diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 9e75447..1eacc3b 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -4,10 +4,10 @@ import logging from robot_interface.endpoints.socket_base import SocketBase from robot_interface.state import state - +from robot_interface.core.config import settings class VideoSender(SocketBase): - def __init__(self, zmq_context, port=5556): + def __init__(self, zmq_context, port=settings.agent_settings.video_sender_port): super(VideoSender, self).__init__("video") self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)]) @@ -20,12 +20,13 @@ class VideoSender(SocketBase): return video = state.qi_session.service("ALVideoDevice") - - camera_index = 0 - kQVGA = 2 - kRGB = 11 - FPS = 15 - vid_stream_name = video.subscribeCamera("Pepper Video", camera_index, kQVGA, kRGB, FPS) + video_settings = settings.video_config + camera_index = video_settings.camera_index + kQVGA = video_settings.resolution + kRGB = video_settings.color_space + FPS = video_settings.fps + video_name = video_settings.stream_name + vid_stream_name = video.subscribeCamera(video_name, camera_index, kQVGA, kRGB, FPS) thread = threading.Thread(target=self.video_rcv_loop, args=(video, vid_stream_name)) thread.start() @@ -43,6 +44,6 @@ class VideoSender(SocketBase): try: img = vid_service.getImageRemote(vid_stream_name) #Possibly limit images sent if queuing issues arise - self.socket.send(img[6]) + self.socket.send(img[settings.video_config.image_buffer]) 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 8874f7d..5af10ce 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -10,6 +10,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.core.config import settings from robot_interface.utils.timeblock import TimeBlock @@ -45,7 +46,7 @@ def main_loop(context): while True: if state.exit_event.is_set(): break - socks = dict(poller.poll(100)) + socks = dict(poller.poll(settings.main_config.poll_timeout_ms)) for receiver in receivers: if receiver.socket not in socks: continue @@ -59,7 +60,7 @@ def main_loop(context): logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", message["endpoint"], time_ms) - with TimeBlock(overtime_callback, 50): + with TimeBlock(overtime_callback, settings.main_config.max_handler_time_ms): response = receiver.handle_message(message) if receiver.socket.getsockopt(zmq.TYPE) == zmq.REP: diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index 877ca3f..2ec4bd4 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -82,11 +82,12 @@ def choose_mic_arguments(audio): :rtype: dict | None """ microphone_name = None + microhopone_prefix = "--microphone=" for i, arg in enumerate(sys.argv): - if arg == "--microphone" and len(sys.argv) > i+1: + if arg == microhopone_prefix and len(sys.argv) > i+1: microphone_name = sys.argv[i+1].strip() - if arg.startswith("--microphone="): - microphone_name = arg[13:].strip() + if arg.startswith(microhopone_prefix): + microphone_name = arg[len(microhopone_prefix):].strip() if not microphone_name: return None From 643d7b919c6dfd1c15068f90ed715893106ddbc4 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sun, 9 Nov 2025 16:00:36 +0100 Subject: [PATCH 36/63] fix: made all tests pass before some tests failed because of a faulty edit to microphone util ref: N25B-236 --- src/robot_interface/utils/microphone.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index 2ec4bd4..3bf9fe6 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -82,12 +82,12 @@ def choose_mic_arguments(audio): :rtype: dict | None """ microphone_name = None - microhopone_prefix = "--microphone=" for i, arg in enumerate(sys.argv): - if arg == microhopone_prefix and len(sys.argv) > i+1: + if arg == "--microphone" and len(sys.argv) > i+1: microphone_name = sys.argv[i+1].strip() - if arg.startswith(microhopone_prefix): - microphone_name = arg[len(microhopone_prefix):].strip() + if arg.startswith("--microphone="): + pre_fix_len = len("--microphone=") + microphone_name = arg[pre_fix_len:].strip() if not microphone_name: return None From 03519e2a1604c3d904834e51fded14aa84763f1d Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:08:31 +0100 Subject: [PATCH 37/63] test: fix microphone interactive test This was created with the assumption that all devices were choosable, but now only ones with input channels are. ref: N25B-119 --- test/common/microphone_utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py index c82de37..74bd777 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -41,6 +41,9 @@ class MicrophoneUtils(object): """ First mock an input that's not an integer, then a valid integer. There should be no errors. """ + microphones = get_microphones(pyaudio_instance) + target_microphone = next(microphones) + mock_input = mocker.patch("__builtin__.raw_input", side_effect=["not an integer", "0"]) fake_out = StringIO() mocker.patch.object(sys, "stdout", fake_out) @@ -48,7 +51,7 @@ class MicrophoneUtils(object): result = choose_mic_interactive(pyaudio_instance) assert "index" in result assert isinstance(result["index"], (int, long)) - assert result["index"] == 0 + assert result["index"] == target_microphone["index"] assert mock_input.called @@ -58,6 +61,9 @@ class MicrophoneUtils(object): """ Make sure that the interactive method does not allow negative integers as input. """ + microphones = get_microphones(pyaudio_instance) + target_microphone = next(microphones) + mock_input = mocker.patch("__builtin__.raw_input", side_effect=["-1", "0"]) fake_out = StringIO() mocker.patch.object(sys, "stdout", fake_out) @@ -65,7 +71,7 @@ class MicrophoneUtils(object): result = choose_mic_interactive(pyaudio_instance) assert "index" in result assert isinstance(result["index"], (int, long)) - assert result["index"] == 0 + assert result["index"] == target_microphone["index"] assert mock_input.called From 16b64e41c86ac141c7c17e0239893f62ea92ba1c Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Fri, 14 Nov 2025 14:12:14 +0000 Subject: [PATCH 38/63] style: applied style suggestions close: N25B-236 --- src/robot_interface/core/config.py | 24 ++++++++++++------- .../endpoints/actuation_receiver.py | 2 +- src/robot_interface/endpoints/audio_sender.py | 2 +- .../endpoints/main_receiver.py | 2 +- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index e8489bd..780f4d2 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -1,39 +1,47 @@ - from __future__ import unicode_literals -import os class AgentSettings(object): """Agent port configuration.""" - def __init__(self, actuating_receiver_port=5557, main_receiver_port=5555,video_sender_port=5556, audio_sender_port=5558): + def __init__( + self, + actuating_receiver_port=5557, + main_receiver_port=5555, + video_sender_port=5556, + audio_sender_port=5558, + ): self.actuating_receiver_port = actuating_receiver_port self.main_receiver_port = main_receiver_port self.video_sender_port = video_sender_port self.audio_sender_port = audio_sender_port - class VideoConfig(object): """Video configuration constants.""" - def __init__(self, camera_index=0, resolution=2, color_space=11, fps=15, stream_name="Pepper Video", image_buffer=6): + def __init__( + self, + camera_index=0, + resolution=2, + color_space=11, + fps=15, + stream_name="Pepper Video", + image_buffer=6, + ): self.camera_index = camera_index self.resolution = resolution self.color_space = color_space self.fps = fps self.stream_name = stream_name self.image_buffer = image_buffer - class AudioConfig(object): """Audio configuration constants.""" def __init__(self, sample_rate=16000, chunk_size=512, channels=1): self.sample_rate = sample_rate self.chunk_size = chunk_size self.channels = channels - class MainConfig(object): """Main configuration""" def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): self.poll_timeout_ms = poll_timeout_ms self.max_handler_time_ms = max_handler_time_ms - class Settings(object): """Global settings container.""" def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None): diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 7fc6546..c5e8ab3 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -10,7 +10,7 @@ from robot_interface.core.config import settings class ActuationReceiver(ReceiverBase): - def __init__(self, zmq_context, port= settings.agent_settings.actuating_receiver_port): + def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port): """ The actuation receiver endpoint, responsible for handling speech and gesture requests. diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index 2e1e45e..e6de258 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) class AudioSender(SocketBase): - def __init__(self, zmq_context, port= settings.agent_settings.audio_sender_port): + def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port): super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str self.create_socket(zmq_context, zmq.PUB, port) self.thread = None diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index e5367c6..b0adf24 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -6,7 +6,7 @@ from robot_interface.state import state from robot_interface.core.config import settings class MainReceiver(ReceiverBase): - def __init__(self, zmq_context, port= settings.agent_settings.main_receiver_port): + def __init__(self, zmq_context, port=settings.agent_settings.main_receiver_port): """ The main receiver endpoint, responsible for handling ping and negotiation requests. From c691e279cde7a8b1633f1025ce5bd804c9bcab6d Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:13:48 +0100 Subject: [PATCH 39/63] style: two lines between top level declarations ref: N25B-236 --- src/robot_interface/core/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index 780f4d2..f5295b2 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -14,6 +14,8 @@ class AgentSettings(object): self.main_receiver_port = main_receiver_port self.video_sender_port = video_sender_port self.audio_sender_port = audio_sender_port + + class VideoConfig(object): """Video configuration constants.""" def __init__( @@ -31,17 +33,23 @@ class VideoConfig(object): self.fps = fps self.stream_name = stream_name self.image_buffer = image_buffer + + class AudioConfig(object): """Audio configuration constants.""" def __init__(self, sample_rate=16000, chunk_size=512, channels=1): self.sample_rate = sample_rate self.chunk_size = chunk_size self.channels = channels + + class MainConfig(object): """Main configuration""" def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): self.poll_timeout_ms = poll_timeout_ms self.max_handler_time_ms = max_handler_time_ms + + class Settings(object): """Global settings container.""" def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None): From 9dd39d2048fd7b62bb61b0c45dc7e0d8e4ce6bf3 Mon Sep 17 00:00:00 2001 From: Storm Date: Wed, 19 Nov 2025 13:49:50 +0100 Subject: [PATCH 40/63] docs: added auto-generation of documentation ref: N25B-270 --- README.md | 22 ++++++++++++++++++++++ requirements.txt | 2 ++ src/robot_interface/state.py | 8 +++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ce43e57..badd3f2 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,25 @@ If your commit fails its either: branch name != /description-of-branch , commit name != : description of the commit. : N25B-Num's + +## Documentation +Generate documentation web pages using: + +```bash +PYTHONPATH=src sphinx-apidoc -F -o docs src/robot_interface +``` + +Optionally, in the `conf.py` file in the new `docs` folder, change preferences. +For the page theme, change `html_theme` to `'sphinx_rtd_theme'`. + +In the `docs` folder: + +### Windows +```bash +.\make.bat html +``` + +### MacOS +```bash +make html +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f93c70d..c7e1990 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ pyaudio<=0.2.11 pytest<5 pytest-mock<3.0.0 pytest-cov<3.0.0 +sphinx +sphinx_rtd_theme \ No newline at end of file diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index b6f8ce1..cae3ce6 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -45,7 +45,13 @@ class State(object): 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__"): + if name in ( + "initialize", + "deinitialize", + "is_initialized", + "__dict__", + "__class__", + "__doc__"): return object.__getattribute__(self, name) if not object.__getattribute__(self, "is_initialized"): From 1c9467d03a285aa03cc93ba4bf82547d59fd7eda Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Wed, 19 Nov 2025 17:57:24 +0100 Subject: [PATCH 41/63] fix: conf includes correct path ref: N25B-270 --- docs/conf.py | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/conf.py diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..345efd3 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath("../src")) + + +# -- Project information ----------------------------------------------------- + +project = u'robot_interface' +copyright = u'2025, Author' +author = u'Author' + +# The short X.Y version +version = u'' +# The full version, including alpha/beta/rc tags +release = u'' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.todo', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'robot_interfacedoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'robot_interface.tex', u'robot\\_interface Documentation', + u'Author', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'robot_interface', u'robot_interface Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'robot_interface', u'robot_interface Documentation', + author, 'robot_interface', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True From a0a8ad2689f4087313201bfaf794a138c30d0e4f Mon Sep 17 00:00:00 2001 From: Storm Date: Wed, 19 Nov 2025 17:59:37 +0100 Subject: [PATCH 42/63] docs: changed readme ref: N25B-270 --- README.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index badd3f2..8bde763 100644 --- a/README.md +++ b/README.md @@ -150,21 +150,26 @@ commit name != : description of the commit. ## Documentation Generate documentation web pages using: +### Linux & macOS ```bash PYTHONPATH=src sphinx-apidoc -F -o docs src/robot_interface ``` +### Windows +```bash +$env:PYTHONPATH="src"; sphinx-apidoc -F -o docs src/control_backend +``` + Optionally, in the `conf.py` file in the new `docs` folder, change preferences. -For the page theme, change `html_theme` to `'sphinx_rtd_theme'`. In the `docs` folder: +### Linux & macOS +```bash +make html +``` + ### Windows ```bash .\make.bat html -``` - -### MacOS -```bash -make html ``` \ No newline at end of file From cec29f620628d2cb09641a42756abf1c47dabac4 Mon Sep 17 00:00:00 2001 From: Storm Date: Wed, 19 Nov 2025 18:10:18 +0100 Subject: [PATCH 43/63] chore: updated .gitignore ref: N25B-270 --- .gitignore | 3 +++ README.md | 2 +- requirements.txt | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 9a65a1b..8be1803 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,6 @@ __marimo__/ .DS_Store +# Docs +docs/* +!docs/conf.py diff --git a/README.md b/README.md index 8bde763..6a34642 100644 --- a/README.md +++ b/README.md @@ -172,4 +172,4 @@ make html ### Windows ```bash .\make.bat html -``` \ No newline at end of file +``` diff --git a/requirements.txt b/requirements.txt index c7e1990..981361c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ pytest<5 pytest-mock<3.0.0 pytest-cov<3.0.0 sphinx -sphinx_rtd_theme \ No newline at end of file +sphinx_rtd_theme From 051f9045768c7e26a150f29cef76ef7333b589ea Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Fri, 21 Nov 2025 16:35:40 +0100 Subject: [PATCH 44/63] chore: add documentation RI Code functionality left unchanged, only added docs where missing close: N25B-298 --- src/robot_interface/core/config.py | 106 +++++++++++++++++- .../endpoints/actuation_receiver.py | 32 ++++-- src/robot_interface/endpoints/audio_sender.py | 29 ++++- .../endpoints/main_receiver.py | 58 +++++++--- .../endpoints/receiver_base.py | 2 +- src/robot_interface/endpoints/socket_base.py | 19 +++- src/robot_interface/endpoints/video_sender.py | 11 ++ src/robot_interface/main.py | 13 +++ src/robot_interface/state.py | 36 +++++- src/robot_interface/utils/qi_utils.py | 6 + src/robot_interface/utils/timeblock.py | 38 +++++-- test/common/microphone_utils.py | 104 +++++++++++++++-- test/integration/test_microphone_utils.py | 11 ++ test/unit/test_actuation_receiver.py | 53 +++++++++ test/unit/test_audio_sender.py | 52 +++++++++ test/unit/test_main_receiver.py | 45 ++++++++ test/unit/test_microphone_utils.py | 55 ++++++++- test/unit/test_time_block.py | 18 +++ 18 files changed, 629 insertions(+), 59 deletions(-) diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index f5295b2..b3a7adc 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -2,7 +2,27 @@ from __future__ import unicode_literals class AgentSettings(object): - """Agent port configuration.""" + """ + Agent port configuration. + + :param actuating_receiver_port: Port for receiving actuation commands. + :type actuating_receiver_port: int + :param main_receiver_port: Port for receiving main messages. + :type main_receiver_port: int + :param video_sender_port: Port used for sending video frames. + :type video_sender_port: int + :param audio_sender_port: Port used for sending audio data. + :type audio_sender_port: int + + :ivar actuating_receiver_port: Port for receiving actuation commands. + :type actuating_receiver_port: int + :ivar main_receiver_port: Port for receiving main messages. + :type main_receiver_port: int + :ivar video_sender_port: Port used for sending video frames. + :type video_sender_port: int + :ivar audio_sender_port: Port used for sending audio data. + :type audio_sender_port: int + """ def __init__( self, actuating_receiver_port=5557, @@ -17,7 +37,35 @@ class AgentSettings(object): class VideoConfig(object): - """Video configuration constants.""" + """ + Video configuration constants. + + :param camera_index: Index of the camera to use. + :type camera_index: int + :param resolution: Video resolution mode. + :type resolution: int + :param color_space: Color space identifier. + :type color_space: int + :param fps: Frames per second of the video stream. + :type fps: int + :param stream_name: Name of the video stream. + :type stream_name: str + :param image_buffer: Internal buffer size for video frames. + :type image_buffer: int + + :ivar camera_index: Index of the camera used. + :type camera_index: int + :ivar resolution: Video resolution mode. + :type resolution: int + :ivar color_space: Color space identifier. + :type color_space: int + :ivar fps: Frames per second of the video stream. + :type fps: int + :ivar stream_name: Name of the video stream. + :type stream_name: str + :ivar image_buffer: Internal buffer size for video frames. + :type image_buffer: int + """ def __init__( self, camera_index=0, @@ -36,7 +84,23 @@ class VideoConfig(object): class AudioConfig(object): - """Audio configuration constants.""" + """ + Audio configuration constants. + + :param sample_rate: Audio sampling rate in Hz. + :type sample_rate: int + :param chunk_size: Size of audio chunks to capture/process. + :type chunk_size: int + :param channels: Number of audio channels. + :type channels: int + + :ivar sample_rate: Audio sampling rate in Hz. + :type sample_rate: int + :ivar chunk_size: Size of audio chunks to capture/process. + :type chunk_size: int + :ivar channels: Number of audio channels. + :type channels: int + """ def __init__(self, sample_rate=16000, chunk_size=512, channels=1): self.sample_rate = sample_rate self.chunk_size = chunk_size @@ -44,14 +108,46 @@ class AudioConfig(object): class MainConfig(object): - """Main configuration""" + """ + Main system configuration. + + :param poll_timeout_ms: Timeout for polling events, in milliseconds. + :type poll_timeout_ms: int + :param max_handler_time_ms: Maximum allowed handler time, in milliseconds. + :type max_handler_time_ms: int + + :ivar poll_timeout_ms: Timeout for polling events, in milliseconds. + :type poll_timeout_ms: int + :ivar max_handler_time_ms: Maximum allowed handler time, in milliseconds. + :type max_handler_time_ms: int + """ def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): self.poll_timeout_ms = poll_timeout_ms self.max_handler_time_ms = max_handler_time_ms class Settings(object): - """Global settings container.""" + """ + Global settings container. + + :param agent_settings: Agent settings instance or None for defaults. + :type agent_settings: AgentSettings | None + :param video_config: VideoConfig instance or None for defaults. + :type video_config: VideoConfig | None + :param audio_config: AudioConfig instance or None for defaults. + :type audio_config: AudioConfig | None + :param main_config: MainConfig instance or None for defaults. + :type main_config: MainConfig | None + + :ivar agent_settings: Agent-related port configuration. + :type agent_settings: AgentSettings + :ivar video_config: Video stream configuration. + :type video_config: VideoConfig + :ivar audio_config: Audio stream configuration. + :type audio_config: AudioConfig + :ivar main_config: Main system-level configuration. + :type main_config: MainConfig + """ def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None): self.agent_settings = agent_settings or AgentSettings() self.video_config = video_config or VideoConfig() diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index c5e8ab3..027d417 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -10,22 +10,32 @@ from robot_interface.core.config import settings class ActuationReceiver(ReceiverBase): + """ + 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 + + :ivar _tts_service: The text-to-speech service object from the Qi session. + :vartype _tts_service: ssl.SSLSession | None + """ def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port): - """ - 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") self.create_socket(zmq_context, zmq.SUB, port) self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options self._tts_service = None def _handle_speech(self, message): + """ + Handle a speech actuation request. + + :param message: The message to handle, must contain properties "endpoint" and "data". + :type message: dict + """ text = message.get("data") if not text: logging.warn("Received message to speak, but it lacks data.") @@ -48,5 +58,11 @@ class ActuationReceiver(ReceiverBase): qi.async(self._tts_service.say, text) def handle_message(self, message): + """ + Handle an actuation/speech message with the receiver. + + :param message: The message to handle, must contain properties "endpoint" and "data". + :type message: dict + """ if message["endpoint"] == "actuate/speech": self._handle_speech(message) diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index e6de258..6f3b135 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -14,6 +14,24 @@ logger = logging.getLogger(__name__) class AudioSender(SocketBase): + """ + Audio sender endpoint, responsible for sending microphone audio data. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use. + :type port: int + + :ivar thread: Thread used for sending audio. + :type thread: threading.Thread | None + + :ivar audio: PyAudio instance. + :type audio: pyaudio.PyAudio | None + + :ivar microphone: Selected microphone information. + :type microphone: dict | None + """ def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port): super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str self.create_socket(zmq_context, zmq.PUB, port) @@ -30,7 +48,10 @@ class AudioSender(SocketBase): def start(self): """ Start sending audio in a different thread. + + Will not start if no microphone is available. """ + if not self.microphone: logger.info("Not listening: no microphone available.") return @@ -41,14 +62,18 @@ class AudioSender(SocketBase): def wait_until_done(self): """ - Wait until the audio thread is done. Will only be done if `state.exit_event` is set, so - make sure to set that before calling this method or it will block. + Wait until the audio thread is done. + + Will block until `state.exit_event` is set. If the thread is not running, does nothing. """ if not self.thread: return self.thread.join() self.thread = None def _stream(self): + """ + Internal method to continuously read audio from the microphone and send it over the socket. + """ audio_settings = settings.audio_config chunk = audio_settings.chunk_size # 320 at 16000 Hz is 20ms, 512 is required for Silero-VAD diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index b0adf24..bd47198 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -6,26 +6,47 @@ from robot_interface.state import state from robot_interface.core.config import settings class MainReceiver(ReceiverBase): + """ + 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 + """ def __init__(self, zmq_context, port=settings.agent_settings.main_receiver_port): - """ - 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, bind=False) @staticmethod def _handle_ping(message): - """A simple ping endpoint. Returns the provided data.""" + """ + Handle a ping request. + + Returns the provided data in a standardized response dictionary. + + :param message: The ping request message. + :type message: dict + + :return: A response dictionary containing the original data. + :rtype: dict[str, str | list[dict]] + """ return {"endpoint": "ping", "data": message.get("data")} @staticmethod def _handle_port_negotiation(message): + """ + Handle a port negotiation request. + + Returns a list of all known endpoints and their descriptions. + + :param message: The negotiation request message. + :type message: dict + + :return: A response dictionary with endpoint descriptions as data. + :rtype: dict[str, list[dict]] + """ endpoints = [socket.endpoint_description() for socket in state.sockets] return {"endpoint": "negotiate/ports", "data": endpoints} @@ -33,13 +54,13 @@ class MainReceiver(ReceiverBase): @staticmethod def _handle_negotiation(message): """ - Handle a negotiation request. Will respond with ports that can be used to connect to the robot. + Handle a negotiation request. Responds 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]] + :return: A response dictionary with the negotiation result. + :rtype: dict[str, str | list[dict]] """ # In the future, the sender could send information like the robot's IP address, etc. @@ -49,6 +70,17 @@ class MainReceiver(ReceiverBase): return {"endpoint": "negotiate/error", "data": "The requested endpoint is not implemented."} def handle_message(self, message): + """ + Main entry point for handling incoming messages. + + Dispatches messages to the appropriate handler based on the endpoint. + + :param message: The received message. + :type message: dict + + :return: A response dictionary based on the requested endpoint. + :rtype: dict[str, str | list[dict]] + """ if message["endpoint"] == "ping": return self._handle_ping(message) elif message["endpoint"].startswith("negotiate"): diff --git a/src/robot_interface/endpoints/receiver_base.py b/src/robot_interface/endpoints/receiver_base.py index 498d38b..8425889 100644 --- a/src/robot_interface/endpoints/receiver_base.py +++ b/src/robot_interface/endpoints/receiver_base.py @@ -4,7 +4,7 @@ from robot_interface.endpoints.socket_base import SocketBase class ReceiverBase(SocketBase, object): - """Associated with a ZeroMQ socket.""" + """Base class for receivers associated with a ZeroMQ socket.""" __metaclass__ = ABCMeta @abstractmethod diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index d08c360..248c7dd 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -4,16 +4,27 @@ import zmq class SocketBase(object): + """ + Base class for endpoints associated with a ZeroMQ socket. + + :ivar identifier: The identifier of the endpoint. + :type identifier: str + + :ivar port: The port used by the socket, set by `create_socket`. + :type port: int | None + + :ivar socket: The ZeroMQ socket object, set by `create_socket`. + :type socket: zmq.Socket | None + + :ivar bound: Whether the socket is bound or connected, set by `create_socket`. + :type bound: bool | None + """ __metaclass__ = ABCMeta name = None socket = None def __init__(self, identifier): - """ - :param identifier: The identifier of the endpoint. - :type identifier: str - """ self.identifier = identifier self.port = None # Set later by `create_socket` self.socket = None # Set later by `create_socket` diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 1eacc3b..1bc0617 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -7,6 +7,15 @@ from robot_interface.state import state from robot_interface.core.config import settings class VideoSender(SocketBase): + """ + Video sender endpoint, responsible for sending video frames. + + :param zmq_context: The ZeroMQ context to use. + :type zmq_context: zmq.Context + + :param port: The port to use for sending video frames. + :type port: int + """ def __init__(self, zmq_context, port=settings.agent_settings.video_sender_port): super(VideoSender, self).__init__("video") self.create_socket(zmq_context, zmq.PUB, port, [(zmq.CONFLATE,1)]) @@ -14,6 +23,8 @@ class VideoSender(SocketBase): def start_video_rcv(self): """ Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. + + Will not start of no qi session is available. """ if not state.qi_session: logging.info("No Qi session available. Not starting video loop.") diff --git a/src/robot_interface/main.py b/src/robot_interface/main.py index 5af10ce..816e53b 100644 --- a/src/robot_interface/main.py +++ b/src/robot_interface/main.py @@ -57,6 +57,13 @@ def main_loop(context): continue def overtime_callback(time_ms): + """ + A callback function executed by TimeBlock if the message handling + exceeds the allowed time limit. + + :param time_ms: The elapsed time, in milliseconds, that the block took. + :type time_ms: float + """ logging.warn("Endpoint \"%s\" took too long (%.2f ms) on the main thread.", message["endpoint"], time_ms) @@ -68,6 +75,12 @@ def main_loop(context): def main(): + """ + Initializes the ZeroMQ context and the application state. + It executes the main event loop (`main_loop`) and ensures that both the + application state and the ZeroMQ context are properly cleaned up (deinitialized/terminated) + upon exit, including handling a KeyboardInterrupt. + """ context = zmq.Context() state.initialize() diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index cae3ce6..2e2b149 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -12,14 +12,31 @@ class State(object): 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. + + :ivar is_initialized: Flag indicating whether the state setup (exit handlers, QI session) has completed. + :type is_initialized: bool + + :ivar exit_event: A thread event used to signal all threads that the program is shutting down. + :type exit_event: threading.Event | None + + :ivar sockets: A list of ZeroMQ socket wrappers (`SocketBase`) that need to be closed during deinitialization. + :type sockets: List[SocketBase] + + :ivar qi_session: The QI session object used for interaction with the robot/platform services. + :type qi_session: None | ssl.SSLSession """ def __init__(self): self.is_initialized = False self.exit_event = None - self.sockets = [] # type: List[SocketBase] - self.qi_session = None # type: None | ssl.SSLSession + self.sockets = [] + self.qi_session = None def initialize(self): + """ + Sets up the application state. Creates the thread exit event, registers + signal handlers (`SIGINT`, `SIGTERM`) for graceful shutdown, and + establishes the QI session. + """ if self.is_initialized: logging.warn("Already initialized") return @@ -36,6 +53,9 @@ class State(object): self.is_initialized = True def deinitialize(self): + """ + Closes all sockets stored in the `sockets` list. + """ if not self.is_initialized: return for socket in self.sockets: @@ -44,7 +64,17 @@ class State(object): self.is_initialized = False def __getattribute__(self, name): - # Enforce that the state is initialized before accessing any property (aside from the basic ones) + """ + Custom attribute access method that enforces a check: the state must be + fully initialized before any non-setup attributes (like `sockets` or `qi_session`) + can be accessed. + + :param name: The name of the attribute being accessed. + :type name: str + + :return: The value of the requested attribute. + :rtype: Any + """ if name in ( "initialize", "deinitialize", diff --git a/src/robot_interface/utils/qi_utils.py b/src/robot_interface/utils/qi_utils.py index fc7640b..23028cd 100644 --- a/src/robot_interface/utils/qi_utils.py +++ b/src/robot_interface/utils/qi_utils.py @@ -8,6 +8,12 @@ except ImportError: def get_qi_session(): + """ + Create and return a Qi session if available. + + :return: The active Qi session or ``None`` if unavailable. + :rtype: qi.Session | None + """ if qi is None: logging.info("Unable to import qi. Running in stand-alone mode.") return None diff --git a/src/robot_interface/utils/timeblock.py b/src/robot_interface/utils/timeblock.py index 23f1c85..3b50fff 100644 --- a/src/robot_interface/utils/timeblock.py +++ b/src/robot_interface/utils/timeblock.py @@ -5,27 +5,45 @@ 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. + + :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 """ 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): + """ + Enter the context manager and record the start time. + + :return: Returns itself so timing information can be accessed if needed. + :rtype: TimeBlock + """ self.start = time.time() return self def __exit__(self, exc_type, exc_value, traceback): + """ + Exit the context manager, calculate the elapsed time, and call the callback + if the time limit was exceeded or not provided. + + :param exc_type: The exception type, or None if no exception occurred. + :type exc_type: Type[BaseException] | None + + :param exc_value: The exception instance, or None if no exception occurred. + :type exc_value: BaseException | None + + :param traceback: The traceback object, or None if no exception occurred. + :type traceback: TracebackType | None + """ 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/common/microphone_utils.py b/test/common/microphone_utils.py index 74bd777..911f4a2 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -17,10 +17,15 @@ class MicrophoneUtils(object): def test_choose_mic_default(self, pyaudio_instance): """ - The result must contain at least "index", as this is used to identify the microphone. - The "name" is used for logging, so it should also exist. - It must have one or more channels. - Lastly it must be capable of sending at least 16000 samples per second. + Tests that the default microphone selection function returns a valid + microphone dictionary containing all necessary keys with correct types and values. + + The result must contain at least "index", as this is used to identify the microphone, + and "name" for logging. It must have one or more channels (`maxInputChannels`), + and a default sample rate of at least 16000 Hz. + + :param pyaudio_instance: A mocked or real PyAudio instance used to query microphone information. + :type pyaudio_instance: PyAudio """ result = choose_mic_default(pyaudio_instance) assert "index" in result @@ -39,7 +44,15 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_input_not_int(self, pyaudio_instance, mocker): """ - First mock an input that's not an integer, then a valid integer. There should be no errors. + Tests the robustness of the interactive selection when the user first enters + a non-integer value, ensuring the system prompts again without error and accepts + a valid integer on the second attempt. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture """ microphones = get_microphones(pyaudio_instance) target_microphone = next(microphones) @@ -59,7 +72,14 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_negative_index(self, pyaudio_instance, mocker): """ - Make sure that the interactive method does not allow negative integers as input. + Tests that the interactive selection method prevents the user from entering + a negative integer as a microphone index. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture """ microphones = get_microphones(pyaudio_instance) target_microphone = next(microphones) @@ -79,7 +99,14 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_index_too_high(self, pyaudio_instance, mocker): """ - Make sure that the interactive method does not allow indices higher than the highest mic index. + Tests that the interactive selection method prevents the user from entering + an index that exceeds the total number of available microphones. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture """ real_count = len(list(get_microphones(pyaudio_instance))) mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"]) @@ -96,7 +123,15 @@ class MicrophoneUtils(object): def test_choose_mic_interactive_random_index(self, pyaudio_instance, mocker): """ - Get a random index from the list of available mics, make sure it's correct. + Tests the core interactive functionality by simulating the selection of a + random valid microphone index and verifying that the correct microphone + information is returned. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture """ microphones = list(get_microphones(pyaudio_instance)) random_index = random.randrange(len(microphones)) @@ -115,6 +150,16 @@ class MicrophoneUtils(object): assert result is None def test_choose_mic_arguments(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when the microphone name is passed as a separate + argument. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture + """ for mic in get_microphones(pyaudio_instance): mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) @@ -124,6 +169,16 @@ class MicrophoneUtils(object): assert result == mic def test_choose_mic_arguments_eq(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when the microphone name is passed using an + equals sign (`--microphone=NAME`). + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture + """ for mic in get_microphones(pyaudio_instance): mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])]) @@ -132,7 +187,17 @@ class MicrophoneUtils(object): assert result is not None assert result == mic - def test_choose_mic_arguments_not_exits(self, pyaudio_instance, mocker): + def test_choose_mic_arguments_not_exist(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when a non-existent microphone name is passed + via command-line arguments, expecting the function to return None. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture + """ mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"]) result = choose_mic_arguments(pyaudio_instance) @@ -140,6 +205,16 @@ class MicrophoneUtils(object): assert result is None def test_choose_mic_with_argument(self, pyaudio_instance, mocker): + """ + Tests `choose_mic` function when a valid microphone is + specified via command-line arguments. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture + """ mic = next(get_microphones(pyaudio_instance)) mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) @@ -149,6 +224,17 @@ class MicrophoneUtils(object): assert result == mic def test_choose_mic_no_argument(self, pyaudio_instance, mocker): + """ + Tests `choose_mic` function when no command-line arguments + are provided, verifying that the function falls back correctly to the + system's default microphone selection. + + :param pyaudio_instance: A mocked or real PyAudio instance. + :type pyaudio_instance: PyAudio + + :param mocker: The fixture used for mocking built-in functions and system objects. + :type mocker: pytest_mock.plugin.MockerFixture + """ default_mic = choose_mic_default(pyaudio_instance) mocker.patch.object(sys, "argv", []) diff --git a/test/integration/test_microphone_utils.py b/test/integration/test_microphone_utils.py index a857498..1af1181 100644 --- a/test/integration/test_microphone_utils.py +++ b/test/integration/test_microphone_utils.py @@ -7,6 +7,17 @@ from common.microphone_utils import MicrophoneUtils @pytest.fixture def pyaudio_instance(): + """ + A pytest fixture that provides an initialized PyAudio instance for tests + requiring microphone access. + + It first initializes PyAudio. If a default input device (microphone) is not + found, the test is skipped to avoid failures in environments + without a mic. + + :return: An initialized PyAudio instance. + :rtype: pyaudio.PyAudio + """ audio = pyaudio.PyAudio() try: audio.get_default_input_device_info() diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py index da70964..7ea504c 100644 --- a/test/unit/test_actuation_receiver.py +++ b/test/unit/test_actuation_receiver.py @@ -9,11 +9,24 @@ from robot_interface.endpoints.actuation_receiver import ActuationReceiver @pytest.fixture def zmq_context(): + """ + A pytest fixture that creates and yields a ZMQ context. + + :return: An initialized ZeroMQ context. + :rtype: zmq.Context + """ context = zmq.Context() yield context def test_handle_unimplemented_endpoint(zmq_context): + """ + Tests that the ``ActuationReceiver.handle_message`` method can + handle an unknown or unimplemented endpoint without raising an error. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + """ receiver = ActuationReceiver(zmq_context) # Should not error receiver.handle_message({ @@ -23,6 +36,16 @@ def test_handle_unimplemented_endpoint(zmq_context): def test_speech_message_no_data(zmq_context, mocker): + """ + Tests that the message handler logs a warning when a speech actuation + request (`actuate/speech`) is received but contains empty string data. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + + :param mocker: The pytest-mock fixture used to patch `logging.warn`. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_warn = mocker.patch("logging.warn") receiver = ActuationReceiver(zmq_context) @@ -32,6 +55,16 @@ def test_speech_message_no_data(zmq_context, mocker): def test_speech_message_invalid_data(zmq_context, mocker): + """ + Tests that the message handler logs a warning when a speech actuation + request (`actuate/speech`) is received with data that is not a string (e.g., a boolean). + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + + :param mocker: The pytest-mock fixture used to patch `logging.warn`. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_warn = mocker.patch("logging.warn") receiver = ActuationReceiver(zmq_context) @@ -41,6 +74,16 @@ def test_speech_message_invalid_data(zmq_context, mocker): def test_speech_no_qi(zmq_context, mocker): + """ + Tests the actuation receiver's behavior when processing a speech request + but the global state does not have an active QI session. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + + :param mocker: The pytest-mock fixture used to patch the global state. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") mock_qi_session = mock.PropertyMock(return_value=None) @@ -53,6 +96,16 @@ def test_speech_no_qi(zmq_context, mocker): def test_speech(zmq_context, mocker): + """ + Tests the core speech actuation functionality by mocking the QI TextToSpeech + service and verifying that it is called correctly. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + + :param mocker: The pytest-mock fixture used to patch state and modules. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") mock_qi = mock.Mock() diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index fc21805..8923ba0 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -11,11 +11,26 @@ from robot_interface.endpoints.audio_sender import AudioSender @pytest.fixture def zmq_context(): + """ + A pytest fixture that creates and yields a ZMQ context. + + :return: An initialized ZeroMQ context. + :rtype: zmq.Context + """ context = zmq.Context() yield context def test_no_microphone(zmq_context, mocker): + """ + Tests the scenario where no valid microphone can be chosen for recording. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + + :param mocker: The pytest-mock fixture used to patch internal dependencies. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = None @@ -31,6 +46,16 @@ def test_no_microphone(zmq_context, mocker): def test_unicode_mic_name(zmq_context, mocker): + """ + Tests the robustness of the `AudioSender` when handling microphone names + that contain Unicode characters. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + + :param mocker: The pytest-mock fixture used to patch internal dependencies. + :type mocker: pytest_mock.plugin.MockerFixture + """ mocker.patch("robot_interface.endpoints.audio_sender.threading") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"• Some Unicode name"} @@ -47,10 +72,25 @@ def test_unicode_mic_name(zmq_context, mocker): def _fake_read(num_frames): + """ + Helper function to simulate reading raw audio data from a microphone stream. + + :param num_frames: The number of audio frames requested. + :type num_frames: int + + :return: A byte string containing random data, simulating audio. + :rtype: str + """ return os.urandom(num_frames * 4) def test_sending_audio(mocker): + """ + Tests the successful sending of audio data over a ZeroMQ socket. + + :param mocker: The pytest-mock fixture used to patch internal dependencies. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} @@ -76,10 +116,22 @@ def test_sending_audio(mocker): def _fake_read_error(num_frames): + """ + Helper function to simulate an I/O error during microphone stream reading. + + :param num_frames: The number of audio frames requested. + :type num_frames: int + """ raise IOError() def test_break_microphone(mocker): + """ + Tests the error handling when the microphone stream breaks (raises an IOError). + + :param mocker: The pytest-mock fixture used to patch internal dependencies. + :type mocker: pytest_mock.plugin.MockerFixture + """ mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} diff --git a/test/unit/test_main_receiver.py b/test/unit/test_main_receiver.py index 4ded502..c37bdff 100644 --- a/test/unit/test_main_receiver.py +++ b/test/unit/test_main_receiver.py @@ -7,11 +7,23 @@ from robot_interface.endpoints.main_receiver import MainReceiver @pytest.fixture def zmq_context(): + """ + A pytest fixture that creates and yields a ZMQ context. + + :return: An initialized ZeroMQ context. + :rtype: zmq.Context + """ context = zmq.Context() yield context def test_handle_ping(zmq_context): + """ + Tests the receiver's ability to handle the "ping" endpoint with data. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({"endpoint": "ping", "data": "pong"}) @@ -22,6 +34,13 @@ def test_handle_ping(zmq_context): def test_handle_ping_none(zmq_context): + """ + Tests the receiver's ability to handle the ping endpoint when the + data field is explicitly set to None. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({"endpoint": "ping", "data": None}) @@ -33,6 +52,15 @@ def test_handle_ping_none(zmq_context): @mock.patch("robot_interface.endpoints.main_receiver.state") def test_handle_negotiate_ports(mock_state, zmq_context): + """ + Tests the handling of the "negotiate/ports" endpoint. + + :param mock_state: Mocked global application state. + :type mock_state: mock.Mock + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + """ receiver = MainReceiver(zmq_context) mock_state.sockets = [receiver] @@ -54,6 +82,13 @@ def test_handle_negotiate_ports(mock_state, zmq_context): def test_handle_unimplemented_endpoint(zmq_context): + """ + Tests that the receiver correctly handles a request to a completely + unknown or non-existent endpoint. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({ "endpoint": "some_endpoint_that_definitely_does_not_exist", @@ -67,6 +102,16 @@ def test_handle_unimplemented_endpoint(zmq_context): def test_handle_unimplemented_negotiation_endpoint(zmq_context): + """ + Tests handling a request to an unknown sub-endpoint within a known + group + + The expected behavior is to return a specific "negotiate/error" response + with a descriptive error string. + + :param zmq_context: The ZeroMQ context fixture. + :type zmq_context: zmq.Context + """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({ "endpoint": "negotiate/but_some_subpath_that_definitely_does_not_exist", diff --git a/test/unit/test_microphone_utils.py b/test/unit/test_microphone_utils.py index 5ad551d..7cb13de 100644 --- a/test/unit/test_microphone_utils.py +++ b/test/unit/test_microphone_utils.py @@ -7,6 +7,16 @@ from robot_interface.utils.microphone import choose_mic_default, choose_mic_inte class MockPyAudio: + """ + A mock implementation of the PyAudio library class, designed for testing + microphone utility functions without requiring actual audio hardware. + + It provides fake devices, including one input microphone, and implements + the core PyAudio methods required for device enumeration. + + :ivar devices: A list of dictionaries representing mock audio devices. + :type devices: List[Dict[str, Any]] + """ def __init__(self): # You can predefine fake device info here self.devices = [ @@ -37,18 +47,36 @@ class MockPyAudio: ] def get_device_count(self): - """Return the number of available mock devices.""" + """ + Returns the number of available mock devices. + + :return: The total number of devices in the mock list. + :rtype: int + """ return len(self.devices) def get_device_info_by_index(self, index): - """Return information for a given mock device index.""" + """ + Returns information for a given mock device index. + + :param index: The index of the device to retrieve. + :type index: int + + :return: A dictionary containing device information. + :rtype: Dict[str, Any] + """ if 0 <= index < len(self.devices): return self.devices[index] else: raise IOError("Invalid device index: {}".format(index)) def get_default_input_device_info(self): - """Return info for a default mock input device.""" + """ + Returns information for the default mock input device. + + :return: A dictionary containing the default input device information. + :rtype: Dict[str, Any] + """ for device in self.devices: if device.get("maxInputChannels", 0) > 0: return device @@ -57,16 +85,32 @@ class MockPyAudio: @pytest.fixture def pyaudio_instance(): + """ + A pytest fixture that returns an instance of the `MockPyAudio` class. + + :return: An initialized instance of the mock PyAudio class. + :rtype: MockPyAudio + """ return MockPyAudio() def _raise_io_error(): + """ + Helper function used to mock PyAudio methods that are expected to fail + when no device is available. + """ raise IOError() class TestAudioUnit(MicrophoneUtils): - """Run shared audio behavior tests with the mock implementation.""" + """ + Runs the shared microphone behavior tests defined in `MicrophoneUtils` using + the mock PyAudio implementation. + """ def test_choose_mic_default_no_mic(self): + """ + Tests `choose_mic_default` when no microphones are available. + """ mock_pyaudio = mock.Mock() mock_pyaudio.get_device_count = mock.Mock(return_value=0L) mock_pyaudio.get_default_input_device_info = _raise_io_error @@ -76,6 +120,9 @@ class TestAudioUnit(MicrophoneUtils): assert result is None def test_choose_mic_interactive_no_mic(self): + """ + Tests `choose_mic_interactive` when no microphones are available. + """ mock_pyaudio = mock.Mock() mock_pyaudio.get_device_count = mock.Mock(return_value=0L) mock_pyaudio.get_default_input_device_info = _raise_io_error diff --git a/test/unit/test_time_block.py b/test/unit/test_time_block.py index eabc91b..129b4b9 100644 --- a/test/unit/test_time_block.py +++ b/test/unit/test_time_block.py @@ -6,11 +6,21 @@ from robot_interface.utils.timeblock import TimeBlock class AnyFloat(object): + """ + A helper class used in tests to assert that a mock function was called + with an argument that is specifically a float, regardless of its value. + + It overrides the equality comparison (`__eq__`) to check only the type. + """ def __eq__(self, other): return isinstance(other, float) def test_no_limit(): + """ + Tests the scenario where the `TimeBlock` context manager is used without + a time limit. + """ callback = mock.Mock() with TimeBlock(callback): @@ -20,6 +30,10 @@ def test_no_limit(): def test_exceed_limit(): + """ + Tests the scenario where the execution time within the `TimeBlock` + exceeds the provided limit. + """ callback = mock.Mock() with TimeBlock(callback, 0): @@ -29,6 +43,10 @@ def test_exceed_limit(): def test_within_limit(): + """ + Tests the scenario where the execution time within the `TimeBlock` + stays within the provided limit. + """ callback = mock.Mock() with TimeBlock(callback, 5): From c53307530b524d620da7c11452a0aaa452899b89 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sat, 22 Nov 2025 11:45:32 +0100 Subject: [PATCH 45/63] chore: applied all feedback close: N25B-298 --- src/robot_interface/core/config.py | 81 +++++-------------- .../endpoints/actuation_receiver.py | 2 +- src/robot_interface/endpoints/audio_sender.py | 7 +- src/robot_interface/endpoints/socket_base.py | 16 ++-- src/robot_interface/endpoints/video_sender.py | 2 +- src/robot_interface/state.py | 8 +- src/robot_interface/utils/timeblock.py | 15 +++- test/common/microphone_utils.py | 60 +------------- test/unit/test_actuation_receiver.py | 27 ------- test/unit/test_audio_sender.py | 27 ------- test/unit/test_main_receiver.py | 18 ----- test/unit/test_microphone_utils.py | 2 +- 12 files changed, 53 insertions(+), 212 deletions(-) diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index b3a7adc..86cbe5a 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -5,23 +5,14 @@ class AgentSettings(object): """ Agent port configuration. - :param actuating_receiver_port: Port for receiving actuation commands. - :type actuating_receiver_port: int - :param main_receiver_port: Port for receiving main messages. - :type main_receiver_port: int - :param video_sender_port: Port used for sending video frames. - :type video_sender_port: int - :param audio_sender_port: Port used for sending audio data. - :type audio_sender_port: int - :ivar actuating_receiver_port: Port for receiving actuation commands. - :type actuating_receiver_port: int + :vartype actuating_receiver_port: int :ivar main_receiver_port: Port for receiving main messages. - :type main_receiver_port: int + :vartype main_receiver_port: int :ivar video_sender_port: Port used for sending video frames. - :type video_sender_port: int + :vartype video_sender_port: int :ivar audio_sender_port: Port used for sending audio data. - :type audio_sender_port: int + :vartype audio_sender_port: int """ def __init__( self, @@ -40,31 +31,18 @@ class VideoConfig(object): """ Video configuration constants. - :param camera_index: Index of the camera to use. - :type camera_index: int - :param resolution: Video resolution mode. - :type resolution: int - :param color_space: Color space identifier. - :type color_space: int - :param fps: Frames per second of the video stream. - :type fps: int - :param stream_name: Name of the video stream. - :type stream_name: str - :param image_buffer: Internal buffer size for video frames. - :type image_buffer: int - :ivar camera_index: Index of the camera used. - :type camera_index: int + :vartype camera_index: int :ivar resolution: Video resolution mode. - :type resolution: int + :vartype resolution: int :ivar color_space: Color space identifier. - :type color_space: int + :vartype color_space: int :ivar fps: Frames per second of the video stream. - :type fps: int + :vartype fps: int :ivar stream_name: Name of the video stream. - :type stream_name: str + :vartype stream_name: str :ivar image_buffer: Internal buffer size for video frames. - :type image_buffer: int + :vartype image_buffer: int """ def __init__( self, @@ -87,19 +65,12 @@ class AudioConfig(object): """ Audio configuration constants. - :param sample_rate: Audio sampling rate in Hz. - :type sample_rate: int - :param chunk_size: Size of audio chunks to capture/process. - :type chunk_size: int - :param channels: Number of audio channels. - :type channels: int - :ivar sample_rate: Audio sampling rate in Hz. - :type sample_rate: int + :vartype sample_rate: int :ivar chunk_size: Size of audio chunks to capture/process. - :type chunk_size: int + :vartype chunk_size: int :ivar channels: Number of audio channels. - :type channels: int + :vartype channels: int """ def __init__(self, sample_rate=16000, chunk_size=512, channels=1): self.sample_rate = sample_rate @@ -111,15 +82,10 @@ class MainConfig(object): """ Main system configuration. - :param poll_timeout_ms: Timeout for polling events, in milliseconds. - :type poll_timeout_ms: int - :param max_handler_time_ms: Maximum allowed handler time, in milliseconds. - :type max_handler_time_ms: int - :ivar poll_timeout_ms: Timeout for polling events, in milliseconds. - :type poll_timeout_ms: int + :vartype poll_timeout_ms: int :ivar max_handler_time_ms: Maximum allowed handler time, in milliseconds. - :type max_handler_time_ms: int + :vartype max_handler_time_ms: int """ def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): self.poll_timeout_ms = poll_timeout_ms @@ -130,23 +96,14 @@ class Settings(object): """ Global settings container. - :param agent_settings: Agent settings instance or None for defaults. - :type agent_settings: AgentSettings | None - :param video_config: VideoConfig instance or None for defaults. - :type video_config: VideoConfig | None - :param audio_config: AudioConfig instance or None for defaults. - :type audio_config: AudioConfig | None - :param main_config: MainConfig instance or None for defaults. - :type main_config: MainConfig | None - :ivar agent_settings: Agent-related port configuration. - :type agent_settings: AgentSettings + :vartype agent_settings: AgentSettings :ivar video_config: Video stream configuration. - :type video_config: VideoConfig + :vartype video_config: VideoConfig :ivar audio_config: Audio stream configuration. - :type audio_config: AudioConfig + :vartype audio_config: AudioConfig :ivar main_config: Main system-level configuration. - :type main_config: MainConfig + :vartype main_config: MainConfig """ def __init__(self, agent_settings=None, video_config=None, audio_config=None, main_config=None): self.agent_settings = agent_settings or AgentSettings() diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 027d417..a844ddf 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -20,7 +20,7 @@ class ActuationReceiver(ReceiverBase): :type port: int :ivar _tts_service: The text-to-speech service object from the Qi session. - :vartype _tts_service: ssl.SSLSession | None + :vartype _tts_service: qi.Session | None """ def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port): diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index 6f3b135..448d6f3 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -24,13 +24,13 @@ class AudioSender(SocketBase): :type port: int :ivar thread: Thread used for sending audio. - :type thread: threading.Thread | None + :vartype thread: threading.Thread | None :ivar audio: PyAudio instance. - :type audio: pyaudio.PyAudio | None + :vartype audio: pyaudio.PyAudio | None :ivar microphone: Selected microphone information. - :type microphone: dict | None + :vartype microphone: dict | None """ def __init__(self, zmq_context, port=settings.agent_settings.audio_sender_port): super(AudioSender, self).__init__(str("audio")) # Convert future's unicode_literal to str @@ -51,7 +51,6 @@ class AudioSender(SocketBase): Will not start if no microphone is available. """ - if not self.microphone: logger.info("Not listening: no microphone available.") return diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index 248c7dd..12797a3 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -7,17 +7,20 @@ class SocketBase(object): """ Base class for endpoints associated with a ZeroMQ socket. - :ivar identifier: The identifier of the endpoint. + :param identifier: The identifier of the endpoint. :type identifier: str + + :ivar identifier: The identifier of the endpoint. + :vartype identifier: str :ivar port: The port used by the socket, set by `create_socket`. - :type port: int | None + :vartype port: int | None :ivar socket: The ZeroMQ socket object, set by `create_socket`. - :type socket: zmq.Socket | None + :vartype socket: zmq.Socket | None :ivar bound: Whether the socket is bound or connected, set by `create_socket`. - :type bound: bool | None + :vartype bound: bool | None """ __metaclass__ = ABCMeta @@ -43,8 +46,7 @@ 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). + :param options: A list of tuples where the first element contains the option and the second the value. :type options: list[tuple[int, int]] :param bind: Whether to bind the socket or connect to it. @@ -73,7 +75,7 @@ class SocketBase(object): Description of the endpoint. Used for 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 + 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 1bc0617..16ea498 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -24,7 +24,7 @@ class VideoSender(SocketBase): """ Prepares arguments for retrieving video images from Pepper and starts video loop on a separate thread. - Will not start of no qi session is available. + Will not start if no qi session is available. """ if not state.qi_session: logging.info("No Qi session available. Not starting video loop.") diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index 2e2b149..b625867 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -14,16 +14,16 @@ class State(object): detect this via the `exit_event` property being set. :ivar is_initialized: Flag indicating whether the state setup (exit handlers, QI session) has completed. - :type is_initialized: bool + :vartype is_initialized: bool :ivar exit_event: A thread event used to signal all threads that the program is shutting down. - :type exit_event: threading.Event | None + :vartype exit_event: threading.Event | None :ivar sockets: A list of ZeroMQ socket wrappers (`SocketBase`) that need to be closed during deinitialization. - :type sockets: List[SocketBase] + :vartype sockets: List[SocketBase] :ivar qi_session: The QI session object used for interaction with the robot/platform services. - :type qi_session: None | ssl.SSLSession + :vartype qi_session: None | qi.Session """ def __init__(self): self.is_initialized = False diff --git a/src/robot_interface/utils/timeblock.py b/src/robot_interface/utils/timeblock.py index 3b50fff..584963b 100644 --- a/src/robot_interface/utils/timeblock.py +++ b/src/robot_interface/utils/timeblock.py @@ -7,13 +7,22 @@ class TimeBlock(object): limit, or if no limit is given, the callback will be called with the time that the block took. :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. + 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. + 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 + + :ivar limit_ms: The number of milliseconds the block of code is allowed to take. + :vartype limit_ms: float | None + + :ivar callback: The callback function that is called when the block of code is over. + :vartype callback: Callable[[float], None] + + ivar start: The start time of the block, set when entering the context. + :vartype start: float | None """ def __init__(self, callback, limit_ms=None): self.limit_ms = float(limit_ms) if limit_ms is not None else None diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py index 911f4a2..65499ea 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -23,9 +23,6 @@ class MicrophoneUtils(object): The result must contain at least "index", as this is used to identify the microphone, and "name" for logging. It must have one or more channels (`maxInputChannels`), and a default sample rate of at least 16000 Hz. - - :param pyaudio_instance: A mocked or real PyAudio instance used to query microphone information. - :type pyaudio_instance: PyAudio """ result = choose_mic_default(pyaudio_instance) assert "index" in result @@ -47,12 +44,6 @@ class MicrophoneUtils(object): Tests the robustness of the interactive selection when the user first enters a non-integer value, ensuring the system prompts again without error and accepts a valid integer on the second attempt. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ microphones = get_microphones(pyaudio_instance) target_microphone = next(microphones) @@ -74,12 +65,6 @@ class MicrophoneUtils(object): """ Tests that the interactive selection method prevents the user from entering a negative integer as a microphone index. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ microphones = get_microphones(pyaudio_instance) target_microphone = next(microphones) @@ -101,12 +86,6 @@ class MicrophoneUtils(object): """ Tests that the interactive selection method prevents the user from entering an index that exceeds the total number of available microphones. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ real_count = len(list(get_microphones(pyaudio_instance))) mock_input = mocker.patch("__builtin__.raw_input", side_effect=[str(real_count), "0"]) @@ -126,12 +105,6 @@ class MicrophoneUtils(object): Tests the core interactive functionality by simulating the selection of a random valid microphone index and verifying that the correct microphone information is returned. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ microphones = list(get_microphones(pyaudio_instance)) random_index = random.randrange(len(microphones)) @@ -143,6 +116,9 @@ class MicrophoneUtils(object): assert result["index"] == microphones[random_index]["index"] def test_choose_mic_no_arguments(self, pyaudio_instance, mocker): + """ + Tests `choose_mic_arguments` when no command-line arguments are provided, + """ mocker.patch.object(sys, "argv", []) result = choose_mic_arguments(pyaudio_instance) @@ -153,12 +129,6 @@ class MicrophoneUtils(object): """ Tests `choose_mic_arguments` when the microphone name is passed as a separate argument. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ for mic in get_microphones(pyaudio_instance): mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) @@ -172,12 +142,6 @@ class MicrophoneUtils(object): """ Tests `choose_mic_arguments` when the microphone name is passed using an equals sign (`--microphone=NAME`). - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ for mic in get_microphones(pyaudio_instance): mocker.patch.object(sys, "argv", ["--microphone={}".format(mic["name"])]) @@ -191,12 +155,6 @@ class MicrophoneUtils(object): """ Tests `choose_mic_arguments` when a non-existent microphone name is passed via command-line arguments, expecting the function to return None. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ mocker.patch.object(sys, "argv", ["--microphone", "Surely this microphone doesn't exist"]) @@ -208,12 +166,6 @@ class MicrophoneUtils(object): """ Tests `choose_mic` function when a valid microphone is specified via command-line arguments. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ mic = next(get_microphones(pyaudio_instance)) mocker.patch.object(sys, "argv", ["--microphone", mic["name"]]) @@ -228,12 +180,6 @@ class MicrophoneUtils(object): Tests `choose_mic` function when no command-line arguments are provided, verifying that the function falls back correctly to the system's default microphone selection. - - :param pyaudio_instance: A mocked or real PyAudio instance. - :type pyaudio_instance: PyAudio - - :param mocker: The fixture used for mocking built-in functions and system objects. - :type mocker: pytest_mock.plugin.MockerFixture """ default_mic = choose_mic_default(pyaudio_instance) mocker.patch.object(sys, "argv", []) diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py index 7ea504c..74620ab 100644 --- a/test/unit/test_actuation_receiver.py +++ b/test/unit/test_actuation_receiver.py @@ -23,9 +23,6 @@ def test_handle_unimplemented_endpoint(zmq_context): """ Tests that the ``ActuationReceiver.handle_message`` method can handle an unknown or unimplemented endpoint without raising an error. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context """ receiver = ActuationReceiver(zmq_context) # Should not error @@ -39,12 +36,6 @@ def test_speech_message_no_data(zmq_context, mocker): """ Tests that the message handler logs a warning when a speech actuation request (`actuate/speech`) is received but contains empty string data. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context - - :param mocker: The pytest-mock fixture used to patch `logging.warn`. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_warn = mocker.patch("logging.warn") @@ -58,12 +49,6 @@ def test_speech_message_invalid_data(zmq_context, mocker): """ Tests that the message handler logs a warning when a speech actuation request (`actuate/speech`) is received with data that is not a string (e.g., a boolean). - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context - - :param mocker: The pytest-mock fixture used to patch `logging.warn`. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_warn = mocker.patch("logging.warn") @@ -77,12 +62,6 @@ def test_speech_no_qi(zmq_context, mocker): """ Tests the actuation receiver's behavior when processing a speech request but the global state does not have an active QI session. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context - - :param mocker: The pytest-mock fixture used to patch the global state. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") @@ -99,12 +78,6 @@ def test_speech(zmq_context, mocker): """ Tests the core speech actuation functionality by mocking the QI TextToSpeech service and verifying that it is called correctly. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context - - :param mocker: The pytest-mock fixture used to patch state and modules. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index 8923ba0..13cd4bf 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -24,12 +24,6 @@ def zmq_context(): def test_no_microphone(zmq_context, mocker): """ Tests the scenario where no valid microphone can be chosen for recording. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context - - :param mocker: The pytest-mock fixture used to patch internal dependencies. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_info_logger = mocker.patch("robot_interface.endpoints.audio_sender.logger.info") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") @@ -49,12 +43,6 @@ def test_unicode_mic_name(zmq_context, mocker): """ Tests the robustness of the `AudioSender` when handling microphone names that contain Unicode characters. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context - - :param mocker: The pytest-mock fixture used to patch internal dependencies. - :type mocker: pytest_mock.plugin.MockerFixture """ mocker.patch("robot_interface.endpoints.audio_sender.threading") mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") @@ -74,12 +62,6 @@ def test_unicode_mic_name(zmq_context, mocker): def _fake_read(num_frames): """ Helper function to simulate reading raw audio data from a microphone stream. - - :param num_frames: The number of audio frames requested. - :type num_frames: int - - :return: A byte string containing random data, simulating audio. - :rtype: str """ return os.urandom(num_frames * 4) @@ -87,9 +69,6 @@ def _fake_read(num_frames): def test_sending_audio(mocker): """ Tests the successful sending of audio data over a ZeroMQ socket. - - :param mocker: The pytest-mock fixture used to patch internal dependencies. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} @@ -118,9 +97,6 @@ def test_sending_audio(mocker): def _fake_read_error(num_frames): """ Helper function to simulate an I/O error during microphone stream reading. - - :param num_frames: The number of audio frames requested. - :type num_frames: int """ raise IOError() @@ -128,9 +104,6 @@ def _fake_read_error(num_frames): def test_break_microphone(mocker): """ Tests the error handling when the microphone stream breaks (raises an IOError). - - :param mocker: The pytest-mock fixture used to patch internal dependencies. - :type mocker: pytest_mock.plugin.MockerFixture """ mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} diff --git a/test/unit/test_main_receiver.py b/test/unit/test_main_receiver.py index c37bdff..2fb8dfc 100644 --- a/test/unit/test_main_receiver.py +++ b/test/unit/test_main_receiver.py @@ -20,9 +20,6 @@ def zmq_context(): def test_handle_ping(zmq_context): """ Tests the receiver's ability to handle the "ping" endpoint with data. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({"endpoint": "ping", "data": "pong"}) @@ -37,9 +34,6 @@ def test_handle_ping_none(zmq_context): """ Tests the receiver's ability to handle the ping endpoint when the data field is explicitly set to None. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({"endpoint": "ping", "data": None}) @@ -54,12 +48,6 @@ def test_handle_ping_none(zmq_context): def test_handle_negotiate_ports(mock_state, zmq_context): """ Tests the handling of the "negotiate/ports" endpoint. - - :param mock_state: Mocked global application state. - :type mock_state: mock.Mock - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context """ receiver = MainReceiver(zmq_context) mock_state.sockets = [receiver] @@ -85,9 +73,6 @@ def test_handle_unimplemented_endpoint(zmq_context): """ Tests that the receiver correctly handles a request to a completely unknown or non-existent endpoint. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({ @@ -108,9 +93,6 @@ def test_handle_unimplemented_negotiation_endpoint(zmq_context): The expected behavior is to return a specific "negotiate/error" response with a descriptive error string. - - :param zmq_context: The ZeroMQ context fixture. - :type zmq_context: zmq.Context """ receiver = MainReceiver(zmq_context) response = receiver.handle_message({ diff --git a/test/unit/test_microphone_utils.py b/test/unit/test_microphone_utils.py index 7cb13de..df31ca3 100644 --- a/test/unit/test_microphone_utils.py +++ b/test/unit/test_microphone_utils.py @@ -15,7 +15,7 @@ class MockPyAudio: the core PyAudio methods required for device enumeration. :ivar devices: A list of dictionaries representing mock audio devices. - :type devices: List[Dict[str, Any]] + :vartype devices: List[Dict[str, Any]] """ def __init__(self): # You can predefine fake device info here From 64c6f0addb6d2b22ac42f1a2457b1bc607bb3030 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sat, 22 Nov 2025 12:44:13 +0100 Subject: [PATCH 46/63] docs: make doc generator understand multi line ref: N25B-298 --- src/robot_interface/utils/microphone.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robot_interface/utils/microphone.py b/src/robot_interface/utils/microphone.py index 3bf9fe6..816c7f6 100644 --- a/src/robot_interface/utils/microphone.py +++ b/src/robot_interface/utils/microphone.py @@ -29,7 +29,7 @@ def choose_mic_interactive(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone. + if there is no microphone. :rtype: dict | None """ microphones = list(get_microphones(audio)) @@ -61,7 +61,7 @@ def choose_mic_default(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone. + if there is no microphone. :rtype: dict | None """ try: @@ -78,7 +78,7 @@ def choose_mic_arguments(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone satisfied by the arguments. + if there is no microphone satisfied by the arguments. :rtype: dict | None """ microphone_name = None @@ -112,7 +112,7 @@ def choose_mic(audio): :type audio: pyaudio.PyAudio :return: A dictionary from PyAudio containing information about the microphone to use, or None - if there is no microphone. + if there is no microphone. :rtype: dict | None """ chosen_mic = choose_mic_arguments(audio) From 6859451bf9036033f35af21c729b3657f54afee0 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sat, 22 Nov 2025 12:36:34 +0000 Subject: [PATCH 47/63] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Twirre --- src/robot_interface/endpoints/actuation_receiver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index a844ddf..927efbd 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -23,7 +23,6 @@ class ActuationReceiver(ReceiverBase): :vartype _tts_service: qi.Session | None """ def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port): - super(ActuationReceiver, self).__init__("actuation") self.create_socket(zmq_context, zmq.SUB, port) self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options From c1e92feba7c10969792416a703eb8ccb237abc3d Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sat, 22 Nov 2025 12:37:39 +0000 Subject: [PATCH 48/63] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Kasper Marinus --- src/robot_interface/endpoints/video_sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 16ea498..9fa1132 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -49,7 +49,7 @@ class VideoSender(SocketBase): :type vid_service: Object (Qi service object) :param vid_stream_name: The name of a camera subscription on the video service object vid_service - :type vid_stream_name: String + :type vid_stream_name: str """ while not state.exit_event.is_set(): try: From a53871360e65c254675da2f015b80451f840dfb5 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Sat, 22 Nov 2025 19:32:50 +0100 Subject: [PATCH 49/63] docs: remove duplicate and double space ref: N25B-298 --- src/robot_interface/endpoints/socket_base.py | 3 --- test/common/microphone_utils.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index 12797a3..9c7c20b 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -7,9 +7,6 @@ class SocketBase(object): """ Base class for endpoints associated with a ZeroMQ socket. - :param identifier: The identifier of the endpoint. - :type identifier: str - :ivar identifier: The identifier of the endpoint. :vartype identifier: str diff --git a/test/common/microphone_utils.py b/test/common/microphone_utils.py index 65499ea..51b353e 100644 --- a/test/common/microphone_utils.py +++ b/test/common/microphone_utils.py @@ -177,7 +177,7 @@ class MicrophoneUtils(object): def test_choose_mic_no_argument(self, pyaudio_instance, mocker): """ - Tests `choose_mic` function when no command-line arguments + Tests `choose_mic` function when no command-line arguments are provided, verifying that the function falls back correctly to the system's default microphone selection. """ From 6ea870623bd47e261b4acbe652a8e7e97e0c7163 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 13:32:31 +0000 Subject: [PATCH 50/63] test: added socket base tests --- test/unit/test_socket_base.py | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 test/unit/test_socket_base.py diff --git a/test/unit/test_socket_base.py b/test/unit/test_socket_base.py new file mode 100644 index 0000000..37aabf4 --- /dev/null +++ b/test/unit/test_socket_base.py @@ -0,0 +1,55 @@ +import mock +import zmq +from robot_interface.endpoints.socket_base import SocketBase + + +def test_close_covers_both_branches(): + """ + Exercise both possible paths inside SocketBase.close(): + - when no socket exists (should just return), + - when a socket object is present (its close() method should be called). + """ + sb = SocketBase("x") + + # First check the case where socket is None. Nothing should happen here. + sb.close() + + # Now simulate a real socket so the close() call is triggered. + fake_socket = mock.Mock() + sb.socket = fake_socket + sb.close() + + fake_socket.close.assert_called_once() + + +def test_create_socket_and_endpoint_description_full_coverage(): + """ + Test the less-commonly used branch of create_socket() where bind=False. + This covers: + - the loop that sets socket options, + - the connect() path, + - the logic in endpoint_description() that inverts self.bound. + """ + fake_context = mock.Mock() + fake_socket = mock.Mock() + + # The context should hand back our fake socket object. + fake_context.socket.return_value = fake_socket + + sb = SocketBase("id") + + # Calling create_socket with bind=False forces the connect() code path. + sb.create_socket( + zmq_context=fake_context, + socket_type=zmq.SUB, + port=9999, + options=[(zmq.CONFLATE, 1)], # one option is enough to hit the loop + bind=False, + ) + + fake_socket.setsockopt.assert_called_once_with(zmq.CONFLATE, 1) + fake_socket.connect.assert_called_once_with("tcp://localhost:9999") + + # Check that endpoint_description reflects bound=False -> "bind": True + desc = sb.endpoint_description() + assert desc == {"id": "id", "port": 9999, "bind": True} From 7628e47478336993f453c3ce04280e4de4fb798c Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 20:02:28 +0000 Subject: [PATCH 51/63] test: added qi_utils test --- test/unit/test_qi_utils.py | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 test/unit/test_qi_utils.py diff --git a/test/unit/test_qi_utils.py b/test/unit/test_qi_utils.py new file mode 100644 index 0000000..70d9c69 --- /dev/null +++ b/test/unit/test_qi_utils.py @@ -0,0 +1,90 @@ +import sys + +# Import module under test +import robot_interface.utils.qi_utils as qi_utils + + +def reload_qi_utils_with(qi_module): + """ + Helper: reload qi_utils after injecting a fake qi module. + Python 2 uses built-in reload(). + Just changing sys.modules[qi] won't affect the already imported module. + """ + if qi_module is None: + if "qi" in sys.modules: + del sys.modules["qi"] + else: + sys.modules["qi"] = qi_module + + # Python 2 reload + global qi_utils + qi_utils = reload(qi_utils) + + +def test_get_qi_session_no_qi_module(): + """ + Tests the 'qi is None' path. + """ + reload_qi_utils_with(None) + + session = qi_utils.get_qi_session() + assert session is None + + +def test_get_qi_session_no_qi_url_argument(monkeypatch): + """ + Tests the '--qi-url not in sys.argv' path. + """ + class FakeQi: + pass + + reload_qi_utils_with(FakeQi()) + + monkeypatch.setattr(sys, "argv", ["pytest"]) + + session = qi_utils.get_qi_session() + assert session is None + + +def test_get_qi_session_runtime_error(monkeypatch): + """ + Tests the 'exept RuntineError' path. + """ + class FakeApp: + def start(self): + raise RuntimeError("boom") + + class FakeQi: + Application = lambda self=None: FakeApp() + + reload_qi_utils_with(FakeQi()) + + monkeypatch.setattr(sys, "argv", ["pytest", "--qi-url", "tcp://localhost"]) + + session = qi_utils.get_qi_session() + assert session is None + + +def test_get_qi_session_success(monkeypatch): + """ + Tests a valid path. + """ + class FakeSession: + pass + + class FakeApp: + def __init__(self): + self.session = FakeSession() + + def start(self): + return True + + class FakeQi: + Application = lambda self=None: FakeApp() + + reload_qi_utils_with(FakeQi()) + + monkeypatch.setattr(sys, "argv", ["pytest", "--qi-url", "tcp://localhost"]) + + session = qi_utils.get_qi_session() + assert isinstance(session, FakeSession) From 2350f6eec7eef124d47f6c8b06316b064310b3e4 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 20:05:10 +0000 Subject: [PATCH 52/63] test: added init failure test in audio sender --- test/unit/test_audio_sender.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index 13cd4bf..4e337c2 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -1,6 +1,5 @@ # coding=utf-8 import os -import time import mock import pytest @@ -127,3 +126,22 @@ def test_break_microphone(mocker): sender.wait_until_done() send_socket.assert_not_called() + + +def test_pyaudio_init_failure(mocker, zmq_context): + """ + Tests the behavior when PyAudio initialization fails (raises an IOError). + """ + # Prevent binding the ZMQ socket + mocker.patch("robot_interface.endpoints.audio_sender.AudioSender.create_socket") + + # Simulate PyAudio() failing + mocker.patch( + "robot_interface.endpoints.audio_sender.pyaudio.PyAudio", + side_effect=IOError("boom") + ) + + sender = AudioSender(zmq_context) + + assert sender.audio is None + assert sender.microphone is None From fbe8f59c38d06894a70c55966d1a10a8d9ddb059 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 20:06:58 +0000 Subject: [PATCH 53/63] test: added not overridden reciever base test --- test/unit/test_reciever_base.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/unit/test_reciever_base.py diff --git a/test/unit/test_reciever_base.py b/test/unit/test_reciever_base.py new file mode 100644 index 0000000..e4ef78b --- /dev/null +++ b/test/unit/test_reciever_base.py @@ -0,0 +1,19 @@ +import pytest +from robot_interface.endpoints.receiver_base import ReceiverBase + + +def test_receiver_base_not_implemented(monkeypatch): + """ + Ensure that the base ReceiverBase raises NotImplementedError when + handle_message is called on a subclass that does not implement it. + """ + # Patch the __abstractmethods__ to allow instantiation + monkeypatch.setattr(ReceiverBase, "__abstractmethods__", frozenset()) + + class DummyReceiver(ReceiverBase): + pass + + dummy = DummyReceiver("dummy") # Can now instantiate + + with pytest.raises(NotImplementedError): + dummy.handle_message({"endpoint": "dummy", "data": None}) From 336acac4405eb4d642e48deefa42ced015e05561 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 20:24:19 +0000 Subject: [PATCH 54/63] test: added tests for full state coverage --- test/unit/test_state.py | 108 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test/unit/test_state.py diff --git a/test/unit/test_state.py b/test/unit/test_state.py new file mode 100644 index 0000000..348f852 --- /dev/null +++ b/test/unit/test_state.py @@ -0,0 +1,108 @@ +import threading +import signal +import pytest +import mock + +from robot_interface.state import State + + +def test_initialize_does_not_reinitialize(): + """ + Check that calling `initialize` on an already initialized state does not change existing + attributes. + """ + state = State() + + # Mock qi_session to avoid real session creation + mock_session = mock.MagicMock() + state.qi_session = mock_session + + # Set state as already initialized + state.is_initialized = True + old_exit_event = state.exit_event + + # Call initialize + state.initialize() + + # Ensure existing attributes were not overwritten + assert state.exit_event == old_exit_event # exit_event should not be recreated + assert state.qi_session == mock_session # qi_session should not be replaced + assert state.is_initialized is True # is_initialized should remain True + + +def test_deinitialize_behavior(): + """Check that deinitialize closes sockets and updates the initialization state correctly.""" + state = State() + + # Case 1: Initialized with sockets + state.is_initialized = True + mock_socket_1 = mock.Mock() + mock_socket_2 = mock.Mock() + state.sockets = [mock_socket_1, mock_socket_2] + state.deinitialize() + + # Sockets should be closed + mock_socket_1.close.assert_called_once() + mock_socket_2.close.assert_called_once() + # State should be marked as not initialized + assert not state.is_initialized + + # Case 2: Not initialized, should not raise + state.is_initialized = False + state.sockets = [] + state.deinitialize() + assert not state.is_initialized + + +def test_access_control_before_initialization(): + """Verify that accessing certain attributes before initialization raises RuntimeError.""" + state = State() + + with pytest.raises(RuntimeError, match=".*sockets.*"): + _ = state.sockets + + with pytest.raises(RuntimeError, match=".*qi_session.*"): + _ = state.qi_session + + +def test_exit_event_before_initialized_returns_if_set(): + """Check that exit_event can be accessed even if state is not initialized, + but only if it is set.""" + state = State() + + # Manually create and set the exit_event + object.__setattr__(state, "exit_event", threading.Event()) + object.__getattribute__(state, "exit_event").set() + + # Should return the event without raising + assert state.exit_event.is_set() + + +def test_getattribute_allowed_attributes_before_init(): + """Ensure attributes allowed before initialization can be accessed without error.""" + state = State() + + assert callable(state.initialize) + assert callable(state.deinitialize) + assert state.is_initialized is False + assert state.__dict__ is not None + assert state.__class__.__name__ == "State" + assert state.__doc__ is not None + + +def test_signal_handler_sets_exit_event(monkeypatch): + """Ensure SIGINT triggers the exit_event via signal handler.""" + state = State() + + # Patch get_qi_session to prevent real session creation + monkeypatch.setattr("robot_interface.state.get_qi_session", lambda: "dummy_session") + + # Initialize state to set up signal handlers + state.initialize() + + # Simulate SIGINT + signal_handler = signal.getsignal(signal.SIGINT) + signal_handler(None, None) + + # Exit event should be set + assert state.exit_event.is_set() From e2a71ad6c220beb56184c837cdf4b58bf8eada86 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 20:37:59 +0000 Subject: [PATCH 55/63] test: added main tests --- test/unit/test_main.py | 222 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 test/unit/test_main.py diff --git a/test/unit/test_main.py b/test/unit/test_main.py new file mode 100644 index 0000000..f323630 --- /dev/null +++ b/test/unit/test_main.py @@ -0,0 +1,222 @@ +import pytest +import threading +import zmq + +import robot_interface.main as main_mod +from robot_interface.state import state + + +class FakeSocket: + """Mock ZMQ socket for testing.""" + def __init__(self, socket_type, messages=None): + self.socket_type = socket_type + self.messages = messages or [] + self.sent = [] + self.closed = False + + def recv_json(self): + if not self.messages: + raise RuntimeError("No more messages") + return self.messages.pop(0) + + def send_json(self, msg): + self.sent.append(msg) + + def getsockopt(self, opt): + if opt == zmq.TYPE: + return self.socket_type + + def close(self): + self.closed = True + + +class FakeReceiver: + """Base class for main/actuation receivers.""" + def __init__(self, socket): + self.socket = socket + self._called = [] + + def handle_message(self, msg): + self._called.append(msg) + return {"endpoint": "pong", "data": "ok"} + + def close(self): + pass + + +class DummySender: + """Mock sender to test start methods.""" + def __init__(self): + self.called = False + + def start_video_rcv(self): + self.called = True + + def start(self): + self.called = True + + def close(self): + pass + + +@pytest.fixture +def fake_sockets(): + """Create default fake main and actuation sockets.""" + main_sock = FakeSocket(zmq.REP) + act_sock = FakeSocket(zmq.SUB) + return main_sock, act_sock + + +@pytest.fixture +def fake_poll(monkeypatch): + """Patch zmq.Poller to simulate a single polling cycle based on socket messages.""" + class FakePoller: + def __init__(self): + self.registered = {} + self.used = False + + def register(self, socket, flags): + self.registered[socket] = flags + + def poll(self, timeout): + # Only return sockets that still have messages + active_socks = { + s: flags + for s, flags + in self.registered.items() + if getattr(s, "messages", []) + } + if active_socks: + return active_socks + # No more messages, exit loop + state.exit_event.set() + return {} + + poller_instance = FakePoller() + monkeypatch.setattr(main_mod.zmq, "Poller", lambda: poller_instance) + return poller_instance + + +@pytest.fixture +def patched_main_components(monkeypatch, fake_sockets, fake_poll): + """ + Fixture to patch main receivers and senders with fakes. + Returns the fake instances for inspection in tests. + """ + main_sock, act_sock = fake_sockets + fake_main = FakeReceiver(main_sock) + fake_act = FakeReceiver(act_sock) + video_sender = DummySender() + audio_sender = DummySender() + + monkeypatch.setattr(main_mod, "MainReceiver", lambda ctx: fake_main) + monkeypatch.setattr(main_mod, "ActuationReceiver", lambda ctx: fake_act) + monkeypatch.setattr(main_mod, "VideoSender", lambda ctx: video_sender) + monkeypatch.setattr(main_mod, "AudioSender", lambda ctx: audio_sender) + + # Register sockets for the fake poller + fake_poll.registered = {main_sock: zmq.POLLIN, act_sock: zmq.POLLIN} + + return fake_main, fake_act, video_sender, audio_sender + + +def test_main_loop_rep_response(patched_main_components): + """REP socket returns proper response and handlers are called.""" + state.initialize() + fake_main, fake_act, video_sender, audio_sender = patched_main_components + + fake_main.socket.messages = [{"endpoint": "ping", "data": "x"}] + fake_act.socket.messages = [{"endpoint": "actuate/speech", "data": "hello"}] + + main_mod.main_loop(object()) + + assert fake_main.socket.sent == [{"endpoint": "pong", "data": "ok"}] + assert fake_main._called + assert fake_act._called + assert video_sender.called + assert audio_sender.called + state.deinitialize() + + +@pytest.mark.parametrize( + "messages", + [ + [{"no_endpoint": True}], # Invalid dict + [["not", "a", "dict"]] # Non-dict message + ] +) +def test_main_loop_invalid_or_non_dict_message(patched_main_components, messages): + """Invalid or non-dict messages are ignored.""" + state.initialize() + fake_main, _, _, _ = patched_main_components + + fake_main.socket.messages = messages + main_mod.main_loop(object()) + assert fake_main.socket.sent == [] + state.deinitialize() + + +def test_main_loop_handler_returns_none(patched_main_components, monkeypatch): + """Handler returning None still triggers send_json(None).""" + state.initialize() + fake_main, _, _, _ = patched_main_components + + class NoneHandler(FakeReceiver): + def handle_message(self, msg): + self._called.append(msg) + return None + + monkeypatch.setattr(main_mod, "MainReceiver", lambda ctx: NoneHandler(fake_main.socket)) + fake_main.socket.messages = [{"endpoint": "some", "data": None}] + + main_mod.main_loop(object()) + assert fake_main.socket.sent == [None] + state.deinitialize() + + +def test_main_loop_overtime_callback(patched_main_components, monkeypatch): + """TimeBlock callback is triggered if handler takes too long.""" + state.initialize() + fake_main, _, _, _ = patched_main_components + fake_main.socket.messages = [{"endpoint": "ping", "data": "x"}] + + class FakeTimeBlock: + def __init__(self, callback, limit_ms): + self.callback = callback + def __enter__(self): + return self + def __exit__(self, *a): + self.callback(999.0) + + monkeypatch.setattr(main_mod, "TimeBlock", FakeTimeBlock) + main_mod.main_loop(object()) + assert fake_main.socket.sent == [{"endpoint": "pong", "data": "ok"}] + state.deinitialize() + + +def test_main_keyboard_interrupt(monkeypatch): + """main() handles KeyboardInterrupt and cleans up.""" + called = {"deinitialized": False, "term_called": False} + + class FakeContext: + def term(self): called["term_called"] = True + + monkeypatch.setattr(main_mod.zmq, "Context", lambda: FakeContext()) + + def raise_keyboard_interrupt(*_): + raise KeyboardInterrupt() + monkeypatch.setattr(main_mod, "main_loop", raise_keyboard_interrupt) + + def fake_initialize(): + state.is_initialized = True + state.exit_event = threading.Event() + def fake_deinitialize(): + called["deinitialized"] = True + state.is_initialized = False + + monkeypatch.setattr(main_mod.state, "initialize", fake_initialize) + monkeypatch.setattr(main_mod.state, "deinitialize", fake_deinitialize) + + main_mod.main() + assert called["term_called"] is True + assert called["deinitialized"] is True From 96f328d56cbb64945f831a10243a151669dc4d1f Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Mon, 24 Nov 2025 20:41:08 +0000 Subject: [PATCH 56/63] test: added full video sender coverage tests --- test/unit/test_video_sender.py | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/unit/test_video_sender.py diff --git a/test/unit/test_video_sender.py b/test/unit/test_video_sender.py new file mode 100644 index 0000000..430f658 --- /dev/null +++ b/test/unit/test_video_sender.py @@ -0,0 +1,99 @@ +# coding=utf-8 + +import mock +import pytest +import zmq + +from robot_interface.endpoints.video_sender import VideoSender +from robot_interface.state import state +from robot_interface.core.config import settings + + +@pytest.fixture +def zmq_context(): + """Provide a ZMQ context.""" + yield zmq.Context() + + +def _patch_basics(mocker): + """Common patches: prevent real threads, port binds, and state errors.""" + mocker.patch("robot_interface.endpoints.socket_base.zmq.Socket.bind") + mocker.patch("robot_interface.endpoints.video_sender.threading.Thread") + mocker.patch.object(state, "is_initialized", True) + + +def _patch_exit_event(mocker): + """Make exit_event stop the loop after one iteration.""" + fake_event = mock.Mock() + fake_event.is_set.side_effect = [False, True] + mocker.patch.object(state, "exit_event", fake_event) + + +def test_no_qi_session(zmq_context, mocker): + """Video loop should not start without a qi_session.""" + _patch_basics(mocker) + mocker.patch.object(state, "qi_session", None) + + sender = VideoSender(zmq_context) + sender.start_video_rcv() + + assert not hasattr(sender, "thread") + + +def test_video_streaming(zmq_context, mocker): + """VideoSender should send retrieved image data.""" + _patch_basics(mocker) + _patch_exit_event(mocker) + + # Pepper's image buffer lives at index 6 + mocker.patch.object(settings.video_config, "image_buffer", 6) + + mock_video_service = mock.Mock() + mock_video_service.getImageRemote.return_value = [None]*6 + ["fake_img"] + + fake_session = mock.Mock() + fake_session.service.return_value = mock_video_service + mocker.patch.object(state, "qi_session", fake_session) + + mocker.patch.object( + fake_session.service("ALVideoDevice"), + "subscribeCamera", + return_value="stream_name" + ) + + sender = VideoSender(zmq_context) + send_socket = mock.Mock() + sender.socket.send = send_socket + + sender.start_video_rcv() + sender.video_rcv_loop(mock_video_service, "stream_name") + + send_socket.assert_called_with("fake_img") + + +def test_video_receive_error(zmq_context, mocker): + """Errors retrieving images should not call send().""" + _patch_basics(mocker) + _patch_exit_event(mocker) + + mock_video_service = mock.Mock() + mock_video_service.getImageRemote.side_effect = Exception("boom") + + fake_session = mock.Mock() + fake_session.service.return_value = mock_video_service + mocker.patch.object(state, "qi_session", fake_session) + + mocker.patch.object( + fake_session.service("ALVideoDevice"), + "subscribeCamera", + return_value="stream_name" + ) + + sender = VideoSender(zmq_context) + send_socket = mock.Mock() + sender.socket.send = send_socket + + sender.start_video_rcv() + sender.video_rcv_loop(mock_video_service, "stream_name") + + send_socket.assert_not_called() From 28a556becd2c63c6a5b3fae7c51dde5e74c75c15 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 21:12:15 +0100 Subject: [PATCH 57/63] feat: introduce CI/CD with tests Using a custom base image installed on the runner, the installation and tests should work (fast). ref: N25B-367 --- .gitlab-ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..9b19c89 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,38 @@ +# ---------- GLOBAL SETUP ---------- # +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +stages: + - install + - test + +default: + image: qi-py-ri-base:latest + cache: + key: "${CI_COMMIT_REF_SLUG}" + paths: + - .venv/ + policy: pull-push + +# --------- INSTALLING --------- # +install: + stage: install + tags: + - install + script: + - pip install -r requirements.txt + artifacts: + paths: + - .venv/ + expire_in: 1h + +# ---------- TESTING ---------- # +test: + stage: test + needs: + - install + tags: + - test + script: + - PYTHONPATH=${PYTHONPATH}:src pytest test/ From f469e4ce368ef047555b641df493f813255173c8 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 21:46:24 +0100 Subject: [PATCH 58/63] fix: install in a .venv artifact This artifact can be reused in different stages. ref: N25B-367 --- .gitlab-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9b19c89..d4f3a77 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,6 +21,9 @@ install: tags: - install script: + - python -m virtualenv .venv + - source .venv/bin/activate + - echo /qi/pynaoqi-python2.7-2.5.7.1-linux64/lib/python2.7/site-packages/ > .venv/lib/python2.7/site-packages/pynaoqi-python2.7.pth - pip install -r requirements.txt artifacts: paths: @@ -35,4 +38,5 @@ test: tags: - test script: - - PYTHONPATH=${PYTHONPATH}:src pytest test/ + - source .venv/bin/activate + - PYTHONPATH=src pytest test/ From 94b92b3e4a844c6d58aa5b16145dbf94403050d3 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:04:46 +0100 Subject: [PATCH 59/63] feat: re-introduce git hooks Now using the standardized method from the CB. ref: N25B-367 --- .githooks/check-branch-name.sh | 77 +++++++++++++++++++ .githooks/check-commit-msg.sh | 135 +++++++++++++++++++++++++++++++++ .githooks/commit-msg | 16 ---- .githooks/pre-commit | 17 ----- .githooks/prepare-commit-msg | 9 --- .pre-commit-config.yaml | 15 ++++ README.md | 20 +++-- requirements.txt | 1 + 8 files changed, 240 insertions(+), 50 deletions(-) create mode 100755 .githooks/check-branch-name.sh create mode 100755 .githooks/check-commit-msg.sh delete mode 100644 .githooks/commit-msg delete mode 100644 .githooks/pre-commit delete mode 100644 .githooks/prepare-commit-msg create mode 100644 .pre-commit-config.yaml diff --git a/.githooks/check-branch-name.sh b/.githooks/check-branch-name.sh new file mode 100755 index 0000000..6a6669a --- /dev/null +++ b/.githooks/check-branch-name.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# This script checks if the current branch name follows the specified format. +# It's designed to be used as a 'pre-commit' git hook. + +# Format: / +# Example: feat/add-user-login + +# --- Configuration --- +# An array of allowed commit types +ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert) +# An array of branches to ignore +IGNORED_BRANCHES=(main dev demo) + +# --- Colors for Output --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# --- Helper Functions --- +error_exit() { + echo -e "${RED}ERROR: $1${NC}" >&2 + echo -e "${YELLOW}Branch name format is incorrect. Aborting commit.${NC}" >&2 + exit 1 +} + +# --- Main Logic --- + +# 1. Get the current branch name +BRANCH_NAME=$(git symbolic-ref --short HEAD) + +# 2. Check if the current branch is in the ignored list +for ignored_branch in "${IGNORED_BRANCHES[@]}"; do + if [ "$BRANCH_NAME" == "$ignored_branch" ]; then + echo -e "${GREEN}Branch check skipped for default branch: $BRANCH_NAME${NC}" + exit 0 + fi +done + +# 3. Validate the overall structure: / +if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then + error_exit "Branch name must be in the format: /\nExample: feat/add-user-login" +fi + +# 4. Extract the type and description +TYPE=$(echo "$BRANCH_NAME" | cut -d'/' -f1) +DESCRIPTION=$(echo "$BRANCH_NAME" | cut -d'/' -f2-) + +# 5. Validate the +type_valid=false +for allowed_type in "${ALLOWED_TYPES[@]}"; do + if [ "$TYPE" == "$allowed_type" ]; then + type_valid=true + break + fi +done + +if [ "$type_valid" == false ]; then + error_exit "Invalid type '$TYPE'.\nAllowed types are: ${ALLOWED_TYPES[*]}" +fi + +# 6. Validate the +# Regex breakdown: +# ^[a-z0-9]+ - Starts with one or more lowercase letters/numbers (the first word). +# (-[a-z0-9]+){0,5} - Followed by a group of (dash + word) 0 to 5 times. +# $ - End of the string. +# This entire pattern enforces 1 to 6 words total, separated by dashes. +DESCRIPTION_REGEX="^[a-z0-9]+(-[a-z0-9]+){0,5}$" + +if ! [[ "$DESCRIPTION" =~ $DESCRIPTION_REGEX ]]; then + error_exit "Invalid short description '$DESCRIPTION'.\nIt must be a maximum of 6 words, all lowercase, separated by dashes.\nExample: add-new-user-authentication-feature" +fi + +# If all checks pass, exit successfully +echo -e "${GREEN}Branch name '$BRANCH_NAME' is valid.${NC}" +exit 0 diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh new file mode 100755 index 0000000..497a32f --- /dev/null +++ b/.githooks/check-commit-msg.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +# This script checks if a commit message follows the specified format. +# It's designed to be used as a 'commit-msg' git hook. + +# Format: +# : +# +# [optional] +# +# [ref/close]: + +# --- Configuration --- +# An array of allowed commit types +ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert) + +# --- Colors for Output --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# The first argument to the hook is the path to the file containing the commit message +COMMIT_MSG_FILE=$1 + +# --- Automated Commit Detection --- + +# Read the first line (header) for initial checks +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") + +# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab) +# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..." +MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*" +if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then + echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Revert commits +# Example: "Revert "feat: add new feature"" +REVERT_PATTERN="^Revert \".*\"" +if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then + echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Cherry-pick commits (this pattern appears at the end of the message) +# Example: "(cherry picked from commit deadbeef...)" +# We use grep -q to search the whole file quietly. +CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)" +if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then + echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Squash +# Example: "Squash commits ..." +SQUASH_PATTERN="^Squash .+" +if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then + echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# --- Validation Functions --- + +# Function to print an error message and exit +# Usage: error_exit "Your error message here" +error_exit() { + # >&2 redirects echo to stderr + echo -e "${RED}ERROR: $1${NC}" >&2 + echo -e "${YELLOW}Commit message format is incorrect. Aborting commit.${NC}" >&2 + exit 1 +} + +# --- Main Logic --- + +# 1. Read the header (first line) of the commit message +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") + +# 2. Validate the header format: : +# Regex breakdown: +# ^(type1|type2|...) - Starts with one of the allowed types +# : - Followed by a literal colon +# \s - Followed by a single space +# .+ - Followed by one or more characters for the description +# $ - End of the line +TYPES_REGEX=$( + IFS="|" + echo "${ALLOWED_TYPES[*]}" +) +HEADER_REGEX="^($TYPES_REGEX): .+$" + +if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then + error_exit "Invalid header format.\n\nHeader must be in the format: : \nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature" +fi + +# Only validate footer if commit type is not chore +TYPE=$(echo "$HEADER" | cut -d':' -f1) +if [ "$TYPE" != "chore" ]; then + # 3. Validate the footer (last line) of the commit message + FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE") + + # Regex breakdown: + # ^(ref|close) - Starts with 'ref' or 'close' + # : - Followed by a literal colon + # \s - Followed by a single space + # N25B- - Followed by the literal string 'N25B-' + # [0-9]+ - Followed by one or more digits + # $ - End of the line + FOOTER_REGEX="^(ref|close): N25B-[0-9]+$" + + if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then + error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: \nExample: ref: N25B-123" + fi +fi + +# 4. If the message has more than 2 lines, validate the separator +# A blank line must exist between the header and the body. +LINE_COUNT=$(wc -l <"$COMMIT_MSG_FILE" | xargs) # xargs trims whitespace + +# We only care if there is a body. Header + Footer = 2 lines. +# Header + Blank Line + Body... + Footer > 2 lines. +if [ "$LINE_COUNT" -gt 2 ]; then + # Get the second line + SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE") + + # Check if the second line is NOT empty. If it's not, it's an error. + if [ -n "$SECOND_LINE" ]; then + error_exit "Missing blank line between header and body.\n\nThe second line of your commit message must be empty if a body is present." + fi +fi + +# If all checks pass, exit with success +echo -e "${GREEN}Commit message is valid.${NC}" +exit 0 diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100644 index 41992ad..0000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,16 +0,0 @@ -#!/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 - 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 deleted file mode 100644 index 7e94937..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,17 +0,0 @@ -#!/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+){0,5}$"; then - exit 0 -else - echo "❌ Invalid branch name: $branch" - echo "Branch must be named / (must have one to six words separated by a dash)" - exit 1 -fi \ No newline at end of file diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg deleted file mode 100644 index 5b706c1..0000000 --- a/.githooks/prepare-commit-msg +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -echo "#: - -#[optional body] - -#[optional footer(s)] - -#[ref/close]: " > $1 \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..90daddf --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: local + hooks: + - id: check-commit-msg + name: Check commit message format + entry: .githooks/check-commit-msg.sh + language: script + stages: [commit-msg] + - id: check-branch-name + name: Check branch name format + entry: .githooks/check-branch-name.sh + language: script + stages: [commit] + always_run: true + pass_filenames: false diff --git a/README.md b/README.md index 6a34642..37458df 100644 --- a/README.md +++ b/README.md @@ -134,18 +134,22 @@ For coverage, add `--cov=robot_interface` as an argument to `pytest`. -## GitHooks +## Git Hooks -To activate automatic commits/branch name checks run: +To activate automatic linting, formatting, branch name checks and commit message checks, run (after installing requirements): -```shell -git config --local core.hooksPath .githooks +```bash +pre-commit install +pre-commit install --hook-type commit-msg ``` -If your commit fails its either: -branch name != /description-of-branch , -commit name != : description of the commit. - : N25B-Num's +You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running: + +```bash +git config --local --unset core.hooksPath +``` + +Then run the pre-commit install commands again. ## Documentation Generate documentation web pages using: diff --git a/requirements.txt b/requirements.txt index 981361c..d46c38e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pytest-mock<3.0.0 pytest-cov<3.0.0 sphinx sphinx_rtd_theme +pre-commit From 3a259c11706864bc52d2aec4f1fa0368dd0f8888 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:28:13 +0100 Subject: [PATCH 60/63] feat: add environment variables and docs ref: N25B-352 --- .env.example | 25 +++++ README.md | 7 +- requirements.txt | 1 + src/robot_interface/core/config.py | 94 ++++++++++--------- .../endpoints/actuation_receiver.py | 2 +- .../endpoints/main_receiver.py | 7 +- src/robot_interface/endpoints/socket_base.py | 4 +- src/robot_interface/utils/get_config.py | 32 +++++++ test/integration/test_config.py | 32 +++++++ test/unit/test_get_config.py | 45 +++++++++ 10 files changed, 200 insertions(+), 49 deletions(-) create mode 100644 .env.example create mode 100644 src/robot_interface/utils/get_config.py create mode 100644 test/integration/test_config.py create mode 100644 test/unit/test_get_config.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..173b63c --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Example .env file. To use, make a copy, call it ".env" (i.e. removing the ".example" suffix), then you edit values. +# To make a variable apply, uncomment it (remove the "#" in front of the line). + +# First, some variables that are likely to be configured: + +# The hostname or IP address of the Control Backend. +AGENT__CONTROL_BACKEND_HOST=localhost + + + +# Variables that are unlikely to be configured, you can probably ignore these: + +#AGENT__ACTUATION_RECEIVER_PORT= +#AGENT__MAIN_RECEIVER_PORT= +#AGENT__VIDEO_SENDER_PORT= +#AGENT__AUDIO_SENDER_PORT= +#VIDEO__CAMERA_INDEX= +#VIDEO__RESOLUTION= +#VIDEO__COLOR_SPACE= +#VIDEO__FPS= +#VIDEO__STREAM_NAME= +#VIDEO__IMAGE_BUFFER= +#AUDIO__SAMPLE_RATE= +#AUDIO__CHUNK_SIZE= +#AUDIO__CHANNELS= diff --git a/README.md b/README.md index 37458df..97fab48 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,15 @@ On Windows: $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. +### Program Arguments + +If you want to connect to the actual robot (or simulator), pass the `--qi-url` argument. There's also a `--microphone` argument that can be used to choose a microphone to use. If not given, the program will try the default microphone. If you don't know the name of the microphone, pass the argument with any value, and it will list the names of available microphones. +### Environment Variables + +You may use environment variables to change settings. Make a copy of the [`.env.example`](.env.example) file, name it `.env` and put it in the root directory. The file itself describes how to do the configuration. ## Testing diff --git a/requirements.txt b/requirements.txt index d46c38e..bc679f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ pytest-cov<3.0.0 sphinx sphinx_rtd_theme pre-commit +python-dotenv diff --git a/src/robot_interface/core/config.py b/src/robot_interface/core/config.py index 86cbe5a..9e638b5 100644 --- a/src/robot_interface/core/config.py +++ b/src/robot_interface/core/config.py @@ -1,95 +1,101 @@ from __future__ import unicode_literals +from robot_interface.utils.get_config import get_config + class AgentSettings(object): """ Agent port configuration. - :ivar actuating_receiver_port: Port for receiving actuation commands. - :vartype actuating_receiver_port: int - :ivar main_receiver_port: Port for receiving main messages. + :ivar control_backend_host: Hostname of the control backend, defaults to "localhost". + :vartype control_backend_host: string + :ivar actuation_receiver_port: Port for receiving actuation commands, defaults to 5557. + :vartype actuation_receiver_port: int + :ivar main_receiver_port: Port for receiving main messages, defaults to 5555. :vartype main_receiver_port: int - :ivar video_sender_port: Port used for sending video frames. + :ivar video_sender_port: Port used for sending video frames, defaults to 5556. :vartype video_sender_port: int - :ivar audio_sender_port: Port used for sending audio data. + :ivar audio_sender_port: Port used for sending audio data, defaults to 5558. :vartype audio_sender_port: int """ def __init__( - self, - actuating_receiver_port=5557, - main_receiver_port=5555, - video_sender_port=5556, - audio_sender_port=5558, + self, + control_backend_host=None, + actuation_receiver_port=None, + main_receiver_port=None, + video_sender_port=None, + audio_sender_port=None, ): - self.actuating_receiver_port = actuating_receiver_port - self.main_receiver_port = main_receiver_port - self.video_sender_port = video_sender_port - self.audio_sender_port = audio_sender_port + self.control_backend_host = get_config(control_backend_host, "AGENT__CONTROL_BACKEND_HOST", "localhost") + self.actuation_receiver_port = get_config(actuation_receiver_port, "AGENT__ACTUATION_RECEIVER_PORT", 5557, int) + self.main_receiver_port = get_config(main_receiver_port, "AGENT__MAIN_RECEIVER_PORT", 5555, int) + self.video_sender_port = get_config(video_sender_port, "AGENT__VIDEO_SENDER_PORT", 5556, int) + self.audio_sender_port = get_config(audio_sender_port, "AGENT__AUDIO_SENDER_PORT", 5558, int) class VideoConfig(object): """ Video configuration constants. - :ivar camera_index: Index of the camera used. + :ivar camera_index: Index of the camera used, defaults to 0. :vartype camera_index: int - :ivar resolution: Video resolution mode. + :ivar resolution: Video resolution mode, defaults to 2. :vartype resolution: int - :ivar color_space: Color space identifier. + :ivar color_space: Color space identifier, defaults to 11. :vartype color_space: int - :ivar fps: Frames per second of the video stream. + :ivar fps: Frames per second of the video stream, defaults to 15. :vartype fps: int - :ivar stream_name: Name of the video stream. + :ivar stream_name: Name of the video stream, defaults to "Pepper Video". :vartype stream_name: str - :ivar image_buffer: Internal buffer size for video frames. + :ivar image_buffer: Internal buffer size for video frames, defaults to 6. :vartype image_buffer: int """ def __init__( self, - camera_index=0, - resolution=2, - color_space=11, - fps=15, - stream_name="Pepper Video", - image_buffer=6, + camera_index=None, + resolution=None, + color_space=None, + fps=None, + stream_name=None, + image_buffer=None, ): - self.camera_index = camera_index - self.resolution = resolution - self.color_space = color_space - self.fps = fps - self.stream_name = stream_name - self.image_buffer = image_buffer + self.camera_index = get_config(camera_index, "VIDEO__CAMERA_INDEX", 0, int) + self.resolution = get_config(resolution, "VIDEO__RESOLUTION", 2, int) + self.color_space = get_config(color_space, "VIDEO__COLOR_SPACE", 11, int) + self.fps = get_config(fps, "VIDEO__FPS", 15, int) + self.stream_name = get_config(stream_name, "VIDEO__STREAM_NAME", "Pepper Video") + self.image_buffer = get_config(image_buffer, "VIDEO__IMAGE_BUFFER", 6, int) class AudioConfig(object): """ Audio configuration constants. - :ivar sample_rate: Audio sampling rate in Hz. + :ivar sample_rate: Audio sampling rate in Hz, defaults to 16000. :vartype sample_rate: int - :ivar chunk_size: Size of audio chunks to capture/process. + :ivar chunk_size: Size of audio chunks to capture/process, defaults to 512. :vartype chunk_size: int - :ivar channels: Number of audio channels. + :ivar channels: Number of audio channels, defaults to 1. :vartype channels: int """ - def __init__(self, sample_rate=16000, chunk_size=512, channels=1): - self.sample_rate = sample_rate - self.chunk_size = chunk_size - self.channels = channels + def __init__(self, sample_rate=None, chunk_size=None, channels=None): + self.sample_rate = get_config(sample_rate, "AUDIO__SAMPLE_RATE", 16000, int) + self.chunk_size = get_config(chunk_size, "AUDIO__CHUNK_SIZE", 512, int) + self.channels = get_config(channels, "AUDIO__CHANNELS", 1, int) class MainConfig(object): """ Main system configuration. - :ivar poll_timeout_ms: Timeout for polling events, in milliseconds. + :ivar poll_timeout_ms: Timeout for polling events, in milliseconds, defaults to 100. :vartype poll_timeout_ms: int - :ivar max_handler_time_ms: Maximum allowed handler time, in milliseconds. + :ivar max_handler_time_ms: Maximum allowed handler time, in milliseconds, defaults to 50. :vartype max_handler_time_ms: int """ - def __init__(self, poll_timeout_ms=100, max_handler_time_ms=50): - self.poll_timeout_ms = poll_timeout_ms - self.max_handler_time_ms = max_handler_time_ms + def __init__(self, poll_timeout_ms=None, max_handler_time_ms=None): + self.poll_timeout_ms = get_config(poll_timeout_ms, "MAIN__POLL_TIMEOUT_MS", 100, int) + self.max_handler_time_ms = get_config(max_handler_time_ms, "MAIN__MAX_HANDLER_TIME_MS", 50, int) class Settings(object): diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index 927efbd..ee09acb 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -22,7 +22,7 @@ class ActuationReceiver(ReceiverBase): :ivar _tts_service: The text-to-speech service object from the Qi session. :vartype _tts_service: qi.Session | None """ - def __init__(self, zmq_context, port=settings.agent_settings.actuating_receiver_port): + def __init__(self, zmq_context, port=settings.agent_settings.actuation_receiver_port): super(ActuationReceiver, self).__init__("actuation") self.create_socket(zmq_context, zmq.SUB, port) self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options diff --git a/src/robot_interface/endpoints/main_receiver.py b/src/robot_interface/endpoints/main_receiver.py index bd47198..2882970 100644 --- a/src/robot_interface/endpoints/main_receiver.py +++ b/src/robot_interface/endpoints/main_receiver.py @@ -5,6 +5,7 @@ from robot_interface.state import state from robot_interface.core.config import settings + class MainReceiver(ReceiverBase): """ The main receiver endpoint, responsible for handling ping and negotiation requests. @@ -12,10 +13,12 @@ class MainReceiver(ReceiverBase): :param zmq_context: The ZeroMQ context to use. :type zmq_context: zmq.Context - :param port: The port to use. + :param port: The port to use, defaults to value in `settings.agent_settings.main_receiver_port`. :type port: int """ - def __init__(self, zmq_context, port=settings.agent_settings.main_receiver_port): + def __init__(self, zmq_context, port=None): + if port is None: + port = settings.agent_settings.main_receiver_port super(MainReceiver, self).__init__("main") self.create_socket(zmq_context, zmq.REP, port, bind=False) diff --git a/src/robot_interface/endpoints/socket_base.py b/src/robot_interface/endpoints/socket_base.py index 9c7c20b..d2279a4 100644 --- a/src/robot_interface/endpoints/socket_base.py +++ b/src/robot_interface/endpoints/socket_base.py @@ -2,6 +2,8 @@ from abc import ABCMeta import zmq +from robot_interface.core.config import settings + class SocketBase(object): """ @@ -59,7 +61,7 @@ class SocketBase(object): if bind: self.socket.bind("tcp://*:{}".format(port)) else: - self.socket.connect("tcp://localhost:{}".format(port)) + self.socket.connect("tcp://{}:{}".format(settings.agent_settings.control_backend_host, port)) def close(self): """Close the ZeroMQ socket.""" diff --git a/src/robot_interface/utils/get_config.py b/src/robot_interface/utils/get_config.py new file mode 100644 index 0000000..ac4cef0 --- /dev/null +++ b/src/robot_interface/utils/get_config.py @@ -0,0 +1,32 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +def get_config(value, env, default, cast=None): + """ + Small utility to get a configuration value, returns `value` if it is not None, else it will try to get the + environment variable cast with `cast`. If the environment variable is not set, it will return `default`. + + :param value: The value to check. + :type value: Any + :param env: The environment variable to check. + :type env: string + :param default: The default value to return if the environment variable is not set. + :type default: Any + :param cast: A function to use to cast the environment variable. Must support string input. + :type cast: Callable[[Any], Any], optional + + :return: The value, the environment variable value, or the default. + :rtype: Any + """ + if value is not None: + return value + + env = os.environ.get(env, default) + + if cast is None: + return env + + return cast(env) diff --git a/test/integration/test_config.py b/test/integration/test_config.py new file mode 100644 index 0000000..a9709b5 --- /dev/null +++ b/test/integration/test_config.py @@ -0,0 +1,32 @@ +from mock import patch, mock + +from robot_interface.core.config import Settings +from robot_interface.endpoints.main_receiver import MainReceiver + + +def test_environment_variables(monkeypatch): + """ + When environment variables are set, creating settings should use these. + """ + monkeypatch.setenv("AGENT__CONTROL_BACKEND_HOST", "some_value_that_should_be_different") + + settings = Settings() + + assert settings.agent_settings.control_backend_host == "some_value_that_should_be_different" + + +@patch("robot_interface.endpoints.main_receiver.settings") +@patch("robot_interface.endpoints.socket_base.settings") +def test_create_endpoint_custom_host(base_settings, main_settings): + """ + When a custom host is given in the settings, check that an endpoint's socket connects to it. + """ + fake_context = mock.Mock() + fake_socket = mock.Mock() + fake_context.socket.return_value = fake_socket + base_settings.agent_settings.control_backend_host = "not_localhost" + main_settings.agent_settings.main_receiver_port = 9999 + + _ = MainReceiver(fake_context) + + fake_socket.connect.assert_called_once_with("tcp://not_localhost:9999") diff --git a/test/unit/test_get_config.py b/test/unit/test_get_config.py new file mode 100644 index 0000000..d2b00e4 --- /dev/null +++ b/test/unit/test_get_config.py @@ -0,0 +1,45 @@ +from robot_interface.utils.get_config import get_config + + +def test_get_config_prefers_explicit_value(monkeypatch): + """ + When a direct value is provided it should be returned without reading the environment. + """ + monkeypatch.setenv("GET_CONFIG_TEST", "from-env") + + result = get_config("explicit", "GET_CONFIG_TEST", "default") + + assert result == "explicit" + + +def test_get_config_returns_env_value(monkeypatch): + """ + If value is None the environment variable should be used. + """ + monkeypatch.setenv("GET_CONFIG_TEST", "from-env") + + result = get_config(None, "GET_CONFIG_TEST", "default") + + assert result == "from-env" + + +def test_get_config_casts_env_value(monkeypatch): + """ + The env value should be cast when a cast function is provided. + """ + monkeypatch.setenv("GET_CONFIG_PORT", "1234") + + result = get_config(None, "GET_CONFIG_PORT", 0, int) + + assert result == 1234 + + +def test_get_config_casts_default_when_env_missing(monkeypatch): + """ + When the env var is missing it should fall back to the default and still apply the cast. + """ + monkeypatch.delenv("GET_CONFIG_MISSING", raising=False) + + result = get_config(None, "GET_CONFIG_MISSING", "42", int) + + assert result == 42 From 9ff1d9a4d33f597847b34d2c9fd892b307215548 Mon Sep 17 00:00:00 2001 From: Twirre Date: Thu, 11 Dec 2025 10:58:56 +0000 Subject: [PATCH 61/63] Improve installation instructions --- .gitignore | 2 + README.md | 106 ++++------------------------------- docs/installation/linux.md | 75 +++++++++++++++++++++++++ docs/installation/macos.md | 106 +++++++++++++++++++++++++++++++++++ docs/installation/windows.md | 44 +++++++++++++++ 5 files changed, 237 insertions(+), 96 deletions(-) create mode 100644 docs/installation/linux.md create mode 100644 docs/installation/macos.md create mode 100644 docs/installation/windows.md diff --git a/.gitignore b/.gitignore index 8be1803..6b42733 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,5 @@ __marimo__/ # Docs docs/* !docs/conf.py +!docs/installation/ +!docs/installation/** diff --git a/README.md b/README.md index 37458df..64a4e2a 100644 --- a/README.md +++ b/README.md @@ -8,90 +8,21 @@ This is an implementation for the Pepper robot, using the Pepper SDK and Python ## Installation -### Linux (or WSL) +- [Linux](./docs/installation/linux.md) +- [macOS](./docs/installation/macos.md) +- [Windows](./docs/installation/windows.md) -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: + + +### Git Hooks + +To activate automatic linting, formatting, branch name checks and commit message checks, run (after installing requirements): ```bash -pyenv install 2.7 -pyenv shell 2.7 +pre-commit install +pre-commit install --hook-type commit-msg ``` -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 -``` - -We depend on PortAudio for the `pyaudio` package, so install it with: - -```bash -sudo apt install -y portaudio19-dev -``` - -On WSL, also install: - -```bash -sudo apt install -y libasound2-plugins -``` - -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. - ## Usage @@ -134,23 +65,6 @@ For coverage, add `--cov=robot_interface` as an argument to `pytest`. -## Git Hooks - -To activate automatic linting, formatting, branch name checks and commit message checks, run (after installing requirements): - -```bash -pre-commit install -pre-commit install --hook-type commit-msg -``` - -You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running: - -```bash -git config --local --unset core.hooksPath -``` - -Then run the pre-commit install commands again. - ## Documentation Generate documentation web pages using: diff --git a/docs/installation/linux.md b/docs/installation/linux.md new file mode 100644 index 0000000..742a87c --- /dev/null +++ b/docs/installation/linux.md @@ -0,0 +1,75 @@ +# Installation + +Of the Pepper Robot Interface on 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 +``` + +We depend on PortAudio for the `pyaudio` package, so install it with: + +```bash +sudo apt install -y portaudio19-dev +``` + +On WSL, also install: + +```bash +sudo apt install -y libasound2-plugins +``` + +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. + +See the README for how to run. diff --git a/docs/installation/macos.md b/docs/installation/macos.md new file mode 100644 index 0000000..6eac59d --- /dev/null +++ b/docs/installation/macos.md @@ -0,0 +1,106 @@ +# Installation + +Of the Pepper Robot Interface on macOS. + + + +## Python 2.7 + +Install Python 2.7.18 from the [Python website](https://www.python.org/downloads/release/python-2718/). + +Check that it worked by executing + +```shell +python2 -V +``` + +Which should return Python 2.7.18. + + + +## Virtual Environment + +Next, cd into this repository and create (and activate) a virtual environment: + +```shell +cd /path/to/project/ +python2 -m pip install virtualenv +python2 -m virtualenv .venv +source .venv/bin/activate +``` + +We depend on PortAudio for the `pyaudio` package. If on Intel, run `brew install portaudio`. If on Apple Silicon, compile manually using the steps described in [the YouTrack article](https://utrechtuniversity.youtrack.cloud/articles/N25B-A-22/Install-PyAudio-for-Python-2-on-Apple-Silicon). + +Then install the required Python packages with + +```shell +pip install -r requirements.txt +``` + + + +## NaoQi SDK + +We need to manually install the NaoQi SDK into our virtual environment. There are two options: + +1. Install a newer version (2.8) which will make running easier, but compatibility is uncertain. +2. Install the version expected by the robot (2.5). This will complicate running slightly. + +### Option 1 + +Download the SDK from [twirre.io](https://twirre.io/files/pynaoqi-python2.7-2.8.6.23-mac64-20191127_144231.tar.gz), or find one on the Aldebaran website, or an archived version on Web Archive. + +Extract it to `/path/to/project/.venv/lib/python2.7/site-packages/`. + +We need to inform our virtual environment where to find our newly installed package: + +```bash +echo "/path/to/project/.venv/lib/python2.7/site-packages/pynaoqi-python2.7-2.8.6.23-mac64-20191127_144231/lib/python2.7/site-packages/" > /path/to/project/.venv/lib/python2.7/site-packages/pynaoqi-python2.7.pth +``` + +Now continue with [verifying](#verifying). + +### Option 2 + +This method of installation requires setting the `DYLD_LIBRARY_PATH` environment variable before running. How will be explained. + +Download the SDK from [twirre.io](https://twirre.io/files/pynaoqi-2.5.7.1-mac64-deps.tar.gz). This is a modified version of the one from Aldebaran, this one including required Choregraphe dependencies. + +Extract it to `/path/to/project/.venv/lib/python2.7/site-packages/`. + +We need to inform our virtual environment where to find our newly installed package: + +```shell +echo "/path/to/project/.venv/lib/python2.7/site-packages/pynaoqi-python2.7-2.5.7.1-mac64/lib/python2.7/site-packages/" > /path/to/project/.venv/lib/python2.7/site-packages/pynaoqi-python2.7.pth +``` + +Now, anytime before running you need to set the `DYLD_LIBRARY_PATH` environment variable. + +```shell +export DYLD_LIBRARY_PATH="/path/to/project/.venv/lib/python2.7/site-packages/pynaoqi-python2.7-2.5.7.1-mac64/choregraphe_lib:${DYLD_LIBRARY_PATH}" +``` + +You may want to simplify environment activation with a script `activate.sh` like: + +```shell +#!/bin/zsh + +export DYLD_LIBRARY_PATH="/path/to/project/.venv/lib/python2.7/site-packages/pynaoqi-python2.7-2.8.6.23-mac64-20191127_144231/choregraphe_lib:${DYLD_LIBRARY_PATH}" +source .venv/bin/activate +``` + +[Verify](#verifying) if it works. + + + +## Verifying + +Verify that the NaoQI SDK installation works with + +```bash +python -c "import qi; print(qi)" +``` + +If so, you should now be able to run this project. + +See the README for how to run. diff --git a/docs/installation/windows.md b/docs/installation/windows.md new file mode 100644 index 0000000..b56453e --- /dev/null +++ b/docs/installation/windows.md @@ -0,0 +1,44 @@ +# Installation + +Of the Pepper Robot Interface on Windows. + +Install Python 2.7.18 from [the Python website](https://www.python.org/downloads/release/python-2718/), choose the x86-64 installer (at the bottom of the page). + +To see if it worked: + +```shell +py -2 -V +``` + +Which should return `Python 2.7.18`. + +Next, `cd` into this repository and create (and activate) a virtual environment: + +```bash +cd / +py -2 -m pip install virtualenv +py -2 -m virtualenv .venv +.\.venv\Scripts\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. Download the SDK from [Aldebaran](https://community-static.aldebaran.com/resources/2.5.5/sdk-python/pynaoqi-python2.7-2.5.5.5-win32-vs2013.zip), [Web Archive](https://web.archive.org/web/20240120111043/https://community-static.aldebaran.com/resources/2.5.5/sdk-python/pynaoqi-python2.7-2.5.5.5-win32-vs2013.zip) or [twirre.io](https://twirre.io/files/pynaoqi-python2.7-2.8.6.23-win64-vs2015-20191127_152649.zip). + +Extract to `.\.venv\Lib\site-packages`. + +Create a file `.venv\Lib\site-packages\pynaoqi-python2.7.pth`, put the full path of `pynaoqi-python2.7-2.8.6.23-win64-vs2015-20191127_152649\lib\python2.7\Lib\site-packages` in there. + +Test if it worked by running: + +```bash +python -c "import qi; print(qi)" +``` + +You should now be able to run this project. + +See the README for how to run. From a8fe887c48c659b878d2492e5c9212d1d19c5775 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Tue, 16 Dec 2025 08:35:26 +0000 Subject: [PATCH 62/63] feat: gestures to ri --- .../endpoints/actuation_receiver.py | 68 ++- .../endpoints/gesture_settings.py | 412 ++++++++++++++++++ test/unit/test_actuation_receiver.py | 157 ++++++- 3 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 src/robot_interface/endpoints/gesture_settings.py diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index ee09acb..fdc77d9 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -7,6 +7,7 @@ from robot_interface.endpoints.receiver_base import ReceiverBase from robot_interface.state import state from robot_interface.core.config import settings +from robot_interface.endpoints.gesture_settings import GestureTags class ActuationReceiver(ReceiverBase): @@ -21,12 +22,16 @@ class ActuationReceiver(ReceiverBase): :ivar _tts_service: The text-to-speech service object from the Qi session. :vartype _tts_service: qi.Session | None + + :ivar _animation_service: The animation/gesture service object from the Qi session. + :vartype _animation_service: qi.Session | None """ def __init__(self, zmq_context, port=settings.agent_settings.actuation_receiver_port): super(ActuationReceiver, self).__init__("actuation") self.create_socket(zmq_context, zmq.SUB, port) self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options self._tts_service = None + self._animation_service = None def _handle_speech(self, message): """ @@ -54,7 +59,54 @@ class ActuationReceiver(ReceiverBase): self._tts_service = state.qi_session.service("ALTextToSpeech") # Returns instantly. Messages received while speaking will be queued. - qi.async(self._tts_service.say, text) + getattr(qi, "async")(self._tts_service.say, text) + + def _handle_gesture(self, message, is_single): + """ + Handle a gesture actuation request. + + :param message: The gesture to do, must contain properties "endpoint" and "data". + :type message: dict + + :param is_single: Whether it's a specific single gesture or a gesture tag. + :type is_single: bool + """ + + gesture = message.get("data") + if not gesture: + logging.warn("Received gesture to do, but it lacks data.") + return + + if not isinstance(gesture, (str, unicode)): + logging.warn("Received gesture to do but it is not a string.") + return + + logging.debug("Received gesture to do: {}".format(gesture)) + + if is_single: + if gesture not in GestureTags.single_gestures: + logging.warn("Received single gesture to do, but it does not exist in settings") + return + else: + if gesture not in GestureTags.tags: + logging.warn("Received single tag to do, but it does not exist in settings") + return + + 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._animation_service: + self._animation_service = state.qi_session.service("ALAnimationPlayer") + + # Play the gesture. Pepper comes with predefined animations like "Wave", "Greet", "Clap" + # You can also create custom animations using Choregraphe and upload them to the robot. + if is_single: + logging.debug("Playing single gesture: {}".format(gesture)) + getattr(qi, "async")(self._animation_service.run, gesture) + else: + logging.debug("Playing tag gesture: {}".format(gesture)) + getattr(qi, "async")(self._animation_service.runTag, gesture) def handle_message(self, message): """ @@ -65,3 +117,17 @@ class ActuationReceiver(ReceiverBase): """ if message["endpoint"] == "actuate/speech": self._handle_speech(message) + if message["endpoint"] == "actuate/gesture/tag": + self._handle_gesture(message, False) + if message["endpoint"] == "actuate/gesture/single": + self._handle_gesture(message, True) + + def endpoint_description(self): + """ + Extend the default endpoint description with gesture tags. + Returned during negotiate/ports so the CB knows available gestures. + """ + desc = super(ActuationReceiver, self).endpoint_description() + desc["gestures"] = GestureTags.tags + desc["single_gestures"] = GestureTags.single_gestures + return desc diff --git a/src/robot_interface/endpoints/gesture_settings.py b/src/robot_interface/endpoints/gesture_settings.py new file mode 100644 index 0000000..26b7c99 --- /dev/null +++ b/src/robot_interface/endpoints/gesture_settings.py @@ -0,0 +1,412 @@ +class GestureTags: + tags = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any", + "assuage", "assuage", "attemper", "back", "bashful", "beg", "beseech", "blank", + "body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud", + "cogitate", "cool", "crazy", "disappointed", "down", "earth", "empty", "embarrassed", + "enthusiastic", "entire", "estimate", "except", "exalted", "excited", "explain", "far", + "field", "floor", "forlorn", "friendly", "front", "frustrated", "gentle", "gift", + "give", "ground", "happy", "hello", "her", "here", "hey", "hi", "him", "hopeless", + "hysterical", "I", "implore", "indicate", "joyful", "me", "meditate", "modest", + "negative", "nervous", "no", "not know", "nothing", "offer", "ok", "once upon a time", + "oppose", "or", "pacify", "pick", "placate", "please", "present", "proffer", "quiet", + "reason", "refute", "reject", "rousing", "sad", "select", "shamefaced", "show", + "show sky", "sky", "soothe", "sun", "supplicate", "tablet", "tall", "them", "there", + "think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah", + "yes", "yoo-hoo", "you", "your", "zero", "zestful"] + + single_gestures = [ + "animations/Stand/BodyTalk/Listening/Listening_1", + "animations/Stand/BodyTalk/Listening/Listening_2", + "animations/Stand/BodyTalk/Listening/Listening_3", + "animations/Stand/BodyTalk/Listening/Listening_4", + "animations/Stand/BodyTalk/Listening/Listening_5", + "animations/Stand/BodyTalk/Listening/Listening_6", + "animations/Stand/BodyTalk/Listening/Listening_7", + "animations/Stand/BodyTalk/Speaking/BodyTalk_1", + "animations/Stand/BodyTalk/Speaking/BodyTalk_10", + "animations/Stand/BodyTalk/Speaking/BodyTalk_11", + "animations/Stand/BodyTalk/Speaking/BodyTalk_12", + "animations/Stand/BodyTalk/Speaking/BodyTalk_13", + "animations/Stand/BodyTalk/Speaking/BodyTalk_14", + "animations/Stand/BodyTalk/Speaking/BodyTalk_15", + "animations/Stand/BodyTalk/Speaking/BodyTalk_16", + "animations/Stand/BodyTalk/Speaking/BodyTalk_2", + "animations/Stand/BodyTalk/Speaking/BodyTalk_3", + "animations/Stand/BodyTalk/Speaking/BodyTalk_4", + "animations/Stand/BodyTalk/Speaking/BodyTalk_5", + "animations/Stand/BodyTalk/Speaking/BodyTalk_6", + "animations/Stand/BodyTalk/Speaking/BodyTalk_7", + "animations/Stand/BodyTalk/Speaking/BodyTalk_8", + "animations/Stand/BodyTalk/Speaking/BodyTalk_9", + "animations/Stand/BodyTalk/Thinking/Remember_1", + "animations/Stand/BodyTalk/Thinking/Remember_2", + "animations/Stand/BodyTalk/Thinking/Remember_3", + "animations/Stand/BodyTalk/Thinking/ThinkingLoop_1", + "animations/Stand/BodyTalk/Thinking/ThinkingLoop_2", + "animations/Stand/Emotions/Negative/Angry_1", + "animations/Stand/Emotions/Negative/Angry_2", + "animations/Stand/Emotions/Negative/Angry_3", + "animations/Stand/Emotions/Negative/Angry_4", + "animations/Stand/Emotions/Negative/Anxious_1", + "animations/Stand/Emotions/Negative/Bored_1", + "animations/Stand/Emotions/Negative/Bored_2", + "animations/Stand/Emotions/Negative/Disappointed_1", + "animations/Stand/Emotions/Negative/Exhausted_1", + "animations/Stand/Emotions/Negative/Exhausted_2", + "animations/Stand/Emotions/Negative/Fear_1", + "animations/Stand/Emotions/Negative/Fear_2", + "animations/Stand/Emotions/Negative/Fearful_1", + "animations/Stand/Emotions/Negative/Frustrated_1", + "animations/Stand/Emotions/Negative/Humiliated_1", + "animations/Stand/Emotions/Negative/Hurt_1", + "animations/Stand/Emotions/Negative/Hurt_2", + "animations/Stand/Emotions/Negative/Late_1", + "animations/Stand/Emotions/Negative/Sad_1", + "animations/Stand/Emotions/Negative/Sad_2", + "animations/Stand/Emotions/Negative/Shocked_1", + "animations/Stand/Emotions/Negative/Sorry_1", + "animations/Stand/Emotions/Negative/Surprise_1", + "animations/Stand/Emotions/Negative/Surprise_2", + "animations/Stand/Emotions/Negative/Surprise_3", + "animations/Stand/Emotions/Neutral/Alienated_1", + "animations/Stand/Emotions/Neutral/AskForAttention_1", + "animations/Stand/Emotions/Neutral/AskForAttention_2", + "animations/Stand/Emotions/Neutral/AskForAttention_3", + "animations/Stand/Emotions/Neutral/Cautious_1", + "animations/Stand/Emotions/Neutral/Confused_1", + "animations/Stand/Emotions/Neutral/Determined_1", + "animations/Stand/Emotions/Neutral/Embarrassed_1", + "animations/Stand/Emotions/Neutral/Hesitation_1", + "animations/Stand/Emotions/Neutral/Innocent_1", + "animations/Stand/Emotions/Neutral/Lonely_1", + "animations/Stand/Emotions/Neutral/Mischievous_1", + "animations/Stand/Emotions/Neutral/Puzzled_1", + "animations/Stand/Emotions/Neutral/Sneeze", + "animations/Stand/Emotions/Neutral/Stubborn_1", + "animations/Stand/Emotions/Neutral/Suspicious_1", + "animations/Stand/Emotions/Positive/Amused_1", + "animations/Stand/Emotions/Positive/Confident_1", + "animations/Stand/Emotions/Positive/Ecstatic_1", + "animations/Stand/Emotions/Positive/Enthusiastic_1", + "animations/Stand/Emotions/Positive/Excited_1", + "animations/Stand/Emotions/Positive/Excited_2", + "animations/Stand/Emotions/Positive/Excited_3", + "animations/Stand/Emotions/Positive/Happy_1", + "animations/Stand/Emotions/Positive/Happy_2", + "animations/Stand/Emotions/Positive/Happy_3", + "animations/Stand/Emotions/Positive/Happy_4", + "animations/Stand/Emotions/Positive/Hungry_1", + "animations/Stand/Emotions/Positive/Hysterical_1", + "animations/Stand/Emotions/Positive/Interested_1", + "animations/Stand/Emotions/Positive/Interested_2", + "animations/Stand/Emotions/Positive/Laugh_1", + "animations/Stand/Emotions/Positive/Laugh_2", + "animations/Stand/Emotions/Positive/Laugh_3", + "animations/Stand/Emotions/Positive/Mocker_1", + "animations/Stand/Emotions/Positive/Optimistic_1", + "animations/Stand/Emotions/Positive/Peaceful_1", + "animations/Stand/Emotions/Positive/Proud_1", + "animations/Stand/Emotions/Positive/Proud_2", + "animations/Stand/Emotions/Positive/Proud_3", + "animations/Stand/Emotions/Positive/Relieved_1", + "animations/Stand/Emotions/Positive/Shy_1", + "animations/Stand/Emotions/Positive/Shy_2", + "animations/Stand/Emotions/Positive/Sure_1", + "animations/Stand/Emotions/Positive/Winner_1", + "animations/Stand/Emotions/Positive/Winner_2", + "animations/Stand/Gestures/Angry_1", + "animations/Stand/Gestures/Angry_2", + "animations/Stand/Gestures/Angry_3", + "animations/Stand/Gestures/BowShort_1", + "animations/Stand/Gestures/BowShort_2", + "animations/Stand/Gestures/BowShort_3", + "animations/Stand/Gestures/But_1", + "animations/Stand/Gestures/CalmDown_1", + "animations/Stand/Gestures/CalmDown_2", + "animations/Stand/Gestures/CalmDown_3", + "animations/Stand/Gestures/CalmDown_4", + "animations/Stand/Gestures/CalmDown_5", + "animations/Stand/Gestures/CalmDown_6", + "animations/Stand/Gestures/Choice_1", + "animations/Stand/Gestures/ComeOn_1", + "animations/Stand/Gestures/Confused_1", + "animations/Stand/Gestures/Confused_2", + "animations/Stand/Gestures/CountFive_1", + "animations/Stand/Gestures/CountFour_1", + "animations/Stand/Gestures/CountMore_1", + "animations/Stand/Gestures/CountOne_1", + "animations/Stand/Gestures/CountThree_1", + "animations/Stand/Gestures/CountTwo_1", + "animations/Stand/Gestures/Desperate_1", + "animations/Stand/Gestures/Desperate_2", + "animations/Stand/Gestures/Desperate_3", + "animations/Stand/Gestures/Desperate_4", + "animations/Stand/Gestures/Desperate_5", + "animations/Stand/Gestures/DontUnderstand_1", + "animations/Stand/Gestures/Enthusiastic_3", + "animations/Stand/Gestures/Enthusiastic_4", + "animations/Stand/Gestures/Enthusiastic_5", + "animations/Stand/Gestures/Everything_1", + "animations/Stand/Gestures/Everything_2", + "animations/Stand/Gestures/Everything_3", + "animations/Stand/Gestures/Everything_4", + "animations/Stand/Gestures/Everything_6", + "animations/Stand/Gestures/Excited_1", + "animations/Stand/Gestures/Explain_1", + "animations/Stand/Gestures/Explain_10", + "animations/Stand/Gestures/Explain_11", + "animations/Stand/Gestures/Explain_2", + "animations/Stand/Gestures/Explain_3", + "animations/Stand/Gestures/Explain_4", + "animations/Stand/Gestures/Explain_5", + "animations/Stand/Gestures/Explain_6", + "animations/Stand/Gestures/Explain_7", + "animations/Stand/Gestures/Explain_8", + "animations/Stand/Gestures/Far_1", + "animations/Stand/Gestures/Far_2", + "animations/Stand/Gestures/Far_3", + "animations/Stand/Gestures/Follow_1", + "animations/Stand/Gestures/Give_1", + "animations/Stand/Gestures/Give_2", + "animations/Stand/Gestures/Give_3", + "animations/Stand/Gestures/Give_4", + "animations/Stand/Gestures/Give_5", + "animations/Stand/Gestures/Give_6", + "animations/Stand/Gestures/Great_1", + "animations/Stand/Gestures/HeSays_1", + "animations/Stand/Gestures/HeSays_2", + "animations/Stand/Gestures/HeSays_3", + "animations/Stand/Gestures/Hey_1", + "animations/Stand/Gestures/Hey_10", + "animations/Stand/Gestures/Hey_2", + "animations/Stand/Gestures/Hey_3", + "animations/Stand/Gestures/Hey_4", + "animations/Stand/Gestures/Hey_6", + "animations/Stand/Gestures/Hey_7", + "animations/Stand/Gestures/Hey_8", + "animations/Stand/Gestures/Hey_9", + "animations/Stand/Gestures/Hide_1", + "animations/Stand/Gestures/Hot_1", + "animations/Stand/Gestures/Hot_2", + "animations/Stand/Gestures/IDontKnow_1", + "animations/Stand/Gestures/IDontKnow_2", + "animations/Stand/Gestures/IDontKnow_3", + "animations/Stand/Gestures/IDontKnow_4", + "animations/Stand/Gestures/IDontKnow_5", + "animations/Stand/Gestures/IDontKnow_6", + "animations/Stand/Gestures/Joy_1", + "animations/Stand/Gestures/Kisses_1", + "animations/Stand/Gestures/Look_1", + "animations/Stand/Gestures/Look_2", + "animations/Stand/Gestures/Maybe_1", + "animations/Stand/Gestures/Me_1", + "animations/Stand/Gestures/Me_2", + "animations/Stand/Gestures/Me_4", + "animations/Stand/Gestures/Me_7", + "animations/Stand/Gestures/Me_8", + "animations/Stand/Gestures/Mime_1", + "animations/Stand/Gestures/Mime_2", + "animations/Stand/Gestures/Next_1", + "animations/Stand/Gestures/No_1", + "animations/Stand/Gestures/No_2", + "animations/Stand/Gestures/No_3", + "animations/Stand/Gestures/No_4", + "animations/Stand/Gestures/No_5", + "animations/Stand/Gestures/No_6", + "animations/Stand/Gestures/No_7", + "animations/Stand/Gestures/No_8", + "animations/Stand/Gestures/No_9", + "animations/Stand/Gestures/Nothing_1", + "animations/Stand/Gestures/Nothing_2", + "animations/Stand/Gestures/OnTheEvening_1", + "animations/Stand/Gestures/OnTheEvening_2", + "animations/Stand/Gestures/OnTheEvening_3", + "animations/Stand/Gestures/OnTheEvening_4", + "animations/Stand/Gestures/OnTheEvening_5", + "animations/Stand/Gestures/Please_1", + "animations/Stand/Gestures/Please_2", + "animations/Stand/Gestures/Please_3", + "animations/Stand/Gestures/Reject_1", + "animations/Stand/Gestures/Reject_2", + "animations/Stand/Gestures/Reject_3", + "animations/Stand/Gestures/Reject_4", + "animations/Stand/Gestures/Reject_5", + "animations/Stand/Gestures/Reject_6", + "animations/Stand/Gestures/Salute_1", + "animations/Stand/Gestures/Salute_2", + "animations/Stand/Gestures/Salute_3", + "animations/Stand/Gestures/ShowFloor_1", + "animations/Stand/Gestures/ShowFloor_2", + "animations/Stand/Gestures/ShowFloor_3", + "animations/Stand/Gestures/ShowFloor_4", + "animations/Stand/Gestures/ShowFloor_5", + "animations/Stand/Gestures/ShowSky_1", + "animations/Stand/Gestures/ShowSky_10", + "animations/Stand/Gestures/ShowSky_11", + "animations/Stand/Gestures/ShowSky_12", + "animations/Stand/Gestures/ShowSky_2", + "animations/Stand/Gestures/ShowSky_3", + "animations/Stand/Gestures/ShowSky_4", + "animations/Stand/Gestures/ShowSky_5", + "animations/Stand/Gestures/ShowSky_6", + "animations/Stand/Gestures/ShowSky_7", + "animations/Stand/Gestures/ShowSky_8", + "animations/Stand/Gestures/ShowSky_9", + "animations/Stand/Gestures/ShowTablet_1", + "animations/Stand/Gestures/ShowTablet_2", + "animations/Stand/Gestures/ShowTablet_3", + "animations/Stand/Gestures/Shy_1", + "animations/Stand/Gestures/Stretch_1", + "animations/Stand/Gestures/Stretch_2", + "animations/Stand/Gestures/Surprised_1", + "animations/Stand/Gestures/TakePlace_1", + "animations/Stand/Gestures/TakePlace_2", + "animations/Stand/Gestures/Take_1", + "animations/Stand/Gestures/Thinking_1", + "animations/Stand/Gestures/Thinking_2", + "animations/Stand/Gestures/Thinking_3", + "animations/Stand/Gestures/Thinking_4", + "animations/Stand/Gestures/Thinking_5", + "animations/Stand/Gestures/Thinking_6", + "animations/Stand/Gestures/Thinking_7", + "animations/Stand/Gestures/Thinking_8", + "animations/Stand/Gestures/This_1", + "animations/Stand/Gestures/This_10", + "animations/Stand/Gestures/This_11", + "animations/Stand/Gestures/This_12", + "animations/Stand/Gestures/This_13", + "animations/Stand/Gestures/This_14", + "animations/Stand/Gestures/This_15", + "animations/Stand/Gestures/This_2", + "animations/Stand/Gestures/This_3", + "animations/Stand/Gestures/This_4", + "animations/Stand/Gestures/This_5", + "animations/Stand/Gestures/This_6", + "animations/Stand/Gestures/This_7", + "animations/Stand/Gestures/This_8", + "animations/Stand/Gestures/This_9", + "animations/Stand/Gestures/WhatSThis_1", + "animations/Stand/Gestures/WhatSThis_10", + "animations/Stand/Gestures/WhatSThis_11", + "animations/Stand/Gestures/WhatSThis_12", + "animations/Stand/Gestures/WhatSThis_13", + "animations/Stand/Gestures/WhatSThis_14", + "animations/Stand/Gestures/WhatSThis_15", + "animations/Stand/Gestures/WhatSThis_16", + "animations/Stand/Gestures/WhatSThis_2", + "animations/Stand/Gestures/WhatSThis_3", + "animations/Stand/Gestures/WhatSThis_4", + "animations/Stand/Gestures/WhatSThis_5", + "animations/Stand/Gestures/WhatSThis_6", + "animations/Stand/Gestures/WhatSThis_7", + "animations/Stand/Gestures/WhatSThis_8", + "animations/Stand/Gestures/WhatSThis_9", + "animations/Stand/Gestures/Whisper_1", + "animations/Stand/Gestures/Wings_1", + "animations/Stand/Gestures/Wings_2", + "animations/Stand/Gestures/Wings_3", + "animations/Stand/Gestures/Wings_4", + "animations/Stand/Gestures/Wings_5", + "animations/Stand/Gestures/Yes_1", + "animations/Stand/Gestures/Yes_2", + "animations/Stand/Gestures/Yes_3", + "animations/Stand/Gestures/YouKnowWhat_1", + "animations/Stand/Gestures/YouKnowWhat_2", + "animations/Stand/Gestures/YouKnowWhat_3", + "animations/Stand/Gestures/YouKnowWhat_4", + "animations/Stand/Gestures/YouKnowWhat_5", + "animations/Stand/Gestures/YouKnowWhat_6", + "animations/Stand/Gestures/You_1", + "animations/Stand/Gestures/You_2", + "animations/Stand/Gestures/You_3", + "animations/Stand/Gestures/You_4", + "animations/Stand/Gestures/You_5", + "animations/Stand/Gestures/Yum_1", + "animations/Stand/Reactions/EthernetOff_1", + "animations/Stand/Reactions/EthernetOn_1", + "animations/Stand/Reactions/Heat_1", + "animations/Stand/Reactions/Heat_2", + "animations/Stand/Reactions/LightShine_1", + "animations/Stand/Reactions/LightShine_2", + "animations/Stand/Reactions/LightShine_3", + "animations/Stand/Reactions/LightShine_4", + "animations/Stand/Reactions/SeeColor_1", + "animations/Stand/Reactions/SeeColor_2", + "animations/Stand/Reactions/SeeColor_3", + "animations/Stand/Reactions/SeeSomething_1", + "animations/Stand/Reactions/SeeSomething_3", + "animations/Stand/Reactions/SeeSomething_4", + "animations/Stand/Reactions/SeeSomething_5", + "animations/Stand/Reactions/SeeSomething_6", + "animations/Stand/Reactions/SeeSomething_7", + "animations/Stand/Reactions/SeeSomething_8", + "animations/Stand/Reactions/ShakeBody_1", + "animations/Stand/Reactions/ShakeBody_2", + "animations/Stand/Reactions/ShakeBody_3", + "animations/Stand/Reactions/TouchHead_1", + "animations/Stand/Reactions/TouchHead_2", + "animations/Stand/Reactions/TouchHead_3", + "animations/Stand/Reactions/TouchHead_4", + "animations/Stand/Waiting/AirGuitar_1", + "animations/Stand/Waiting/BackRubs_1", + "animations/Stand/Waiting/Bandmaster_1", + "animations/Stand/Waiting/Binoculars_1", + "animations/Stand/Waiting/BreathLoop_1", + "animations/Stand/Waiting/BreathLoop_2", + "animations/Stand/Waiting/BreathLoop_3", + "animations/Stand/Waiting/CallSomeone_1", + "animations/Stand/Waiting/Drink_1", + "animations/Stand/Waiting/DriveCar_1", + "animations/Stand/Waiting/Fitness_1", + "animations/Stand/Waiting/Fitness_2", + "animations/Stand/Waiting/Fitness_3", + "animations/Stand/Waiting/FunnyDancer_1", + "animations/Stand/Waiting/HappyBirthday_1", + "animations/Stand/Waiting/Helicopter_1", + "animations/Stand/Waiting/HideEyes_1", + "animations/Stand/Waiting/HideHands_1", + "animations/Stand/Waiting/Innocent_1", + "animations/Stand/Waiting/Knight_1", + "animations/Stand/Waiting/KnockEye_1", + "animations/Stand/Waiting/KungFu_1", + "animations/Stand/Waiting/LookHand_1", + "animations/Stand/Waiting/LookHand_2", + "animations/Stand/Waiting/LoveYou_1", + "animations/Stand/Waiting/Monster_1", + "animations/Stand/Waiting/MysticalPower_1", + "animations/Stand/Waiting/PlayHands_1", + "animations/Stand/Waiting/PlayHands_2", + "animations/Stand/Waiting/PlayHands_3", + "animations/Stand/Waiting/Relaxation_1", + "animations/Stand/Waiting/Relaxation_2", + "animations/Stand/Waiting/Relaxation_3", + "animations/Stand/Waiting/Relaxation_4", + "animations/Stand/Waiting/Rest_1", + "animations/Stand/Waiting/Robot_1", + "animations/Stand/Waiting/ScratchBack_1", + "animations/Stand/Waiting/ScratchBottom_1", + "animations/Stand/Waiting/ScratchEye_1", + "animations/Stand/Waiting/ScratchHand_1", + "animations/Stand/Waiting/ScratchHead_1", + "animations/Stand/Waiting/ScratchLeg_1", + "animations/Stand/Waiting/ScratchTorso_1", + "animations/Stand/Waiting/ShowMuscles_1", + "animations/Stand/Waiting/ShowMuscles_2", + "animations/Stand/Waiting/ShowMuscles_3", + "animations/Stand/Waiting/ShowMuscles_4", + "animations/Stand/Waiting/ShowMuscles_5", + "animations/Stand/Waiting/ShowSky_1", + "animations/Stand/Waiting/ShowSky_2", + "animations/Stand/Waiting/SpaceShuttle_1", + "animations/Stand/Waiting/Stretch_1", + "animations/Stand/Waiting/Stretch_2", + "animations/Stand/Waiting/TakePicture_1", + "animations/Stand/Waiting/Taxi_1", + "animations/Stand/Waiting/Think_1", + "animations/Stand/Waiting/Think_2", + "animations/Stand/Waiting/Think_3", + "animations/Stand/Waiting/Think_4", + "animations/Stand/Waiting/Waddle_1", + "animations/Stand/Waiting/Waddle_2", + "animations/Stand/Waiting/WakeUp_1", + "animations/Stand/Waiting/Zombie_1"] \ No newline at end of file diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py index 74620ab..1197196 100644 --- a/test/unit/test_actuation_receiver.py +++ b/test/unit/test_actuation_receiver.py @@ -5,6 +5,7 @@ import pytest import zmq from robot_interface.endpoints.actuation_receiver import ActuationReceiver +from robot_interface.endpoints.gesture_settings import GestureTags @pytest.fixture @@ -94,7 +95,159 @@ def test_speech(zmq_context, mocker): mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech") - mock_qi.async.assert_called_once() - call_args = mock_qi.async.call_args[0] + getattr(mock_qi, "async").assert_called_once() + call_args = getattr(mock_qi, "async").call_args[0] assert call_args[0] == mock_tts_service.say assert call_args[1] == "Some message to speak." + + +def test_gesture_no_data(zmq_context, mocker): + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/single", "data": ""}, True) + # Just ensuring no crash + + +def test_gesture_invalid_data(zmq_context, mocker): + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/single", "data": 123}, True) + # No crash expected + + +def test_gesture_single_not_found(zmq_context, mocker): + mock_tags = mocker.patch("robot_interface.endpoints.actuation_receiver.GestureTags") + mock_tags.single_gestures = ["wave", "bow"] # allowed single gestures + + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/single", "data": "unknown_gesture"}, True) + # No crash expected + + +def test_gesture_tag_not_found(zmq_context, mocker): + mock_tags = mocker.patch("robot_interface.endpoints.actuation_receiver.GestureTags") + mock_tags.tags = ["happy", "sad"] + + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/tag", "data": "not_a_tag"}, False) + # No crash expected + + +def test_gesture_no_qi_session(zmq_context, mocker): + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + mock_state.qi_session = None + + mock_tags = mocker.patch("robot_interface.endpoints.actuation_receiver.GestureTags") + mock_tags.single_gestures = ["hello"] + + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/single", "data": "hello"}, True) + # No crash, path returns early + + +def test_gesture_single_success(zmq_context, mocker): + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + # Setup gesture settings + mock_tags = mocker.patch("robot_interface.endpoints.actuation_receiver.GestureTags") + mock_tags.single_gestures = ["wave"] + + mock_animation_service = mock.Mock() + mock_state.qi_session = mock.Mock() + mock_state.qi_session.service.return_value = mock_animation_service + + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/single", "data": "wave"}, True) + + mock_state.qi_session.service.assert_called_once_with("ALAnimationPlayer") + getattr(mock_qi, "async").assert_called_once() + assert getattr(mock_qi, "async").call_args[0][0] == mock_animation_service.run + assert getattr(mock_qi, "async").call_args[0][1] == "wave" + + +def test_gesture_tag_success(zmq_context, mocker): + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + mock_tags = mocker.patch("robot_interface.endpoints.actuation_receiver.GestureTags") + mock_tags.tags = ["greeting"] + + mock_animation_service = mock.Mock() + mock_state.qi_session = mock.Mock() + mock_state.qi_session.service.return_value = mock_animation_service + + receiver = ActuationReceiver(zmq_context) + receiver._handle_gesture({"endpoint": "actuate/gesture/tag", "data": "greeting"}, False) + + mock_state.qi_session.service.assert_called_once_with("ALAnimationPlayer") + getattr(mock_qi, "async").assert_called_once() + assert getattr(mock_qi, "async").call_args[0][0] == mock_animation_service.runTag + assert getattr(mock_qi, "async").call_args[0][1] == "greeting" + + +def test_handle_message_all_routes(zmq_context, mocker): + """ + Ensures all handle_message endpoint branches route correctly. + """ + receiver = ActuationReceiver(zmq_context) + + mock_speech = mocker.patch.object(receiver, "_handle_speech") + mock_gesture = mocker.patch.object(receiver, "_handle_gesture") + + receiver.handle_message({"endpoint": "actuate/speech", "data": "hi"}) + receiver.handle_message({"endpoint": "actuate/gesture/tag", "data": "greeting"}) + receiver.handle_message({"endpoint": "actuate/gesture/single", "data": "wave"}) + + mock_speech.assert_called_once() + assert mock_gesture.call_count == 2 + + +def test_endpoint_description(zmq_context, mocker): + mock_tags = mocker.patch("robot_interface.endpoints.actuation_receiver.GestureTags") + mock_tags.tags = ["happy"] + mock_tags.single_gestures = ["wave"] + + receiver = ActuationReceiver(zmq_context) + desc = receiver.endpoint_description() + + assert "gestures" in desc + assert desc["gestures"] == ["happy"] + + assert "single_gestures" in desc + assert desc["single_gestures"] == ["wave"] + + +def test_gesture_single_real_gesturetags(zmq_context, mocker): + """ + Uses the real GestureTags (no mocking) to ensure the receiver + references GestureTags.single_gestures correctly. + """ + # Ensure qi session exists so we pass the early return + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + mock_state.qi_session = mock.Mock() + + # Mock qi.async to avoid real async calls + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + # Mock animation service + mock_animation_service = mock.Mock() + mock_state.qi_session.service.return_value = mock_animation_service + + receiver = ActuationReceiver(zmq_context) + + # Pick a real gesture from GestureTags.single_gestures + assert len(GestureTags.single_gestures) > 0, "GestureTags.single_gestures must not be empty" + gesture = GestureTags.single_gestures[0] + + receiver._handle_gesture( + {"endpoint": "actuate/gesture/single", "data": gesture}, + is_single=True, + ) + + mock_state.qi_session.service.assert_called_once_with("ALAnimationPlayer") + getattr(mock_qi, "async").assert_called_once() + assert getattr(mock_qi, "async").call_args[0][0] == mock_animation_service.run + assert getattr(mock_qi, "async").call_args[0][1] == gesture + From e51cf8fe65478a09c6f4688735587deff743f22d Mon Sep 17 00:00:00 2001 From: "Luijkx,S.O.H. (Storm)" Date: Wed, 14 Jan 2026 14:26:38 +0000 Subject: [PATCH 63/63] feat: implemented forced speech and speech queue --- .../endpoints/actuation_receiver.py | 41 ++- src/robot_interface/endpoints/audio_sender.py | 1 + src/robot_interface/endpoints/video_sender.py | 1 + src/robot_interface/state.py | 1 + test/conftest.py | 10 + test/unit/test_actuation_receiver.py | 291 ++++++++++++++++-- test/unit/test_audio_sender.py | 33 +- 7 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 test/conftest.py diff --git a/src/robot_interface/endpoints/actuation_receiver.py b/src/robot_interface/endpoints/actuation_receiver.py index fdc77d9..dd40214 100644 --- a/src/robot_interface/endpoints/actuation_receiver.py +++ b/src/robot_interface/endpoints/actuation_receiver.py @@ -1,11 +1,12 @@ from __future__ import unicode_literals # So that we can log texts with Unicode characters import logging +from threading import Thread +import Queue import zmq from robot_interface.endpoints.receiver_base import ReceiverBase from robot_interface.state import state - from robot_interface.core.config import settings from robot_interface.endpoints.gesture_settings import GestureTags @@ -32,6 +33,9 @@ class ActuationReceiver(ReceiverBase): self.socket.setsockopt_string(zmq.SUBSCRIBE, u"") # Causes block if given in options self._tts_service = None self._animation_service = None + self._message_queue = Queue.Queue() + self.message_thread = Thread(target=self._handle_messages) + self.message_thread.start() def _handle_speech(self, message): """ @@ -58,8 +62,26 @@ class ActuationReceiver(ReceiverBase): if not self._tts_service: self._tts_service = state.qi_session.service("ALTextToSpeech") - # Returns instantly. Messages received while speaking will be queued. - getattr(qi, "async")(self._tts_service.say, text) + if message.get("is_priority"): + # Bypass queue and speak immediately + self.clear_queue() + self._message_queue.put(text) + logging.debug("Force speaking immediately: {}".format(text)) + else: + self._message_queue.put(text) + + def clear_queue(self): + """ + Safely drains all pending messages from the queue. + """ + logging.info("Message queue size: {}".format(self._message_queue.qsize())) + try: + while True: + # Remove items one by one without waiting + self._message_queue.get_nowait() + except Queue.Empty: + pass + logging.info("Message queue cleared.") def _handle_gesture(self, message, is_single): """ @@ -122,6 +144,19 @@ class ActuationReceiver(ReceiverBase): if message["endpoint"] == "actuate/gesture/single": self._handle_gesture(message, True) + def _handle_messages(self): + while not state.exit_event.is_set(): + try: + text = self._message_queue.get(timeout=0.1) + state.is_speaking = True + self._tts_service.say(text) + except Queue.Empty: + state.is_speaking = False + except RuntimeError: + logging.error("Lost connection to Pepper. Please check if you're connected to the " + "local WiFi and restart this application.") + state.exit_event.set() + def endpoint_description(self): """ Extend the default endpoint description with gesture tags. diff --git a/src/robot_interface/endpoints/audio_sender.py b/src/robot_interface/endpoints/audio_sender.py index 448d6f3..dc37967 100644 --- a/src/robot_interface/endpoints/audio_sender.py +++ b/src/robot_interface/endpoints/audio_sender.py @@ -89,6 +89,7 @@ class AudioSender(SocketBase): try: while not state.exit_event.is_set(): data = stream.read(chunk) + if (state.is_speaking): continue # Do not send audio while the robot is speaking self.socket.send(data) except IOError as e: logger.error("Stopped listening: failed to get audio from microphone.", exc_info=e) diff --git a/src/robot_interface/endpoints/video_sender.py b/src/robot_interface/endpoints/video_sender.py index 9fa1132..d822352 100644 --- a/src/robot_interface/endpoints/video_sender.py +++ b/src/robot_interface/endpoints/video_sender.py @@ -6,6 +6,7 @@ from robot_interface.endpoints.socket_base import SocketBase from robot_interface.state import state from robot_interface.core.config import settings + class VideoSender(SocketBase): """ Video sender endpoint, responsible for sending video frames. diff --git a/src/robot_interface/state.py b/src/robot_interface/state.py index b625867..8d69cca 100644 --- a/src/robot_interface/state.py +++ b/src/robot_interface/state.py @@ -30,6 +30,7 @@ class State(object): self.exit_event = None self.sockets = [] self.qi_session = None + self.is_speaking = False def initialize(self): """ diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..d387f28 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,10 @@ +from mock import patch, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def mock_zmq_context(): + with patch("zmq.Context") as mock: + mock.instance.return_value = MagicMock() + yield mock \ No newline at end of file diff --git a/test/unit/test_actuation_receiver.py b/test/unit/test_actuation_receiver.py index 1197196..e6bacd2 100644 --- a/test/unit/test_actuation_receiver.py +++ b/test/unit/test_actuation_receiver.py @@ -20,46 +20,109 @@ def zmq_context(): yield context -def test_handle_unimplemented_endpoint(zmq_context): +def test_force_speech_clears_queue(mocker): """ - Tests that the ``ActuationReceiver.handle_message`` method can - handle an unknown or unimplemented endpoint without raising an error. + Tests that a force speech message clears the existing queue + and places the high-priority message at the front. """ - receiver = ActuationReceiver(zmq_context) - # Should not error + mocker.patch("threading.Thread") + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + mock_tts_service = mock.Mock() + mock_state.qi_session.service.return_value = mock_tts_service + + # Use Mock Context + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + + receiver._message_queue.put("old_message_1") + receiver._message_queue.put("old_message_2") + + assert receiver._message_queue.qsize() == 2 + + force_msg = { + "endpoint": "actuate/speech", + "data": "Emergency Notification", + "is_priority": True, + } + receiver.handle_message(force_msg) + + assert receiver._message_queue.qsize() == 1 + queued_item = receiver._message_queue.get() + assert queued_item == "Emergency Notification" + + +def test_handle_unimplemented_endpoint(mocker): + """ + Tests handling of unknown endpoints. + """ + mocker.patch("threading.Thread") + + # Use Mock Context + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + receiver.handle_message({ "endpoint": "some_endpoint_that_definitely_does_not_exist", "data": None, }) -def test_speech_message_no_data(zmq_context, mocker): +def test_speech_message_no_data(mocker): """ - Tests that the message handler logs a warning when a speech actuation - request (`actuate/speech`) is received but contains empty string data. + Tests that if the message data is empty, the receiver returns immediately + WITHOUT attempting to access the global robot state or session. """ - mock_warn = mocker.patch("logging.warn") + # 1. Prevent background threads from running + mocker.patch("threading.Thread") + + # 2. Mock the global state object + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") - receiver = ActuationReceiver(zmq_context) + # 3. Create a PropertyMock to track whenever 'qi_session' is accessed + # We attach it to the class type of the mock so it acts like a real property + mock_session_prop = mock.PropertyMock(return_value=None) + type(mock_state).qi_session = mock_session_prop + + # 4. Initialize Receiver (Mocking the context to avoid ZMQ errors) + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + + # 5. Send empty data receiver.handle_message({"endpoint": "actuate/speech", "data": ""}) - mock_warn.assert_called_with(mock.ANY) + # 6. Assertion: + # Because the code does `if not text: return` BEFORE `if not state.qi_session`, + # the state property should NEVER be read. + mock_session_prop.assert_not_called() -def test_speech_message_invalid_data(zmq_context, mocker): +def test_speech_message_invalid_data(mocker): """ - Tests that the message handler logs a warning when a speech actuation - request (`actuate/speech`) is received with data that is not a string (e.g., a boolean). + Tests that if the message data is not a string, the function returns. + :param mocker: Description """ - mock_warn = mocker.patch("logging.warn") + mocker.patch("threading.Thread") - receiver = ActuationReceiver(zmq_context) + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + mock_session_prop = mock.PropertyMock(return_value=None) + type(mock_state).qi_session = mock_session_prop + + # Use Mock Context + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + receiver.handle_message({"endpoint": "actuate/speech", "data": True}) - mock_warn.assert_called_with(mock.ANY) + # Because the code does `if not text: return` BEFORE `if not state.qi_session`, + # the state property should NEVER be read. + mock_session_prop.assert_not_called() - -def test_speech_no_qi(zmq_context, mocker): +def test_speech_no_qi(mocker): """ Tests the actuation receiver's behavior when processing a speech request but the global state does not have an active QI session. @@ -69,16 +132,21 @@ def test_speech_no_qi(zmq_context, mocker): mock_qi_session = mock.PropertyMock(return_value=None) type(mock_state).qi_session = mock_qi_session - receiver = ActuationReceiver(zmq_context) + mock_tts_service = mock.Mock() + + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + receiver._tts_service = mock_tts_service + receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) - mock_qi_session.assert_called() + receiver._tts_service.assert_not_called() -def test_speech(zmq_context, mocker): +def test_speech(mocker): """ Tests the core speech actuation functionality by mocking the QI TextToSpeech - service and verifying that it is called correctly. + service and verifying that the received message is put into the queue. """ mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") @@ -89,17 +157,182 @@ def test_speech(zmq_context, mocker): mock_state.qi_session = mock.Mock() mock_state.qi_session.service.return_value = mock_tts_service - receiver = ActuationReceiver(zmq_context) + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + receiver._tts_service = None receiver._handle_speech({"endpoint": "actuate/speech", "data": "Some message to speak."}) - mock_state.qi_session.service.assert_called_once_with("ALTextToSpeech") + assert receiver._message_queue.qsize() == 1 - getattr(mock_qi, "async").assert_called_once() - call_args = getattr(mock_qi, "async").call_args[0] - assert call_args[0] == mock_tts_service.say - assert call_args[1] == "Some message to speak." + queued_item = receiver._message_queue.get() + assert queued_item == "Some message to speak." +def test_speech_priority(mocker): + """ + Tests that a priority speech message is handled correctly by clearing the queue + and placing the priority message at the front. + """ + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + mock_qi = mock.Mock() + sys.modules["qi"] = mock_qi + + mock_tts_service = mock.Mock() + mock_state.qi_session = mock.Mock() + mock_state.qi_session.service.return_value = mock_tts_service + + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + + receiver._message_queue.put("old_message_1") + receiver._message_queue.put("old_message_2") + + assert receiver._message_queue.qsize() == 2 + + priority_msg = { + "endpoint": "actuate/speech", + "data": "Urgent Message", + "is_priority": True, + } + receiver._handle_speech(priority_msg) + + assert receiver._message_queue.qsize() == 1 + queued_item = receiver._message_queue.get() + assert queued_item == "Urgent Message" + +def test_handle_messages_loop(mocker): + """ + Tests the background consumer loop (_handle_messages) processing an item. + Runs SYNCHRONOUSLY to ensure coverage tools pick up the lines. + """ + # Patch Thread so the real background thread NEVER starts automatically + mocker.patch("threading.Thread") + + # Mock state + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + # Setup initial speaking state to False (covers "Started speaking" print) + mock_state.is_speaking = False + + # Mock the TextToSpeech service + mock_tts_service = mock.Mock() + mock_state.qi_session.service.return_value = mock_tts_service + + # Initialize receiver (Thread is patched, so no thread starts) + # Use Mock Context to avoid ZMQ errors + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + + # Manually inject service (since lazy loading might handle it, but this is safer) + receiver._tts_service = mock_tts_service + + # This ensures the while loop iterates exactly once + mock_state.exit_event.is_set.side_effect = [False, True] + + # Put an item in the queue + receiver._message_queue.put("Hello World") + + # RUN MANUALLY in the main thread + # This executes the code: while -> try -> get -> if print -> speaking=True -> say + receiver._handle_messages() + + # Assertions + assert receiver._message_queue.empty() + mock_tts_service.say.assert_called_with("Hello World") + assert mock_state.is_speaking is True + + +def test_handle_messages_queue_empty(mocker): + """ + Tests the Queue.Empty exception handler in the consumer loop. + This covers the logic that resets 'state.is_speaking' to False. + """ + # Prevent the real background thread from starting + mocker.patch("threading.Thread") + + # Mock the state object + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + # Setup 'is_speaking' property mock + # We set return_value=True so the code enters the 'if state.is_speaking:' block. + # We use PropertyMock to track when this attribute is set. + type(mock_state).is_speaking = True + + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + + # This ensures the while loop body runs exactly once for our test + mock_state.exit_event.is_set.side_effect = [False, True] + + # Force get() to raise Queue.Empty immediately (simulate timeout) + # We patch the 'get' method on the specific queue instance of our receiver + #mocker.patch.object(receiver._message_queue, 'get', side_effect=Queue.Empty) + + # Run the loop logic manually (synchronously) + receiver._handle_messages() + + # Final Assertion: Verify is_speaking was set to False + # The code execution order is: read (returns True) -> print -> set (to False) + # assert_called_with checks the arguments of the LAST call, which is the setter. + assert mock_state.is_speaking is False + + +def test_handle_messages_runtime_error(mocker): + """ + Tests the RuntimeError exception handler (e.g. lost WiFi connection). + Uses a Mock ZMQ context to avoid 'Address already in use' errors. + """ + # Patch Thread so we don't accidentally spawn real threads + mocker.patch("threading.Thread") + + # Mock the state and logging + mock_state = mocker.patch("robot_interface.endpoints.actuation_receiver.state") + + # Use a MOCK ZMQ context. + # This prevents the receiver from trying to bind to a real TCP port. + mock_zmq_ctx = mock.Mock() + + # Initialize receiver with the mock context + receiver = ActuationReceiver(mock_zmq_ctx) + + mock_state.exit_event.is_set.side_effect = [False, True] + + receiver._message_queue.put("Test Message") + + # Setup: ...BUT the service raises RuntimeError when asked to speak + mock_tts = mock.Mock() + mock_tts.say.side_effect = RuntimeError("Connection lost") + receiver._tts_service = mock_tts + + # Run the loop logic manually + receiver._handle_messages() + + # Assertions + assert mock_state.exit_event.is_set.called + +def test_clear_queue(mocker): + """ + Tests that the clear_queue method properly drains all items from the message queue. + """ + mocker.patch("threading.Thread") + + # Use Mock Context + mock_zmq_ctx = mock.Mock() + receiver = ActuationReceiver(mock_zmq_ctx) + + # Populate the queue with multiple items + receiver._message_queue.put("msg1") + receiver._message_queue.put("msg2") + receiver._message_queue.put("msg3") + + assert receiver._message_queue.qsize() == 3 + + # Clear the queue + receiver.clear_queue() + + # Assert the queue is empty + assert receiver._message_queue.qsize() == 0 def test_gesture_no_data(zmq_context, mocker): receiver = ActuationReceiver(zmq_context) diff --git a/test/unit/test_audio_sender.py b/test/unit/test_audio_sender.py index 4e337c2..c6ca87f 100644 --- a/test/unit/test_audio_sender.py +++ b/test/unit/test_audio_sender.py @@ -77,7 +77,8 @@ def test_sending_audio(mocker): mock_zmq_context = mock.Mock() send_socket = mock.Mock() - + + mock_state.is_speaking = False # If there's something wrong with the microphone, it will raise an IOError when `read`ing. stream = mock.Mock() stream.read = _fake_read @@ -93,6 +94,36 @@ def test_sending_audio(mocker): send_socket.assert_called() +def test_no_sending_if_speaking(mocker): + """ + Tests the successful sending of audio data over a ZeroMQ socket. + """ + mock_choose_mic = mocker.patch("robot_interface.endpoints.audio_sender.choose_mic") + mock_choose_mic.return_value = {"name": u"Some mic", "index": 0L} + + mock_state = mocker.patch("robot_interface.endpoints.audio_sender.state") + mock_state.exit_event.is_set.side_effect = [False, True] + + mock_zmq_context = mock.Mock() + send_socket = mock.Mock() + + mock_state.is_speaking = True + + # If there's something wrong with the microphone, it will raise an IOError when `read`ing. + stream = mock.Mock() + stream.read = _fake_read + + sender = AudioSender(mock_zmq_context) + sender.socket.send = send_socket + sender.audio.open = mock.Mock() + sender.audio.open.return_value = stream + + sender.start() + sender.wait_until_done() + + send_socket.assert_not_called() + + def _fake_read_error(num_frames): """ Helper function to simulate an I/O error during microphone stream reading.