243 lines
7.3 KiB
Python
243 lines
7.3 KiB
Python
"""Robot abstraction layer.
|
|
|
|
This is the ONLY contract the rest of the application knows about. Nothing above
|
|
this layer (services, API, frontend) may import a vendor SDK, an HTTP client or a
|
|
ROS package. Adding a new robot means adding one file that implements
|
|
`RobotAdapter` and registering it in `factory.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# enums
|
|
# --------------------------------------------------------------------------- #
|
|
class RobotState(str, Enum):
|
|
DISCONNECTED = "disconnected"
|
|
CONNECTING = "connecting"
|
|
CONNECTED = "connected"
|
|
ERROR = "error"
|
|
|
|
|
|
class SpeechStage(str, Enum):
|
|
"""Lifecycle of a single utterance, mirrored 1:1 in the UI."""
|
|
|
|
QUEUED = "queued"
|
|
SENDING = "sending" # request leaving the PC
|
|
PROCESSING = "processing" # robot accepted it, synthesising
|
|
SPEAKING = "speaking" # audio is coming out of the speaker
|
|
COMPLETED = "completed"
|
|
CANCELLED = "cancelled"
|
|
FAILED = "failed"
|
|
|
|
|
|
TERMINAL_STAGES = {SpeechStage.COMPLETED, SpeechStage.CANCELLED, SpeechStage.FAILED}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# errors
|
|
# --------------------------------------------------------------------------- #
|
|
class RobotError(Exception):
|
|
"""Base class for every robot-layer failure.
|
|
|
|
`user_message` is safe to show in the browser; `str(exc)` may contain detail
|
|
useful in the server log but noisy for an operator during a live demo.
|
|
"""
|
|
|
|
code = "robot_error"
|
|
user_message = "The robot reported an error."
|
|
|
|
def __init__(self, message: str = "", user_message: Optional[str] = None) -> None:
|
|
super().__init__(message or self.user_message)
|
|
if user_message:
|
|
self.user_message = user_message
|
|
|
|
|
|
class RobotNotConfigured(RobotError):
|
|
code = "not_configured"
|
|
user_message = "Robot is not configured. Set ROBOT_IP in the .env file."
|
|
|
|
|
|
class RobotUnreachable(RobotError):
|
|
code = "unreachable"
|
|
user_message = "Robot is offline. Check the robot IP address and network connection."
|
|
|
|
|
|
class RobotTimeout(RobotError):
|
|
code = "timeout"
|
|
user_message = "The robot did not respond in time."
|
|
|
|
|
|
class RobotBusy(RobotError):
|
|
code = "busy"
|
|
user_message = "The robot is already speaking."
|
|
|
|
|
|
class SpeechFailed(RobotError):
|
|
code = "speech_failed"
|
|
user_message = "Speech request failed."
|
|
|
|
|
|
class TransportNotAvailable(RobotError):
|
|
code = "transport_unavailable"
|
|
user_message = "The selected robot transport is not available on this PC."
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# value objects
|
|
# --------------------------------------------------------------------------- #
|
|
@dataclass(frozen=True)
|
|
class RobotCapabilities:
|
|
"""What this adapter can actually do, so the UI never offers a dead control."""
|
|
|
|
native_tts: bool = True
|
|
stop: bool = True
|
|
progress_events: bool = True
|
|
reports_completion: bool = True
|
|
voice_selection: bool = False
|
|
volume_control: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RobotInfo:
|
|
name: str
|
|
model: str
|
|
mode: str
|
|
transport: str
|
|
address: str
|
|
capabilities: RobotCapabilities
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"model": self.model,
|
|
"mode": self.mode,
|
|
"transport": self.transport,
|
|
"address": self.address,
|
|
"capabilities": {
|
|
"nativeTts": self.capabilities.native_tts,
|
|
"stop": self.capabilities.stop,
|
|
"progressEvents": self.capabilities.progress_events,
|
|
"reportsCompletion": self.capabilities.reports_completion,
|
|
"voiceSelection": self.capabilities.voice_selection,
|
|
"volumeControl": self.capabilities.volume_control,
|
|
},
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SpeechRequest:
|
|
text: str
|
|
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
voice: Optional[str] = None
|
|
language: Optional[str] = None
|
|
created_at: float = field(default_factory=time.time)
|
|
|
|
@property
|
|
def preview(self) -> str:
|
|
text = " ".join(self.text.split())
|
|
return text if len(text) <= 80 else text[:77] + "..."
|
|
|
|
|
|
@dataclass
|
|
class SpeechProgress:
|
|
"""One lifecycle update for an utterance, pushed straight to the browser."""
|
|
|
|
request_id: str
|
|
stage: SpeechStage
|
|
message: str = ""
|
|
detail: Optional[str] = None
|
|
error_code: Optional[str] = None
|
|
elapsed_ms: Optional[int] = None
|
|
at: float = field(default_factory=time.time)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"requestId": self.request_id,
|
|
"stage": self.stage.value,
|
|
"message": self.message,
|
|
"detail": self.detail,
|
|
"errorCode": self.error_code,
|
|
"elapsedMs": self.elapsed_ms,
|
|
"at": self.at,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SpeechResult:
|
|
request_id: str
|
|
success: bool
|
|
stage: SpeechStage
|
|
# Time from "user pressed Speak" to the robot acknowledging the request. This
|
|
# is the number that matters for a live demo - it is the perceived delay.
|
|
ack_latency_ms: Optional[int] = None
|
|
total_ms: Optional[int] = None
|
|
error_code: Optional[str] = None
|
|
error: Optional[str] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"requestId": self.request_id,
|
|
"success": self.success,
|
|
"stage": self.stage.value,
|
|
"ackLatencyMs": self.ack_latency_ms,
|
|
"totalMs": self.total_ms,
|
|
"errorCode": self.error_code,
|
|
"error": self.error,
|
|
}
|
|
|
|
|
|
ProgressCallback = Callable[[SpeechProgress], Awaitable[None]]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# adapter contract
|
|
# --------------------------------------------------------------------------- #
|
|
class RobotAdapter(abc.ABC):
|
|
"""Every robot backend implements exactly this."""
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def info(self) -> RobotInfo:
|
|
"""Static description of the robot / connection."""
|
|
|
|
@abc.abstractmethod
|
|
async def connect(self) -> None:
|
|
"""Establish (or verify) the connection. Raises RobotError on failure."""
|
|
|
|
@abc.abstractmethod
|
|
async def disconnect(self) -> None:
|
|
"""Release sockets/clients. Must never raise."""
|
|
|
|
@abc.abstractmethod
|
|
async def health_check(self) -> bool:
|
|
"""Cheap liveness probe used by the connection manager."""
|
|
|
|
@abc.abstractmethod
|
|
async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> SpeechResult:
|
|
"""Send text to the robot and report progress until the utterance ends."""
|
|
|
|
@abc.abstractmethod
|
|
async def stop_speaking(self) -> bool:
|
|
"""Interrupt the current utterance. Returns True if a stop was issued."""
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def is_connected(self) -> bool:
|
|
"""Last known connection state - must not perform I/O."""
|
|
|
|
@property
|
|
def is_speaking(self) -> bool:
|
|
return False
|
|
|
|
async def describe(self) -> Dict[str, Any]:
|
|
"""Optional richer diagnostics for the /api/robot/diagnostics endpoint."""
|
|
return {}
|