GoWelcome/gowelcome/robot/interface.py

124 lines
4.3 KiB
Python

"""Abstract robot + audio contracts.
The state machine and main loop talk *only* to ``RobotInterface``; concrete
implementations are :class:`gowelcome.robot.go2_robot.Go2Robot` (real hardware
via the official Unitree SDK) and :class:`gowelcome.robot.mock_robot.MockRobot`
(webcam / video file, for off-robot development).
Velocity convention (matches Unitree ``SportClient.Move``):
vx forward (+) / backward (-) metres/second, body frame
vy left (+) / right (-) metres/second, body frame
vyaw turn-left/CCW (+) / right/CW (-) radians/second
Gesture names accepted by :meth:`RobotInterface.gesture` (mapped to the SDK on
real hardware): ``hello``, ``heart``, ``stretch``, ``dance1``, ``dance2``,
``scrape``, ``content``, ``sit``, ``rise_sit``.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
import numpy as np
# Canonical gesture vocabulary the state machine may request. Backends map
# these to whatever the underlying hardware supports (unknown -> no-op + warn).
GESTURES = (
"hello",
"heart",
"stretch",
"dance1",
"dance2",
"scrape",
"content",
"sit",
"rise_sit",
# dog-play actions (supported by the WebRTC backend; no-op where unsupported)
"wiggle",
"wallow",
"pounce",
)
class AudioBackend(ABC):
"""Plays the greeting clip. Pluggable because the Go2 has no first-party
SDK audio path (see ``gowelcome/robot/audio.py`` for the gory details)."""
@abstractmethod
def play(self, wav_path: str, blocking: bool = False) -> bool:
"""Play ``wav_path``. Returns ``True`` if playback was dispatched.
``blocking=True`` waits for the clip to finish; ``False`` returns
immediately (fire-and-forget). Implementations must never raise on a
missing file or backend error -- log and return ``False`` instead.
"""
def close(self) -> None: # optional cleanup hook
"""Release any audio resources. Default: no-op."""
class RobotInterface(ABC):
"""Everything the behaviour layer needs from a robot."""
# --- perception input -------------------------------------------------
@abstractmethod
def get_frame(self) -> "Optional[np.ndarray]":
"""Return the latest BGR camera frame, or ``None`` if unavailable."""
@abstractmethod
def frame_size(self) -> "tuple[int, int]":
"""Current ``(width, height)``; ``(0, 0)`` until the first frame."""
# --- locomotion -------------------------------------------------------
@abstractmethod
def drive(self, vx: float, vy: float, vyaw: float) -> None:
"""Command a body-frame velocity (one tick). Call repeatedly to keep
moving. On real hardware this goes through the firmware LiDAR
obstacle-avoidance layer when avoidance is enabled."""
@abstractmethod
def stop(self) -> None:
"""Command zero velocity (stay standing)."""
@abstractmethod
def set_avoidance(self, on: bool) -> None:
"""Enable/disable the on-robot LiDAR hard-stop avoidance layer."""
# --- posture / expression --------------------------------------------
@abstractmethod
def balance_stand(self) -> None:
"""Enter balanced standing (ready-to-move)."""
@abstractmethod
def stand_up(self) -> None:
"""Stiff stand up."""
@abstractmethod
def damp(self) -> None:
"""Emergency soft-stop: limp motors. Safe failure posture."""
@abstractmethod
def gesture(self, name: str) -> None:
"""Perform an expressive gesture (see ``GESTURES``)."""
# --- greeting payload -------------------------------------------------
@abstractmethod
def play_greeting(self) -> None:
"""Play the configured greeting audio clip (non-blocking)."""
# --- lifecycle --------------------------------------------------------
@abstractmethod
def shutdown(self) -> None:
"""Stop motion, release avoidance API, close camera/audio. Idempotent
and safe to call from a signal handler / ``finally`` block."""
# context-manager sugar so callers can ``with build_robot(...) as bot:``
def __enter__(self) -> "RobotInterface":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.shutdown()