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