2026-09-03 00:10:18 +04:00

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 {}