"""AGIBOT A3 adapter. ============================================================================ THIS FILE IS THE INTEGRATION POINT. ============================================================================ Everything else in this project is finished and testable today. When the robot's speech interface is confirmed, the change is limited to: 1. .env - transport, address, endpoint/topic/command and payload 2. (only if the wire format is unusual) a new file under robot/transports/ No endpoint, port, ROS topic or SDK symbol is invented in this code. The adapter deliberately holds *no* opinion about the A3's protocol; it holds the parts that are true regardless of protocol: - one persistent connection, reused across utterances (latency) - ack-latency measurement, so "how fast is it really" is answerable - the SENDING -> PROCESSING -> SPEAKING -> COMPLETED lifecycle the UI renders - completion by robot event when the robot reports it, by estimate when it does not - interruption, timeouts and error mapping that never wedge the web app See docs/AGIBOT_A3_INTEGRATION.md for the discovery procedure to run against the real unit, and for the exact questions to put to AgiBot support. """ from __future__ import annotations import asyncio import logging import time from typing import Any, Dict, Optional from ..config.settings import Settings from ..core.text import estimate_speech_seconds from .base import ( ProgressCallback, RobotAdapter, RobotCapabilities, RobotError, RobotInfo, RobotNotConfigured, RobotUnreachable, SpeechProgress, SpeechRequest, SpeechResult, SpeechStage, ) from .transports import create_transport from .transports.base import SpeechTransport logger = logging.getLogger(__name__) _TICK = 0.05 class AgibotA3(RobotAdapter): """Real-robot adapter, delegating the wire protocol to a configured transport.""" def __init__(self, settings: Settings) -> None: self._settings = settings self._robot = settings.robot self._a3 = settings.a3 self._transport: Optional[SpeechTransport] = None self._connected = False self._speaking = False self._cancel = asyncio.Event() self._last_error: Optional[str] = None # -- identity ------------------------------------------------------------ # @property def info(self) -> RobotInfo: transport = self._transport return RobotInfo( name=self._robot.name, model=self._robot.model, mode="real", transport=self._a3.transport, address=self._robot.address, capabilities=RobotCapabilities( native_tts=True, stop=self._can_stop(), progress_events=True, reports_completion=bool(transport and transport.reports_completion), voice_selection=bool(self._a3.voice), volume_control=self._a3.volume is not None, ), ) def _can_stop(self) -> bool: """Only advertise Stop when a stop path is actually configured.""" transport = self._a3.transport if transport == "aimdk": return bool(self._a3.aimdk_stop_method) if transport == "http": return bool(self._a3.http_stop_path) if transport == "ws": return bool(self._a3.ws_stop_payload) if transport == "ros2": return bool(self._a3.ros_stop_topic) if transport == "ssh": return bool(self._a3.ssh_stop_command) return False @property def is_connected(self) -> bool: return self._connected @property def is_speaking(self) -> bool: return self._speaking @property def last_error(self) -> Optional[str]: return self._last_error # -- lifecycle ----------------------------------------------------------- # async def connect(self) -> None: if not self._robot.ip: raise RobotNotConfigured() if self._settings.has_blocking_issue: first = next(i for i in self._settings.issues if i.level == "error") raise RobotError(first.message, user_message=first.message) if self._transport is None: self._transport = create_transport(self._settings) try: await self._transport.open() ok = await self._transport.probe() except RobotError: self._connected = False raise except Exception as exc: self._connected = False raise RobotUnreachable( "transport {0} failed to open: {1}".format(self._a3.transport, exc) ) from exc if not ok: self._connected = False raise RobotUnreachable( "probe against {0} failed".format(self._robot.address) ) self._connected = True self._last_error = None logger.info( "AGIBOT A3 connected via %s at %s", self._a3.transport, self._robot.address ) async def disconnect(self) -> None: self._cancel.set() self._connected = False self._speaking = False if self._transport is not None: try: await self._transport.close() except Exception: # pragma: no cover logger.debug("transport close raised", exc_info=True) async def health_check(self) -> bool: if self._transport is None: return False try: ok = await self._transport.probe() except Exception as exc: logger.debug("health check raised: %s", exc) ok = False self._connected = ok return ok # -- speech -------------------------------------------------------------- # async def speak(self, request: SpeechRequest, on_progress: ProgressCallback) -> SpeechResult: if self._transport is None or not self._connected: raise RobotUnreachable("robot is not connected") self._cancel.clear() self._speaking = True started = time.perf_counter() try: ack = await self._transport.speak(request, on_progress) ack_ms = ack.ack_latency_ms await on_progress( SpeechProgress( request.id, SpeechStage.PROCESSING, "Robot accepted the request", detail=ack.detail, elapsed_ms=ack_ms, ) ) estimated = estimate_speech_seconds(request.text) await on_progress( SpeechProgress( request.id, SpeechStage.SPEAKING, "Speaking...", detail=None if self._reports_completion else "~{0:.1f}s".format(estimated), elapsed_ms=int((time.perf_counter() - started) * 1000), ) ) cancelled = await self._await_end(estimated) total_ms = int((time.perf_counter() - started) * 1000) if cancelled: await on_progress( SpeechProgress(request.id, SpeechStage.CANCELLED, "Speech stopped.") ) return SpeechResult( request_id=request.id, success=False, stage=SpeechStage.CANCELLED, ack_latency_ms=ack_ms, total_ms=total_ms, error_code="cancelled", error="Stopped by operator.", ) await on_progress( SpeechProgress(request.id, SpeechStage.COMPLETED, "Ready", elapsed_ms=total_ms) ) return SpeechResult( request_id=request.id, success=True, stage=SpeechStage.COMPLETED, ack_latency_ms=ack_ms, total_ms=total_ms, ) except RobotError as exc: self._last_error = str(exc) raise finally: self._speaking = False @property def _reports_completion(self) -> bool: return bool(self._transport and self._transport.reports_completion) async def _await_end(self, estimated_seconds: float) -> bool: """Wait for the utterance to finish. Returns True if it was cancelled. If the transport delivers a real completion event we use it; otherwise we fall back to the estimated duration so the UI still returns to Ready. """ transport = self._transport if self._reports_completion and hasattr(transport, "await_completion"): budget = max(estimated_seconds * 3, 10.0) done_task = asyncio.ensure_future(transport.await_completion(budget)) # type: ignore[union-attr] cancel_task = asyncio.ensure_future(self._cancel.wait()) try: await asyncio.wait( {done_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED ) return cancel_task.done() and not done_task.done() finally: for task in (done_task, cancel_task): if not task.done(): task.cancel() if self._reports_completion: # e.g. SSH: transport.speak() already blocked until playback ended. return self._cancel.is_set() deadline = time.perf_counter() + estimated_seconds while True: remaining = deadline - time.perf_counter() if remaining <= 0: return False if self._cancel.is_set(): return True await asyncio.sleep(min(_TICK, remaining)) async def stop_speaking(self) -> bool: self._cancel.set() if self._transport is None: return False try: return await self._transport.stop() except Exception as exc: logger.warning("stop failed: %s", exc) return False async def describe(self) -> Dict[str, Any]: detail: Dict[str, Any] = { "transport": self._a3.transport, "address": self._robot.address, "connected": self._connected, "lastError": self._last_error, } if self._transport is not None: try: detail["transportDetail"] = await self._transport.describe() except Exception: # pragma: no cover pass return detail