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