304 lines
12 KiB
Python
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()
|