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

137 lines
4.7 KiB
Python

"""Transport contract for the AGIBOT A3 adapter.
A *transport* knows how to move one "speak this text" command onto the wire and
back. It knows nothing about history, the UI, or the event bus.
Why the payloads are templates rather than code
-----------------------------------------------
The exact request body an A3 expects is defined by AgiBot's SDK/API reference for
your specific unit and firmware. Inventing one would be worse than useless, so the
shape is supplied as configuration:
A3_HTTP_SPEAK_PATH=/some/documented/path
A3_HTTP_SPEAK_PAYLOAD={"text": "{text}", "voice": "{voice}"}
Placeholders available in any template string:
{text} {id} {voice} {language} {volume} {speed}
A template string that is *exactly* one placeholder keeps the value's native type
(so {volume} becomes a JSON number, not the string "0.8"). Keys whose rendered
value is null are dropped, so unset optional parameters are simply not sent.
"""
from __future__ import annotations
import abc
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
from urllib.parse import quote
from ..base import ProgressCallback, SpeechRequest
@dataclass
class TransportAck:
"""What the robot said when it accepted (or rejected) the utterance."""
accepted: bool
ack_latency_ms: int
detail: Optional[str] = None
raw: Optional[Any] = None
class SpeechTransport(abc.ABC):
"""One wire protocol for delivering text to the robot."""
name: str = "transport"
#: True when the robot itself tells us the utterance finished. When False the
#: adapter estimates the speaking duration instead of guessing completion.
reports_completion: bool = False
@abc.abstractmethod
async def open(self) -> None:
"""Set up any persistent client/socket. Raises RobotError on failure."""
@abc.abstractmethod
async def close(self) -> None:
"""Tear down. Must never raise."""
@abc.abstractmethod
async def probe(self) -> bool:
"""Cheap liveness check against the robot."""
@abc.abstractmethod
async def speak(
self, request: SpeechRequest, on_progress: ProgressCallback
) -> TransportAck:
"""Deliver the utterance. Return as soon as the robot acknowledges it."""
@abc.abstractmethod
async def stop(self) -> bool:
"""Ask the robot to stop speaking. Returns True if a stop was delivered."""
async def describe(self) -> Dict[str, Any]:
return {}
# --------------------------------------------------------------------------- #
# payload templating
# --------------------------------------------------------------------------- #
def build_context(request: SpeechRequest, defaults: Dict[str, Any]) -> Dict[str, Any]:
ctx: Dict[str, Any] = {
"text": request.text,
"id": request.id,
"voice": request.voice if request.voice is not None else defaults.get("voice"),
"language": request.language if request.language is not None else defaults.get("language"),
"volume": defaults.get("volume"),
"speed": defaults.get("speed"),
}
return ctx
def render_payload(template: Any, ctx: Dict[str, Any], drop_null: bool = True) -> Any:
"""Recursively substitute placeholders into a parsed-JSON template."""
if isinstance(template, str):
stripped = template.strip()
if stripped.startswith("{") and stripped.endswith("}"):
key = stripped[1:-1]
if key in ctx:
return ctx[key] # preserve native type
rendered = template
for key, value in ctx.items():
token = "{" + key + "}"
if token in rendered:
rendered = rendered.replace(token, "" if value is None else str(value))
return rendered
if isinstance(template, dict):
out: Dict[str, Any] = {}
for key, value in template.items():
resolved = render_payload(value, ctx, drop_null)
if drop_null and resolved is None:
continue # unset optional parameter - do not send the key at all
out[key] = resolved
return out
if isinstance(template, list):
items = [render_payload(item, ctx, drop_null) for item in template]
return [item for item in items if not (drop_null and item is None)]
return template
def render_path(template: str, ctx: Dict[str, Any]) -> str:
"""Render a URL path/query template with percent-encoded values."""
rendered = template
for key, value in ctx.items():
token = "{" + key + "}"
if token in rendered:
rendered = rendered.replace(token, quote("" if value is None else str(value), safe=""))
return rendered
def now_ms(since: float) -> int:
return int((time.perf_counter() - since) * 1000)