agi_fleet/README.md
2026-08-04 11:27:37 +00:00

230 lines
8.0 KiB
Markdown

# 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 <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.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=<new-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 <token>`, 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.