# AGIBOT A3 — how the speech integration works **Short answer: the A3 has a documented, LAN-callable, native text-to-speech HTTP endpoint, and this project talks to it directly.** No SDK, no login, no ROS, no audio file transfer. ``` Browser → this backend → HTTP JSON-RPC → A3 head unit (HDU) → built-in TTS → speaker ``` Everything below is sourced. Claims that could not be verified are tagged **[UNVERIFIED]** or **[UNKNOWN]** rather than smoothed over, and anything found on a *different* AgiBot model is labelled as such — an A2/X2/A3-Ultra fact is not an A3 fact. > Research date: **2 September 2026**, against AgiBot doc tree **A3 v3.1 / v3.2**. > If your unit's firmware differs, re-run the discovery procedure in §6. --- ## 1. The interface AgiBot's A3 developer guide documents two developer transports on the robot: | Transport | AgiBot's stated use | Notes | | --- | --- | --- | | **HTTP JSON-RPC** | "low-frequency, many-to-one calls" | `POST http://{IP}:{PORT}/rpc/{service}/{method}`, `Content-Type: application/json` **mandatory** | | **ROS 2 topic** | "high-frequency, many-to-many calls" | ROS 2 Jazzy on Fast DDS; default QoS `keep_last`/depth 10/**`best_effort`** | Source: [03-second_develop_interface_overview](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview) The same page explicitly sanctions calling it from another machine: > *"For remote calls, replace it with the reachable IP of the corresponding > device in the current network."* > *"…there are no restrictions [on language]. As long as you can send and receive > HTTP requests, you can call all HTTP JSON RPC interfaces."* **There is no documented gRPC or WebSocket interface on the robot.** (WebSocket appears only in AgiBot's separate *cloud* LinkSoul agent SDK — see §3.) ### The TTS call On the **HDU** (the head unit — it owns all audio), port **59301**: ```http POST http://:59301/rpc/aimdk.protocol.TTSService/PlayTTS Content-Type: application/json {"text": "Hello, welcome to our showroom.", "priority_level": "INTERACTION_L6", "domain": "voice_control", "trace_id": "abc123", "is_interrupted": true} ``` ```json {"text": "...", "priority_level": "INTERACTION_L6", "priority_weight": 0, "domain": "voice_control", "trace_id": "abc123_18bZZLTk5VfJGSy8Cylsu4", "is_sucess": true, "error_message": "", "estimated_duration": 0} ``` Source: [07-02-audio_play](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play) · runnable `curl` in AgiBot's own [quick start](https://open.agibot.com/docs/en/aimdk/a3/v3_1/dev_guide/06-second_develop_quick_start) Companion methods on the same host and port: | Method | Body | Purpose | | --- | --- | --- | | `StopTTSTraceId` | `{"trace_id": "..."}` | interrupt an utterance | | `GetAudioStatus` | `{"trace_id": "..."}` | `TTSStatusType_Begin\|_Playing\|_End\|_Stop\|_Error\|_InQue\|_NOTInQue` | | `PlayMediaFile` | `{"file_name": "..."}` | play a file from `/agibot/data/var/interaction/audio/` | Volume lives on a **different port, 56666**, service `aimdk.protocol.HalAudioService` (`GetAudioVolume` / `SetAudioVolume`, range 0–100). > ⚠️ **AgiBot's own hardware warning: do not set volume above 70 — "exceeding > this range may damage the speaker."** This project does not change volume, so > it cannot trip that. If you add volume control later, clamp it at 70. --- ## 2. Four traps, and how this project handles each These are the things that make a naive client fail. All four are handled in [backend/robot/transports/aimdk_transport.py](../backend/robot/transports/aimdk_transport.py). | # | The trap | What we do | | --- | --- | --- | | 1 | The success flag is spelled **`is_sucess`** — one `c` — in AgiBot's documentation. | Read `is_sucess` **and** `is_success`, so a corrected firmware still works. | | 2 | The **returned `trace_id` is not the one you sent** — the robot appends a random suffix. Stop only works with the returned value. | The returned id is captured and is what `Stop` uses. | | 3 | **1024 *bytes*** of UTF-8 is a hard limit (~200 characters). A 1000-character Chinese sentence is ~3000 bytes and is rejected outright. | Text is chunked on sentence boundaries by **byte** length; only the first chunk may interrupt, the rest queue behind it. | | 4 | Status polling is capped at **≤ 0.2 Hz**; AgiBot warns high-frequency RPC can destabilise the robot. | The liveness probe is a **TCP connect, never an RPC**, and completion is estimated rather than polled. | Two more behaviours worth knowing, from reading AimRT's own source ([http_rpc_backend.cc](https://raw.githubusercontent.com/AimRT/AimRT/main/src/plugins/net_plugin/http/http_rpc_backend.cc)): - The route is `"/rpc" + ` — the `pb:/` prefix seen in AimRT function names is **stripped**. Never put `pb:/` in a URL. - **An RPC-level failure is HTTP 500, not a JSON error.** Unknown route is 404. **HTTP 200 means the transport worked, not that the robot spoke** — `is_sucess` only confirms the priority check passed. --- ## 3. Why this path, and not the others | Path | Latency | Reliability | Complexity | Verdict | | --- | --- | --- | --- | --- | | **1. HTTP JSON-RPC `PlayTTS`** (this project) | 1 request RTT to enqueue (< 20 ms LAN); time-to-first-audio dominated by the robot's own synthesis | Highest — AgiBot's own primary documented path | Near zero — one HTTP POST | **✅ Chosen** | | 2. Own TTS → WAV → SCP → `PlayFile` | 1–4 s cold; ~1 RTT for pre-cached phrases | Good; documented offline fallback | Moderate — SSH creds, file lifecycle | Fallback if #1 needs internet and you are air-gapped | | 3. Own TTS → PCM → ROS 2 `/audiohal/audio/playback` | Best possible — tens of ms, true streaming | High ceiling, high risk (best-effort QoS, focus arbitration) | **High** — ROS 2 Jazzy on the PC, login-gated aarch64 packages, robot reboot into `only_voice` | Only if you need barge-in streaming | | 4. LinkSoul cloud agent SDK (WebSocket) | Worst — PC → AgiBot cloud → robot | n/a | Commercial credentials, cloud dependency | Wrong tool: a conversational agent layer, not a "say this now" primitive | | 5. SSH + direct ALSA write | — | — | — | **Do not ship.** No AgiBot document mentions ALSA. `hal_audio` owns the device behind an explicit focus/priority protocol; bypassing it also bypasses the volume damage limit. | Path 1 wins on every axis the brief asked for: lowest latency, highest reliability, simplest deployment, best-documented A3 compatibility, and it scales because it is stateless HTTP. **Critically, it sends *text*.** The robot synthesises. Nothing is rendered, encoded or transferred by the PC — which is exactly the architecture the "fast speech" requirement asks for. --- ## 4. What this project does with it `A3_TRANSPORT=aimdk` (the default) selects [aimdk_transport.py](../backend/robot/transports/aimdk_transport.py): - one **keep-alive** `httpx` client opened at startup and reused for every utterance — no handshake per Speak; - a `Timeout:` header (AimRT honours whole seconds) **and** a client-side timeout, so a wedged robot cannot hang the dashboard; - ack latency measured and reported to the UI; - `Stop` via `StopTTSTraceId` using the robot-returned `trace_id`; - errors mapped to messages an operator can act on. **Every documented value is a setting, not a literal**, because AimRT has no service-discovery endpoint and AgiBot does not guarantee port or name stability across firmware: ```env A3_AIMDK_SERVICE=aimdk.protocol.TTSService A3_AIMDK_PLAY_METHOD=PlayTTS A3_AIMDK_STOP_METHOD=StopTTSTraceId A3_AIMDK_PRIORITY=INTERACTION_L6 A3_AIMDK_MAX_BYTES=1024 ``` If your firmware renames something, you edit `.env`. If it uses a wholly different shape, `A3_TRANSPORT=http` gives you a fully templated HTTP client, and `ws` / `ros2` / `ssh` are there as further fallbacks. --- ## 5. Connecting the robot — the exact steps ### 5.1 Where the IP goes **One file: `.env`, in the project root.** Three lines: ```env ROBOT_MODE=real ROBOT_IP=192.168.1.50 ← the robot's IP on YOUR network ROBOT_PORT=59301 ``` > **Do not use `10.42.10.10`.** That address appears throughout AgiBot's > examples, but it is the HDU's address on the robot's *internal* `eth_hdu` > network. Your PC cannot reach it. Use the HDU's address on your WiFi/LAN. Then either restart the server, or without restarting: ```bash curl -X POST http://localhost:8000/api/config/reload ``` ### 5.2 The order to do things in ```bash # 1. reachable at all? ping 192.168.1.50 # 2. what does it expose? (probes the documented ports and routes, read-only) python scripts/discover_robot.py 192.168.1.50 # 3. the decisive test - does it actually speak? python scripts/discover_robot.py 192.168.1.50 --speak "Hello, I am Expedition A3" # 4. point the app at it, then python scripts/selftest.py ``` ### 5.3 What each failure means | Result of step 3 | Meaning | Next step | | --- | --- | --- | | **The robot speaks** | Path 1 works. Done. | Set `.env`, restart, demo. | | Connection refused / timeout | 59301 is not bound on the WLAN interface | §6 Step 2 — SSH in and check `ss -ltnp` | | **HTTP 404** | Port is right, route is wrong | Check `A3_AIMDK_SERVICE` / `A3_AIMDK_PLAY_METHOD`; confirm firmware version | | **HTTP 500** | Route exists, the RPC failed — **the port is reachable** | Read the response body; check `priority_level` | | `is_sucess: false` | The robot rejected it | Read `error_message` — usually priority or text length | | 200 + success but silence | Accepted but not played | Check robot volume, and whether TTS needs internet (§7) | --- ## 6. Discovery procedure for the live robot `scripts/discover_robot.py` automates the PC-side checks. The rest needs an SSH session on the robot. ```bash # From the PC - which of the documented ports answer? python scripts/discover_robot.py # Equivalent by hand. A 404 proves an AimRT HTTP server is listening; # connection-refused proves it is not. for p in 59301 56666 51049; do curl -s -o /dev/null -w "$p -> %{http_code}\n" -m 3 \ "http://:$p/rpc/does.not.Exist/Nope" done ``` On the robot (username `agi` is **[UNVERIFIED]** — confirm with AgiBot): ```bash # Is the RPC server bound to 0.0.0.0 or only to the internal 10.42.10.10? ss -ltnp | grep -E '59301|56666|51049' ip -br a # the HDU's real WLAN address # The authoritative port map, from firmware config grep -rn 'listen_port' /agibot/software/v0/ 2>/dev/null | head -50 # ROS environment (resolves ROS_DOMAIN_ID, only needed for the ros2 transport) source /agibot/software/v0/entry/env/env.sh || source /agibot/software/v0/entry/env.sh echo "DOMAIN=$ROS_DOMAIN_ID RMW=$RMW_IMPLEMENTATION" # The real audio surface — this substitutes for the missing discovery API ros2 topic list | grep -Ei 'audio|tts|interaction' ros2 topic echo --once /interaction/tts_status # while a PlayTTS is running ``` > **Do not put any of these in a loop.** AgiBot's > [constraints page](https://open.agibot.com/docs/aimdk/a3/v3_2/dev_guide/04-second_develop_constraints_overview) > caps status RPCs at ≤ 0.2 Hz, forbids deploying on the MDU ("risks the robot > falling"), forbids `apt` install/uninstall, and states violations "may void > warranty." --- ## 7. Open questions — send these to AgiBot The five that actually affect this project, in priority order. (Chinese versions follow, since AgiBot support answers faster in Chinese.) 1. **Is port 59301 on the HDU bound to all interfaces and reachable from a PC on the same WiFi/LAN using the HDU's WLAN IP — or only on the internal `eth_hdu` 10.42.10.x segment?** *HDU 的 59301 端口是否监听在所有网络接口上?能否从同一局域网的 PC 通过 HDU 的 WLAN IP 访问?还是仅绑定在内部 eth_hdu (10.42.10.x) 网段?* 2. **Does `TTSService/PlayTTS` require internet access?** Your English v3.2 page says it "requires network connection". Which engine performs synthesis, and is there a fully on-device/offline TTS mode? What happens with LAN but no internet? *`PlayTTS` 是否必须联网?语音合成由哪家引擎提供?是否有完全离线的端侧 TTS 方案?只有局域网、没有外网时会怎样?* 3. **Is there any authentication, token or IP allow-list on the `/rpc/` endpoints?** If not, what do you recommend on a shared network? (AimRT sets `Access-Control-Allow-Origin: *` unconditionally, so any page on the LAN can call these.) *`/rpc/` 接口是否有鉴权、token 或 IP 白名单?如果没有,在共享网络下推荐如何防护?* 4. **Can we select voice/timbre, language, speaking rate or emotion per `PlayTTS` call?** Is `SetTtsParameters` or an equivalent available on the A3, and at which endpoint? **Our unit's voice is currently set to "Yunxiao" (teenager, male, multi-language) — is that selectable per request, or only in the console?** *能否在每次 PlayTTS 调用时指定音色、语言、语速或情感?A3 是否提供 SetTtsParameters?接口地址是什么? 我们的机器人当前音色为"Yunxiao"(少年、男声、多语言),该音色能否在每次调用时指定,还是只能在控制台配置?* > **Operator-reported, 2026-09-02:** the robot's configured voice is > **"Yunxiao"** — labelled *teenager | male*, *multi-language*, and marked as > the default (默). It was shown in a voice-library UI offering per-voice > "Speech Synthesis", which is consistent with AgiBot's LinkSoul timbre > library (30–40 timbres + voice cloning) — though that link is inference, not > something any published document states. This is **not** in AgiBot's docs; > it comes from the console for this specific unit. It suggests voice > selection happens on a platform/console rather than in the `PlayTTS` body, > which is exactly what question 4 needs to settle. 5. **Please confirm the complete `priority_level` enum** accepted by A3 `PlayTTS`, and the semantics of `priority_weight` and `is_interrupted`. Your X2 documentation publishes seven levels — do the same apply to the A3? *请提供 A3 PlayTTS 支持的完整 priority_level 枚举,以及 priority_weight 和 is_interrupted 的语义。X2 文档公布了七个等级,A3 是否相同?* Lower priority, worth asking in the same message: the response envelope (is there a `header.code`/`header.msg` wrapper?); whether the AimDK SDK download needs a verified-customer account; whether ports 59301/56666/51049 are stable across firmware; and the `PlayMediaFile` sample rate — **your English v3.2 page says 24 kHz and your Chinese v3.2 page says 16 kHz for the same call.** --- ## 8. Known contradictions in AgiBot's own documentation Recorded so nobody wastes an afternoon on them. | # | Contradiction | Impact here | | --- | --- | --- | | 1 | `PlayMediaFile` sample rate: **EN v3.2 says 24 kHz, ZH v3.2 says 16 kHz**, same page | None — we send text, not files | | 2 | Microphone: v3.1 and v3.2 dev guides say the built-in mic is "not yet configured or enabled"; the User Manual and product page advertise an 8-mic array | None — output only | | 3 | `env.sh` path: `/agibot/software/v0/entry/env.sh` vs `…/entry/env/env.sh` | Only matters for the `ros2` transport | | 4 | ROS 2 distro: AgiBot says the A3 is **Jazzy**; AimRT's `ros2_plugin` doc says Humble | Only matters for the `ros2` transport | | 5 | SDK wheel: the A3 v3.2 guide instructs `a2_aimdk-3.0.0…whl` in most places and `a3_aimdk-3.0.0…whl` in one — an **A2 artifact inside the A3 doc tree** | None — Path 1 needs no SDK | **`ROS_DOMAIN_ID` for the A3 is [UNKNOWN]** — it is not printed in any A3 document. A2 uses 232 (**different model**), and one third-party A3-Ultra deployment also uses 232 — suggestive, not proof. Read it from `env.sh` on the robot. --- ## 9. Security note worth raising internally No authentication scheme is documented for the `/rpc/` endpoints, and AimRT sets `Access-Control-Allow-Origin: *` unconditionally with OPTIONS preflight handled. **Taken together: any host — or any web page opened in any browser — on the same LAN as the robot can make it speak.** This project does not widen that exposure. The browser talks only to `127.0.0.1:8000`; robot credentials and addresses stay server-side. But on a shared or public network, treat the robot's RPC ports as unauthenticated and segment the network accordingly. --- ## 10. Testing the real path before the robot arrives `ROBOT_MODE=mock` tests the application. To test **this transport** — the URL shape, the JSON body, the `trace_id` round trip, chunking, Stop, and the error paths — run the local stand-in: ```bash python scripts/fake_a3_server.py # implements the documented contract on :59301 ``` then in `.env`: ```env ROBOT_MODE=real ROBOT_IP=127.0.0.1 ROBOT_PORT=59301 ``` and use the dashboard normally. Every utterance prints in the stand-in's console. > It is a **test double written from public documentation**, not the robot. > Passing against it proves the client is well-formed — not that the firmware > behaves identically. Verify on the real unit. --- ## 11. Sources **Official AgiBot, A3-specific** - Audio interfaces (TTS, status, stop, volume, ROS 2 audio) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-02-audio_play) · [ZH](https://open.agibot.com/docs/aimdk/a3/v3_2/dev_guide/07-02-audio_play) - Interface overview (the two transports, URL shape, remote-call sanction) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/03-second_develop_interface_overview) - Quick start (the runnable `PlayTTS` curl) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_1/dev_guide/06-second_develop_quick_start) - Resource manager (`PlayFile`, WAV/PCM rules, resource dirs) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/07-05-resource_manager) - Interactive guide (`audio_msgs/msg/AudioPlayback`, focus services, `only_voice`) — [EN](https://open.agibot.com/docs/en/aimdk/a3/v3_2/dev_guide/08-interactive_guide) - Development constraints (polling limits, MDU, apt, warranty) — [ZH](https://open.agibot.com/docs/aimdk/a3/v3_2/dev_guide/04-second_develop_constraints_overview) - Hardware/software overview (HDU/MDU, internal IPs, Ubuntu 24.04) — [ZH](https://open.agibot.com/docs/aimdk/a3/v3_1/dev_guide/01-a3_overview) - AimDK docs root — https://open.agibot.com/docs/aimdk (SDK downloads are login-gated) **AimRT framework** (transport semantics, read from source) - [net_plugin docs](https://docs.aimrt.org/tutorials/plugins/net_plugin.html) · [http_rpc_backend.cc](https://raw.githubusercontent.com/AimRT/AimRT/main/src/plugins/net_plugin/http/http_rpc_backend.cc) **Different model — do not assume it applies to the A3** - X2 voice interface, full 7-value priority enum — https://x2-aimdk.agibot.com/en/dev/Interface/interactor/voice.html **Third-party corroboration** (an A3 **Ultra** deployment, not a specification) - [tzj-hub/agibot_A3_sells_cars](https://github.com/tzj-hub/agibot_A3_sells_cars) — same base URL, methods and payload shape; reads `is_sucess` with an `is_success` fallback; its `a3_interfaces.py` is the only source anywhere for the `header.code`/`header.msg` error envelope, which is why this project reads that envelope defensively rather than depending on it.