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

300 lines
10 KiB
Python

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