8.0 KiB
AGI Fleet — AGIBOT X2 agent
On-robot agent that reports the AGIBOT X2's live state to the YS Lootah fleet server_ Eco system.
Status: LIVE on production. Running on
10.255.254.84, posting tohttps://eco.yslootahrobotics.comevery 2 s.
telemetry ok: battery=68 charging=True status=charging pos={'x': 1.222, 'y': 0.437} -> HTTP 200
1. Layout
agi_fleet/
├── README.md ← this file
├── agent/
│ ├── sanad_api_x2.py ← THE AGENT (deployed as-is — do not edit casually)
│ ├── .env.example ← every setting, documented
│ ├── requirements.txt ← requests (+ websocket-client, optional)
│ ├── AGENT_README.md ← agent internals: backends, field mapping
│ ├── Dockerfile ← optional container path (NOT used — see §7)
│ ├── entrypoint.sh ← container entrypoint (unused)
│ └── .dockerignore
├── tools/
│ └── probe_x2.sh ← read-only robot discovery
└── docs/
└── X2_INTERFACE.md ← what the X2 actually exposes (topics, QoS, msgs)
The agent filename stays sanad_api_x2.py because that is exactly what is
deployed and running on the robot (~/sanad_api_x2/, unit sanad-api-x2).
Renaming it would mean redeploying a working production feed for cosmetic
reasons — not worth it.
2. The robot
| Host | 10.255.254.84 (NIC wifi0) |
| SSH | agi@10.255.254.84, key ~/.ssh/agibot_x2_ed25519 (alias agix2) |
Serial (SN) |
X230028C5Z0058 |
| Display name | x2_84 |
| Hardware | NVIDIA Jetson Orin NX, arm64, L4T R36.4.3 |
| OS | Ubuntu 22.04.5 LTS, kernel 5.15.148-tegra |
| ROS | Humble (/opt/ros/humble), ROS_DOMAIN_ID=0 |
| Install dir | ~/sanad_api_x2/ |
| Service | systemctl --user sanad-api-x2 |
3. How it gets its data
Two independent sources, running at the same time:
| what | source |
|---|---|
| battery, charging, voltage, current, temp, cycles | HTTP — the robot's AGIBOT X2 Control Dash at http://127.0.0.1:8770/api/state |
position {x, y} |
ROS 2 — /aima/mc/leg_odometry (nav_msgs/msg/Odometry) |
locomotion → status: moving |
joints.leg[*].velocity from the same dash payload |
| OS, kernel, arch, board, L4T, storage, MAC | read directly from the host |
That split matters: the Control Dash carries no odometry, and ROS carries no
tidy battery percentage — so X2_SOURCE=http and X2_POSITION_SOURCE=ros2 run
side by side. Neither depends on the other.
The field mapping actually in use
X2_SOURCE=http
X2_STATE_URL=http://127.0.0.1:8770/api/state
X2_FIELD_SOC=battery_pct X2_FIELD_VOLTAGE=battery_voltage
X2_FIELD_CURRENT=battery_current X2_FIELD_TEMP=battery_temp
X2_FIELD_CYCLES=battery_cycles X2_FIELD_VEL=joints.leg[*].velocity
X2_SOC_SCALE=percent X2_CURRENT_SIGN=1
X2_POSITION_SOURCE=ros2
X2_TOPIC_ODOM=/aima/mc/leg_odometry
X2_ROS_QOS=best_effort # ← must stay best_effort, see docs/X2_INTERFACE.md
MAC_INTERFACE=wifi0
REMOTE_PORTS=8770,8001,8000,8080
Nothing above is hard-coded in the agent — it is all .env. Retargeting is an
.env edit + restart, never a code change.
4. What it sends
One JSON object every 2 s to POST /api/v1/fleet/ingest/telemetry with
Authorization: Bearer <DEVICE_TOKEN>, plus /{sn}/alert, /{sn}/logs and
/{sn}/remote. Verified field-by-field against the robot's own readings:
| robot ground truth | agent sends |
|---|---|
dash battery_pct: 68.0 |
battery: 68 |
dash battery_voltage: 51.996 |
voltage_v: 52.0 |
dash battery_current: 3.544 |
current_a: 3.54 |
dash battery_temp: 41.6 · cycles: 14 |
temp_c: 42 · cycles: 14 |
dash charging: true |
charging: true, status: "charging" |
ROS odom x: 1.2202 y: 0.4363 |
position: {x: 1.222, y: 0.437} |
Also registers the Control Dash (http://10.255.254.84:8770) and
ssh agi@10.255.254.84 for the fleet UI.
Fields that are always null — and why
motor_temp— the X2 publishes no per-motor temperatures at all.PmuState.msgis the onlyaimdk_msgsdefinition containing any temperature field and it is the power unit, not the motors. Reportingpmu_tempthere would be fabricating data.map—no_map; there are no saved maps on this robot.project_logs/control— these are Sanad-app integrations; the X2 does not run that app, so they degrade tonullautomatically.
null means "not available", never "zero".
5. Operating it
# live log
ssh agix2 'journalctl --user -u sanad-api-x2 -f'
# what it is sending right now
ssh agix2 "journalctl --user -u sanad-api-x2 -n 20 --no-pager | grep -oE 'telemetry ok:.*'"
# service control
ssh agix2 'systemctl --user restart sanad-api-x2'
ssh agix2 'systemctl --user status sanad-api-x2'
# change a setting (then restart)
ssh agix2 'nano ~/sanad_api_x2/.env && systemctl --user restart sanad-api-x2'
Updating the agent code
scp -i ~/.ssh/agibot_x2_ed25519 agent/sanad_api_x2.py \
agi@10.255.254.84:~/sanad_api_x2/sanad_api_x2.py
ssh agix2 'systemctl --user restart sanad-api-x2'
.env is never overwritten by this, so the device token stays put.
Rotating the device token
The fleet server issues a Universal Connector token — regenerating it on the server invalidates the previous one for every robot that uses it.
ssh agix2 "sed -i 's#^DEVICE_TOKEN=.*#DEVICE_TOKEN=<new-token>#' ~/sanad_api_x2/.env \
&& chmod 600 ~/sanad_api_x2/.env && systemctl --user restart sanad-api-x2"
Re-discovering the robot's interface
FLEET_SSH_KEY=~/.ssh/agibot_x2_ed25519 bash tools/probe_x2.sh 10.255.254.84 agi
Read-only — no install, no writes, safe on a live robot. Every line it prints is
labelled with the .env variable it feeds.
6. ⚠ Open item — reboot survival
linger is off, so the agent will not start again after a power cycle.
It does survive crashes (systemd Restart=always, verified by kill -9).
polkit denies loginctl enable-linger for a non-root user on this image, so it
needs one privileged command on the robot:
ssh agix2 'sudo loginctl enable-linger agi' # prompts for the password
ssh agix2 'loginctl show-user agi | grep Linger' # expect Linger=yes
7. Why it runs natively, not in Docker
Docker is installed on the X2 but the daemon is inactive and masked, and
agi has no passwordless sudo — so the container path cannot start. The agent's
only hard dependency is requests, so it runs directly under a user-level
systemd unit instead. Same auto-start model, same .env, no root.
The unit sources the ROS overlay first so rclpy is importable for position:
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'
set +u is required — ROS's setup.bash reads unbound variables and would abort
the unit under set -u.
agent/Dockerfile and agent/entrypoint.sh are kept for the day dockerd is
unmasked; nothing currently uses them.
8. Security
- Outbound only. No inbound port is opened on the robot; every call is an
HTTPS POST carrying
Authorization: Bearer <token>, keyed bysn. - Read-only toward the robot. It reads an HTTP status page and subscribes to one ROS topic. It never commands motion.
- Token handling. Lives only in
~/sanad_api_x2/.envon the robot, mode600, and is.gitignored here. Only.env.example(placeholders) is in this repo. - TLS verified (
VERIFY_TLS=1) against the production server.
9. Relationship to the Unitree fleet
None. This project shares no files with fleet/ (the G1 / R1 / Go2 agents).
sanad_api_x2.py is standalone — it is not generated by fleet/tools/gen_agents.py
and contains zero Unitree/DDS code. The two can be changed independently.