300 lines
10 KiB
Python
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
|