chore: add documentation RI

Code functionality left unchanged, only added docs where missing

close: N25B-298
This commit is contained in:
Pim Hutting
2025-11-21 16:35:40 +01:00
parent 1e3531ac6e
commit 051f904576
18 changed files with 629 additions and 59 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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"):

View File

@@ -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

View File

@@ -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`

View File

@@ -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.")

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

View File

@@ -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)