137 lines
4.7 KiB
Python
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)
|