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