A3_text_to_speach/scripts/discover_robot.py
2026-09-03 00:10:18 +04:00

304 lines
12 KiB
Python

"""Probe an AGIBOT A3 to find out what it actually exposes.
Run this the moment you have the robot's IP, before touching .env:
python scripts/discover_robot.py 192.168.1.50
It is READ-ONLY by default: it pings, checks the ports AgiBot documents, and asks
each one for a deliberately nonexistent route. A **404 proves an AimRT HTTP
server is listening** on that port; connection-refused proves it is not. Nothing
is played and nothing is changed.
To run the decisive end-to-end test - which makes the robot actually talk:
python scripts/discover_robot.py 192.168.1.50 --speak "Hello, I am Expedition A3"
Port and route expectations come from AgiBot's A3 developer guide:
https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play
https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview
They are documented facts for A3 v3.1/v3.2 firmware, not guarantees: AgiBot does
not promise port stability, and AimRT has no service-discovery endpoint. Anything
this script cannot confirm, it reports as unknown rather than guessing.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import platform
import subprocess
import sys
import time
from typing import Any, Dict, List, Optional, Tuple
try:
import httpx
except ImportError: # pragma: no cover
print("Install dependencies first: pip install -r requirements.txt")
raise SystemExit(2)
def _use_utf8_console() -> None:
"""Windows consoles default to a legacy code page; the robot speaks Chinese."""
for stream in (sys.stdout, sys.stderr):
try:
if stream is not None and hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
_use_utf8_console()
# Ports with published evidence. Audio ones first; the motion/mapping ports are
# probed only so the report is an honest map - they have nothing to do with TTS.
PORTS: List[Tuple[int, str, str]] = [
(59301, "TTS / agent RPC (HDU)", "audio"),
(56666, "HalAudioService - volume, PlayFile (HDU)", "audio"),
(51049, "ResourceService - audio resources (HDU)", "audio"),
(56444, "MotionCommandService (MDU)", "other"),
(56322, "motion control (MDU)", "other"),
(22, "SSH", "other"),
(8080, "common HTTP alternative", "other"),
]
TTS_SERVICE = "aimdk.protocol.TTSService"
NONEXISTENT_ROUTE = "/rpc/does.not.Exist/Nope"
OK = " [ OK ]"
NO = " [ -- ]"
WARN = " [ !! ]"
def head(title: str) -> None:
print("\n" + title)
print(" " + "-" * (len(title) + 2))
# --------------------------------------------------------------------------- #
# 1. ICMP
# --------------------------------------------------------------------------- #
def ping(host: str) -> Optional[float]:
flag = "-n" if platform.system().lower().startswith("win") else "-c"
started = time.perf_counter()
try:
result = subprocess.run(
["ping", flag, "2", host],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=12,
)
except Exception:
return None
if result.returncode != 0:
return None
return (time.perf_counter() - started) * 1000 / 2
# --------------------------------------------------------------------------- #
# 2. TCP
# --------------------------------------------------------------------------- #
async def tcp_open(host: str, port: int, timeout: float = 2.0) -> Optional[float]:
started = time.perf_counter()
try:
fut = asyncio.open_connection(host, port)
_, writer = await asyncio.wait_for(fut, timeout=timeout)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return (time.perf_counter() - started) * 1000
except Exception:
return None
# --------------------------------------------------------------------------- #
# 3. Is it an AimRT HTTP server?
# --------------------------------------------------------------------------- #
async def aimrt_probe(client: "httpx.AsyncClient", host: str, port: int) -> Dict[str, Any]:
"""Ask for a route that cannot exist.
AimRT answers 404 for an unknown route, which is a positive identification
without invoking anything.
"""
url = "http://{0}:{1}{2}".format(host, port, NONEXISTENT_ROUTE)
try:
response = await client.post(url, json={}, headers={"Content-Type": "application/json"})
except Exception as exc:
return {"reachable": False, "detail": type(exc).__name__}
return {
"reachable": True,
"status": response.status_code,
"aimrt": response.status_code in (404, 500),
"cors": response.headers.get("access-control-allow-origin"),
"server": response.headers.get("server"),
"body": response.text[:160],
}
# --------------------------------------------------------------------------- #
# 4. The decisive test
# --------------------------------------------------------------------------- #
async def try_speak(client: "httpx.AsyncClient", host: str, port: int, text: str) -> Dict[str, Any]:
url = "http://{0}:{1}/rpc/{2}/PlayTTS".format(host, port, TTS_SERVICE)
payload = {
"text": text,
"priority_level": "INTERACTION_L6",
"domain": "discovery_probe",
"trace_id": "probe{0}".format(int(time.time())),
"is_interrupted": True,
}
started = time.perf_counter()
try:
response = await client.post(url, json=payload, headers={"Content-Type": "application/json"})
except Exception as exc:
return {"ok": False, "error": "{0}: {1}".format(type(exc).__name__, exc)}
elapsed = (time.perf_counter() - started) * 1000
try:
body = response.json()
except Exception:
body = {"raw": response.text[:300]}
success = None
if isinstance(body, dict):
success = body.get("is_sucess", body.get("is_success"))
return {
"ok": response.status_code == 200 and success is not False,
"status": response.status_code,
"ms": round(elapsed, 1),
"success_flag": success,
"trace_id": body.get("trace_id") if isinstance(body, dict) else None,
"sent_trace": payload["trace_id"],
"has_header_envelope": isinstance(body, dict) and isinstance(body.get("header"), dict),
"body": body,
}
# --------------------------------------------------------------------------- #
# main
# --------------------------------------------------------------------------- #
async def run(host: str, text: Optional[str], tts_port: int) -> int:
print("\n" + "=" * 72)
print(" AGIBOT A3 discovery -> {0}".format(host))
print("=" * 72)
# ---- 1. ping -----------------------------------------------------------
head("1. Reachability")
rtt = ping(host)
if rtt is None:
print(NO + " ping got no reply.")
print(" Not conclusive - some robots drop ICMP but still serve their ports.")
else:
print(OK + " ping replies (~{0:.0f} ms round trip)".format(rtt))
# ---- 2. ports ----------------------------------------------------------
head("2. Ports")
open_ports: List[int] = []
results = await asyncio.gather(*[tcp_open(host, port) for port, _, _ in PORTS])
for (port, label, kind), elapsed in zip(PORTS, results):
if elapsed is None:
print("{0} {1:<6} closed / filtered {2}".format(NO, port, label))
else:
open_ports.append(port)
print("{0} {1:<6} OPEN ({2:.0f} ms) {3}".format(OK, port, elapsed, label))
if not open_ports:
print("\n" + WARN + " Nothing answered. Check the IP, that the robot has finished")
print(" booting, and that both machines are on the same subnet.")
print(" See docs/NETWORK.md.")
return 1
# ---- 3. AimRT identification ------------------------------------------
head("3. Which ports speak AimRT HTTP JSON-RPC?")
aimrt_ports: List[int] = []
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
for port in open_ports:
if port == 22:
continue
info = await aimrt_probe(client, host, port)
if not info.get("reachable"):
print("{0} {1:<6} no HTTP response ({2})".format(NO, port, info.get("detail")))
continue
if info.get("aimrt"):
aimrt_ports.append(port)
extra = ""
if info.get("cors"):
extra = " CORS: {0}".format(info["cors"])
print("{0} {1:<6} AimRT RPC server (HTTP {2} for an unknown route){3}".format(
OK, port, info["status"], extra))
else:
print("{0} {1:<6} HTTP {2}, but not an AimRT RPC route".format(
NO, port, info.get("status")))
# ---- 4. summary / speak -------------------------------------------
head("4. Verdict")
if tts_port in aimrt_ports:
print(OK + " Port {0} looks like the A3 TTS RPC service.".format(tts_port))
print("")
print(" Put this in .env:")
print(" ROBOT_MODE=real")
print(" ROBOT_IP={0}".format(host))
print(" ROBOT_PORT={0}".format(tts_port))
print(" A3_TRANSPORT=aimdk")
elif aimrt_ports:
print(WARN + " AimRT RPC found on {0}, but not on the documented TTS port {1}.".format(
", ".join(str(p) for p in aimrt_ports), tts_port))
print(" Try each with --speak, and set ROBOT_PORT to whichever talks.")
else:
print(WARN + " No AimRT RPC server found.")
print(" Most likely the RPC ports are bound only to the robot's internal")
print(" 10.42.10.x network. SSH in and check: ss -ltnp | grep 59301")
print(" See docs/AGIBOT_A3_INTEGRATION.md section 6.")
if text:
head("5. Speech test (the robot should talk now)")
print(' Sending: "{0}"'.format(text))
result = await try_speak(client, host, tts_port, text)
if result.get("ok"):
print(OK + " Accepted in {0} ms.".format(result["ms"]))
print(" is_sucess : {0}".format(result["success_flag"]))
print(" trace_id sent : {0}".format(result["sent_trace"]))
print(" trace_id back : {0}".format(result["trace_id"]))
if result["trace_id"] and result["trace_id"] != result["sent_trace"]:
print(" (differs, as documented - Stop must use the returned id)")
if result["has_header_envelope"]:
print(" header envelope: present")
print("")
print(" If you heard the robot, the integration is confirmed.")
print(" If it was silent, check the robot's volume and whether its")
print(" TTS needs internet access - see docs/AGIBOT_A3_INTEGRATION.md section 7.")
else:
print(WARN + " Speech request failed.")
for key in ("status", "error", "success_flag"):
if result.get(key) is not None:
print(" {0:<14}: {1}".format(key, result[key]))
if result.get("status") == 404:
print(" 404 = the port is right but the route is wrong. Check the")
print(" service/method names against your firmware's documentation.")
if result.get("body"):
print(" body : {0}".format(
json.dumps(result["body"], ensure_ascii=False)[:300]))
return 1
print("\n" + "=" * 72 + "\n")
return 0
def main() -> None:
parser = argparse.ArgumentParser(
description="Probe an AGIBOT A3 for its speech interface.",
epilog="Read-only unless --speak is given.",
)
parser.add_argument("ip", help="The robot's IP address on your network.")
parser.add_argument("--speak", metavar="TEXT", default=None,
help="Also send this text - THE ROBOT WILL TALK.")
parser.add_argument("--port", type=int, default=59301,
help="TTS RPC port (default: 59301, AgiBot's documented port).")
args = parser.parse_args()
raise SystemExit(asyncio.run(run(args.ip, args.speak, args.port)))
if __name__ == "__main__":
main()