# 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 to > `https://eco.yslootahrobotics.com` every 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 ```ini 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 `, 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.msg` is the only `aimdk_msgs` definition containing any temperature field and it is the *power unit*, not the motors. Reporting `pmu_temp` there 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 to `null` automatically. `null` means "not available", never "zero". --- ## 5. Operating it ```bash # 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 ```bash 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. ```bash ssh agix2 "sed -i 's#^DEVICE_TOKEN=.*#DEVICE_TOKEN=#' ~/sanad_api_x2/.env \ && chmod 600 ~/sanad_api_x2/.env && systemctl --user restart sanad-api-x2" ``` ### Re-discovering the robot's interface ```bash 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**: ```bash 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: ```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' ``` `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 `, keyed by `sn`. - **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/.env` on the robot, mode `600`, and is `.gitignore`d 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.