# sanad_api_x2 — AGIBOT X2 fleet agent Reports the **AGIBOT X2**'s live state to the YS Lootah fleet server over the fleet's five ingest endpoints, with the standard 27-field payload and per-robot Bearer-token auth. Deploy it with the fleet installer (`../../fleet_install.sh`). Generated from the canonical `agents/g1/` by `tools/gen_agents.py` — **do not hand-edit this folder's `sanad_api_x2.py`**. Everything except the state source is byte-identical logic to the G1/R1/Go2 agents. --- ## The one thing that is different **The X2 exposes no fixed topic contract**, so this agent hard-codes nothing: it picks a state-source backend at runtime and reads every topic name and field path from `.env`. Retargeting it at the real interface is an **`.env` edit + restart** — no code change, no image rebuild. | `X2_SOURCE` | what it does | needs | |---|---|---| | `auto` *(default)* | `http` if `X2_STATE_URL` is set → else `ros2` if `rclpy` imports → else `aimrt` if `aimrt_py` imports → else `none` | — | | `http` | polls `X2_STATE_URL` for one JSON object | nothing (slim image) | | `ros2` | subscribes `X2_TOPIC_*` | a ROS base image (see below) | | `aimrt` | **not natively bound** — see below | — | | `none` | never reads; always heartbeat | — | Whatever the backend, `AgiBotSource` fills one six-key snapshot — `bms`, `state_age`, `temps`, `max_vel`, `xy`, `fw` — which is the entire seam between this robot and the shared telemetry / fault / status / alert pipeline. ### On the `aimrt` backend AgiBot's open-source X1 stack is built on **AimRT**, and it is the likely middleware here. It is deliberately **not** implemented natively: the X2's AimRT channel names and message definitions aren't public, and guessing them yields an agent that imports cleanly, builds, and posts perfect-looking heartbeats with `battery: null` forever — a failure you'd only find on the robot. The supported path today is **AimRT's ROS 2 plugin**: enable it and run with `X2_SOURCE=ros2` against the bridged topics. If you get the native channel definitions from AgiBot, implement `_start_aimrt()` in `tools/gen_agents.py` (not here — this file is generated). The only contract to satisfy is calling `_ingest_battery` / `_ingest_joints` / `_ingest_odom` with the incoming messages. --- ## Quick start **1. Discover the robot's interface** (read-only — safe on a live robot): ```bash ./fleet_install.sh probe x2 --user # or: tools/probe_x2.sh ``` It prints the platform, serial-number candidates, NICs, ROS topics **with their message types**, listening ports, running containers and map directories — each labelled with the `.env` variable it feeds. **2. Install**, using the real serial from the probe: ```bash ./fleet_install.sh install x2 --sn \ --token --server-url https://eco.yslootahrobotics.com ``` For the `ros2` backend, build against a base matching the **robot's** ROS distro (a mismatch means ROS 2 discovery silently sees no topics): ```bash X2_BASE_IMAGE=ros:humble-ros-base ./fleet_install.sh install x2 --sn ... ``` **3. Map the fields** you found, then restart: ```bash ssh @ 'nano ~/sanad_api_x2/.env' ssh @ 'systemctl --user restart sanad-api-x2' ./fleet_install.sh data x2 ``` **Updating later?** Always pass `--keep-token`, or `--token` defaults to `test-token` and the feed dies with `401`. --- ## Field mapping Paths are dotted and work over **both** ROS message objects and plain JSON dicts, with list indices: `pose.pose.position.x`, `battery.cell_temp[0]`, `percentage`. A missing link yields `null` — a wrong path degrades one field, it never crashes the agent. | var | default (standard ROS msg) | feeds | |---|---|---| | `X2_FIELD_SOC` | `percentage` | `battery` | | `X2_FIELD_VOLTAGE` / `_CURRENT` | `voltage` / `current` | `battery_detail`, `charging` | | `X2_FIELD_TEMP` / `_SOH` / `_CYCLES` | `temperature` / — / — | `battery_detail` | | `X2_FIELD_TEMPS` | *(empty)* | `motor_temp`, `MOTOR_OVERTEMP` | | `X2_FIELD_VEL` | `velocity` | `status: moving` | | `X2_FIELD_X` / `_Y` | `pose.pose.position.{x,y}` | `position` | | `X2_FIELD_FSM` / `_FW` | *(empty)* | `control.fsm_id`, `firmware` *(http only)* | > **`motor_temp` is `null` on the X2, permanently.** `PmuState.msg` is the only > `aimdk_msgs` definition with any temperature field, and it is the *power unit* > temperature — not per-motor. `aimdk_msgs/msg/JointState` is > `name/position/velocity/effort/error_code`. Reporting `pmu_temp` as > `motor_temp` would be fabricating data, so it stays `null` = "not receiving". ## Position — a second, independent source The Control Dash exposes no odometry, but ROS does. `X2_POSITION_SOURCE=ros2` runs **alongside** `X2_SOURCE=http`: battery over HTTP, position over ROS 2. ``` X2_POSITION_SOURCE=ros2 X2_TOPIC_ODOM=/aima/mc/leg_odometry # nav_msgs/msg/Odometry X2_ROS_QOS=best_effort ``` ⚠ **`X2_ROS_QOS` must stay `best_effort`.** The X2 publishes that topic as `BEST_EFFORT`, and a `RELIABLE` subscriber receives **nothing** from a `BEST_EFFORT` publisher — rclpy creates the subscription, raises no error, and position silently stays `null`. A `BEST_EFFORT` subscriber reads from either kind of publisher, which is why it's the default. This requires `rclpy`, so the systemd unit sources the ROS overlay before starting the agent: ```ini ExecStart=/bin/bash -c 'set +u; . /opt/ros/humble/setup.bash >/dev/null 2>&1 || true; \ exec /usr/bin/python3 -u /home/agi/sanad_api_x2/sanad_api_x2.py' ``` ### Verify these two on real hardware Both are unit conventions that look plausible when wrong: | var | symptom if wrong | fix | |---|---|---| | `X2_SOC_SCALE` | battery reads `0` or `100` when it should be `47` | `fraction` (0–1 source) or `percent` (0–100 source) | | `X2_CURRENT_SIGN` | `charging` is inverted | `-1` | | `X2_VOLTAGE_SCALE` / `X2_CURRENT_SCALE` | `voltage_v: 48200` instead of `48.2` | `0.001` for mV/mA sources | --- ## What works without any mapping These need no X2-specific configuration and are live from the first install — so the robot is useful on the dashboard **before** the state map is known: - **identity** — `sn`, `name`, `mac`, `brand: agibot`, `type`, `model` - **software / firmware** — OS, kernel, arch, python, board, L4T (via `/host`) - **storage** — real host disk usage - **maps** — `pgm`+`yaml` sets rendered to PNG, RTAB-Map `.db` sent as-is - **logs** — the agent's own lines, shipped every 60 s with requeue on failure - **remote** — `ssh @` registration - **alerts** — log-pattern scanning Fields that depend on the **Sanad app** degrade to `null` automatically if the X2 doesn't run it: `project_logs`, `remote.web`, `control`. No configuration needed — the schema stays identical, the dashboard just shows fewer cards. --- ## Status ⚠ **Unverified on hardware.** The full pipeline is verified end-to-end against `fleet_test_server.py` — all five endpoints, correct Bearer auth, 27-field payload, field mapping, unit scaling, fault derivation and map upload. What is *not* verified is which backend and which field paths the real X2 needs; that is what `probe_x2.sh` and the `.env` mapping exist to resolve.