Update 2026-08-04 15:14

This commit is contained in:
Sidra 2026-08-04 15:14:59 +04:00
commit 2757229abc
11 changed files with 3200 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
state/
maps/

230
README.md Normal file
View File

@ -0,0 +1,230 @@
# AGI Fleet — AGIBOT X2 agent
On-robot agent that reports the **AGIBOT X2**'s live state to the **YS Lootah
fleet server**. Self-contained: it shares no code with the Unitree (G1 / R1 /
Go2) agents and is deployed and managed independently.
> **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.

5
agent/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.env
data/
__pycache__/
*.pyc
README.md

166
agent/.env.example Normal file
View File

@ -0,0 +1,166 @@
# sanad_api_x2 — AGIBOT X2. Copy to .env and fill in.
# ONE agent = telemetry + map + logs + alerts + remote (see fleet/README.md).
#
# READ THIS FIRST — the X2 exposes no fixed topic contract, so EVERY topic name
# and field path used to read robot state is an env var in this file. Nothing is
# hard-coded in the agent. Discover the real ones with:
#
# ./fleet_install.sh probe x2 <ip> (or: tools/probe_x2.sh <user>@<ip>)
#
# then set them below and restart — no code change, no image rebuild.
# ── fleet server (REQUIRED — YS Lootah gives you these two) ──────────────────
SERVER_URL=https://eco.yslootahrobotics.com
DEVICE_TOKEN=REPLACE_WITH_DEVICE_TOKEN
# ── identity ─────────────────────────────────────────────────────────────────
# The robot's REAL serial — keys the robot on the server ({sn} routes).
SN=REPLACE_WITH_X2_SERIAL
# Friendly display name shown on the dashboard (default <model>_<ip-octet>).
ROBOT_NAME=x2_10
ROBOT_BRAND=agibot
ROBOT_TYPE=humanoid
ROBOT_MODEL=x2
# Maps subdir (<MAPS_DIR>/<ROBOT>/…) + X-Robot-Name header.
ROBOT=sanad
# Optional: app data dir whose size is reported in storage (as /host/<path>
# when the installer's read-only /:/host mount is used). Empty = omit.
STORAGE_DATA_PATH=
# ═════════════════════════════════════════════════════════════════════════════
# X2 STATE SOURCE — the one robot-specific section
# ═════════════════════════════════════════════════════════════════════════════
# auto = http if X2_STATE_URL is set, else ros2 if rclpy imports, else aimrt if
# aimrt_py imports, else none (heartbeat: battery null, status offline).
# http = poll X2_STATE_URL for one JSON object; map fields with X2_FIELD_* .
# ros2 = subscribe X2_TOPIC_* (needs a ROS base image — see Dockerfile).
# aimrt = AgiBot's runtime. NOT natively bound (channel/message defs are not
# public). Enable AimRT's ROS 2 plugin and use X2_SOURCE=ros2 instead.
# none = never read; always heartbeat. Useful to bring a robot online on the
# dashboard (identity, storage, maps, logs) before the state map is known.
X2_SOURCE=auto
# ── backend: http ────────────────────────────────────────────────────────────
# A vendor endpoint returning one JSON object with the robot's state.
X2_STATE_URL=
X2_HTTP_INTERVAL=1
# ── backend: ros2 ────────────────────────────────────────────────────────────
# Topic names from `ros2 topic list` on the robot. Blank = don't subscribe.
# The TYPE must match `ros2 topic info <topic>`; custom vendor messages work as
# long as their python package is importable inside the container.
X2_TOPIC_BATTERY=/battery_state
X2_TYPE_BATTERY=sensor_msgs/msg/BatteryState
X2_TOPIC_JOINTS=/joint_states
X2_TYPE_JOINTS=sensor_msgs/msg/JointState
X2_TOPIC_ODOM=/odom
X2_TYPE_ODOM=nav_msgs/msg/Odometry
# Must match the robot's domain or ROS 2 discovery silently sees nothing.
ROS_DOMAIN_ID=0
# ── field mapping (applies to EVERY backend) ─────────────────────────────────
# Dotted paths into the message/JSON. They work over both ROS message objects
# and plain dicts, support list indices ("bms.cell_temp[0]"), and yield null for
# any missing link — a wrong path degrades a field, it never crashes the agent.
# Defaults below are the STANDARD ROS messages: correct for a stock ros2 setup,
# almost certainly wrong for a vendor http payload. Empty = field unavailable.
X2_FIELD_SOC=percentage
X2_FIELD_VOLTAGE=voltage
X2_FIELD_CURRENT=current
X2_FIELD_TEMP=temperature
X2_FIELD_SOH=
X2_FIELD_CYCLES=
# Standard sensor_msgs/JointState has NO temperature array — leave empty unless
# the X2 publishes a custom message that carries one (motor_temp reports null).
X2_FIELD_TEMPS=
# Joint velocities → the "moving" status (max |velocity| > 0.15).
X2_FIELD_VEL=velocity
X2_FIELD_X=pose.pose.position.x
X2_FIELD_Y=pose.pose.position.y
# http backend only: an FSM id and a {name: version} firmware dict, if exposed.
X2_FIELD_FSM=
X2_FIELD_FW=
# ── unit conventions (verify these on the real robot!) ───────────────────────
# auto = a value in (0, 1] is treated as a 0..1 fraction and scaled to %.
# fraction = always scale x100. percent = never scale.
X2_SOC_SCALE=auto
# +1 = positive current means CHARGING (the ROS BatteryState convention).
# Set -1 if the X2 reports the opposite sign, or "charging" will be inverted.
X2_CURRENT_SIGN=1
# The telemetry schema is VOLTS and AMPS. BMS firmware often publishes mV/mA
# instead — set 0.001 for those, so battery_detail isn't off by 1000x.
X2_VOLTAGE_SCALE=1
X2_CURRENT_SCALE=1
# ── position / status ────────────────────────────────────────────────────────
# none | ros2 | rosbridge. ros2 is INDEPENDENT of X2_SOURCE: the Control Dash
# carries no odometry, but ROS publishes it — so http battery + ros2 position
# run side by side. Needs rclpy, i.e. the unit must source the ROS overlay.
# On the X2 as deployed: /aima/mc/leg_odometry (nav_msgs/msg/Odometry).
X2_POSITION_SOURCE=ros2
ROSBRIDGE_URL=ws://127.0.0.1:9090
# Subscription reliability. MUST stay best_effort unless you know otherwise:
# the X2 publishes /aima/mc/leg_odometry as BEST_EFFORT, and a RELIABLE
# subscriber receives NOTHING from a BEST_EFFORT publisher — the subscription
# is created, no error is raised, and position silently stays null forever.
# A BEST_EFFORT subscriber reads from either kind of publisher.
X2_ROS_QOS=best_effort
# There is no known X2 loco FSM RPC — leave 0.
X2_READ_FSM=0
# ── identity / networking ────────────────────────────────────────────────────
# Which NIC's MAC is reported as the robot identity.
MAC_INTERFACE=eth0
# ── fault thresholds ─────────────────────────────────────────────────────────
LOW_SOC=50
# Only meaningful when X2_FIELD_TEMPS is mapped; otherwise motor_temp is null.
MOTOR_TEMP_MAX=85
# ── cadence / transport ──────────────────────────────────────────────────────
POLL_INTERVAL=2
VERIFY_TLS=1
HTTP_TIMEOUT=30
# ── map sync (uploaded ONCE per content; status shown in telemetry "map") ────
# pgm+yaml sets are rendered to PNG with pure stdlib; RTAB-Map .db is sent
# as-is (skipped above the server's upload cap).
MAPS_DIR=/data/maps
EXTRA_MAP_DIRS=/data/nav2_maps
DATA_DIR=/data/web_data
STATE_DIR=/data/state
MAP_SELECT=all
MAP_UPLOAD_MODE=multipart
MAP_POLL_INTERVAL=30
MAP_MAX_UPLOAD_MB=7
# ── logs + alerts ────────────────────────────────────────────────────────────
LOGS_INTERVAL=60
# auto = find a RUNNING sanad* container via the /host mount and tail its
# json-log. If the X2 doesn't run the Sanad app, telemetry.project_logs is null
# and only the agent's own lines ship — no configuration needed.
PROJECT_LOG_CONTAINER=auto
PROJECT_LOG_PATH=
PROJECT_LOG_LABEL=
ALERT_SCAN_INTERVAL=10
ALERT_LOG_COOLDOWN=300
# ── remote (register a dashboard URL + ssh for the fleet UI) ─────────────────
# If the X2 exposes no dashboard, remote.web is null and only ssh registers.
REMOTE_ENABLE=1
REMOTE_PORTS=8001,8014,8011,8012,8013,8000,8080
REMOTE_KIND=web
REMOTE_INTERVAL=60
SSH_REGISTER=1
# The X2's LOGIN user. The installer writes the user it deployed with; set it
# here for manual runs.
# Left empty, ssh registration is skipped rather than registering a wrong user.
SSH_USER=
# ── control panel (READ-ONLY; remote mode-SWITCH is off by design) ───────────
# Blank URL = use the auto-discovered dashboard port. No known X2 FSM id map, so
# control.mode reports "unknown" until the ids are filled into the agent.
CONTROL_STATUS_URL=
CONTROL_ENABLE=0

174
agent/AGENT_README.md Normal file
View File

@ -0,0 +1,174 @@
# 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 <ip> --user <ssh-user> # or: tools/probe_x2.sh <ip> <ssh-user>
```
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 <ip> --sn <serial> \
--token <device-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 <ip> --sn ...
```
**3. Map the fields** you found, then restart:
```bash
ssh <user>@<ip> 'nano ~/sanad_api_x2/.env'
ssh <user>@<ip> 'systemctl --user restart sanad-api-x2'
./fleet_install.sh data x2 <ip>
```
**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` (01 source) or `percent` (0100 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 <user>@<ip>` 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.

43
agent/Dockerfile Normal file
View File

@ -0,0 +1,43 @@
# sanad_api_x2 — AGIBOT X2 fleet agent: TELEMETRY + MAP sync.
#
# BUILD ON THE X2 COMPUTE (native arm64). There is no middleware to compile into
# the image: the state source is chosen at RUNTIME
# (X2_SOURCE=http|ros2|aimrt|none), so the image only needs whatever the chosen
# backend requires. That is what BASE_IMAGE selects:
#
# http / none backend (default) — tiny, no ROS at all:
# docker build -t sanad-api-x2:latest .
#
# ros2 backend — rclpy comes from the ROS base; match the ROBOT's distro:
# docker build --build-arg BASE_IMAGE=ros:humble-ros-base -t sanad-api-x2:latest .
# (or ros:foxy-ros-base, ros:jazzy-ros-base, … — must match the robot, or
# ROS 2 discovery silently fails to see the robot's topics)
#
# Run with --network host so the robot's middleware traffic is visible (ROS 2
# discovery does not cross a NAT bridge) and the agent can reach vendor APIs on
# 127.0.0.1 (the installer does this).
ARG BASE_IMAGE=python:3.10-slim-bookworm
FROM ${BASE_IMAGE}
# iproute2 for iface checks; ca-certificates for outbound HTTPS to the fleet
# server. Kept minimal — the ROS bases already carry python3 + rclpy.
RUN apt-get update && apt-get install -y --no-install-recommends \
iproute2 ca-certificates python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
# ROS bases ship a Debian-managed python (PEP 668), so retry with
# --break-system-packages rather than failing the build.
RUN python3 -m pip install --no-cache-dir -r requirements.txt \
|| python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt
COPY sanad_api_x2.py entrypoint.sh ./
RUN chmod +x entrypoint.sh
ENV PYTHONUNBUFFERED=1 \
X2_SOURCE=auto \
MAC_INTERFACE=eth0
# entrypoint.sh sources a ROS overlay when the base has one (no-op otherwise),
# then execs the agent — so ONE image definition serves every backend.
ENTRYPOINT ["/app/entrypoint.sh"]

19
agent/entrypoint.sh Normal file
View File

@ -0,0 +1,19 @@
#!/bin/sh
# sanad_api_x2 entrypoint.
#
# The X2 agent runs on either a slim python base (http backend) or a ROS 2 base
# (ros2 backend). rclpy is only importable after the ROS overlay is sourced, and
# sourcing it must NOT be a hard requirement — so: source it if it exists, carry
# on if it doesn't. One image definition, every backend.
set -e
if [ -z "${ROS_DISTRO:-}" ] && [ -d /opt/ros ]; then
ROS_DISTRO="$(ls /opt/ros 2>/dev/null | head -1)"
fi
if [ -n "${ROS_DISTRO:-}" ] && [ -f "/opt/ros/${ROS_DISTRO}/setup.sh" ]; then
# shellcheck disable=SC1090
. "/opt/ros/${ROS_DISTRO}/setup.sh"
echo "entrypoint: sourced ROS ${ROS_DISTRO} (rclpy available)" >&2
fi
exec python3 -u /app/sanad_api_x2.py "$@"

10
agent/requirements.txt Normal file
View File

@ -0,0 +1,10 @@
# sanad_api_x2 — AGIBOT X2 fleet agent.
requests>=2.31,<3
# only needed if X2_POSITION_SOURCE=rosbridge (reads /odom over a websocket):
websocket-client>=1.6,<2
# NOT installed here:
# rclpy — comes from the ROS base image (see Dockerfile BASE_IMAGE); it is
# not on PyPI and must match the robot's ROS distro.
# aimrt_py — AgiBot's runtime, installed with their stack if you use it.
# The agent degrades to heartbeat mode when no state source is reachable, so a
# missing backend never stops telemetry.

2199
agent/sanad_api_x2.py Normal file

File diff suppressed because it is too large Load Diff

190
docs/X2_INTERFACE.md Normal file
View File

@ -0,0 +1,190 @@
# AGIBOT X2 — what the robot actually exposes
Everything here was read off the live robot (`10.255.254.84`) with
`tools/probe_x2.sh` and `ros2` introspection. It is the reference for *why* the
`.env` mapping looks the way it does, so nobody has to rediscover it.
---
## 1. Platform
| | |
|---|---|
| Board | NVIDIA Jetson Orin NX Engineering Reference Developer Kit Super V2Board |
| Arch / OS | aarch64 · Ubuntu 22.04.5 LTS · kernel 5.15.148-tegra |
| L4T | R36.4.3 |
| Python | 3.10.12 (`/usr/bin/python3`) |
| ROS | Humble, `ROS_DOMAIN_ID=0` |
| Docker | 27.5.1 installed, daemon **inactive + masked** (no passwordless sudo) |
| systemd user bus | available · `linger=no` (polkit denies enabling it unprivileged) |
### Network interfaces
| iface | address | note |
|---|---|---|
| `wifi0` | `10.255.254.84` | fleet-reachable → `MAC_INTERFACE=wifi0` |
| `develop0` | `10.0.1.41` | |
| `sensor0` | `10.11.1.1` | |
| `ssh0` | `10.0.200.41` | |
### Serial number
`X230028C5Z0058` — the AgiBot device serial, found in `/home/agi/agibot_report.py`.
Prefer it over `/proc/device-tree/serial-number` (`1421126035442`) and over
`/etc/machine-id`, which are board identifiers rather than the robot's identity.
---
## 2. The Control Dash — the practical state source
A dashboard listens on **`:8770`** and serves a single JSON object at
**`/api/state`**. This is the cleanest source on the robot: battery is already
percent / volts / amps, no protobuf decoding, no ROS dependency.
```json
{
"mode": "STAND_DEFAULT", "mode_desc": "Stable stand", "mode_status": "Running",
"battery_pct": 68.0, "battery_voltage": 51.996, "battery_current": 3.544,
"battery_temp": 41.6, "battery_cycles": 14, "charging": true,
"pmu_temp": 40.17, "fan_rpm": 6960.0, "fan_pct": 68,
"rails": { "bus_48v": {...}, "output_48v": {...}, "output_12v": {...},
"head_power": {...}, "orin": {...}, "rk3588": {...} },
"imu": { "chest": { "roll":…, "pitch":…, "yaw":…, "accel_x":…, "gyro_x":… },
"torso": { … } },
"joints": { "head": [2], "waist": [3], "arm": [14], "leg": [12] },
"hand_type": "None", "hand_state": { "left": [], "right": [] },
"connection": { "online": true, "transport": "agent", "host": "127.0.0.1:8781",
"ros_domain_id": 0, "uptime_s": … }
}
```
Each `joints.<group>[]` element is
`{name, position, velocity, effort, error}` — **an array of objects, not
numbers**. That is why the velocity mapping needs the wildcard form
`joints.leg[*].velocity`; a plain path would resolve to a list of dicts and
silently yield nothing.
**Units confirmed on hardware:** `battery_pct` is already 0100 (→
`X2_SOC_SCALE=percent`), voltage is volts and current is amps (→ both scales
`1`), and a **positive** `battery_current` accompanies `charging: true` (→
`X2_CURRENT_SIGN=1`).
Other listening ports: `8781` (agent transport, no HTTP), `50080`, `21274/21275`,
`39101`, `11511` — none served a usable state document.
---
## 3. ROS 2 topics
The X2 runs AgiBot's **`aima`** stack. `ros2 topic list` shows ~70 topics; the
telemetry-relevant ones:
| topic | type | use |
|---|---|---|
| `/aima/mc/leg_odometry` | `nav_msgs/msg/Odometry` | **position — in use** |
| `/slam/localization/odometry` | `nav_msgs/msg/Odometry` | not publishing |
| `/pnc/estimate_odom` | `nav_msgs/msg/Odometry` | not publishing |
| `/aima/hal/pmu/state` | `aimdk_msgs/msg/PmuState` | voltages + currents, **no SOC** |
| `/aima/battery_state/pb_3Aaimdk_2Eprotocol_2EBmsState` | `ros2_plugin_proto/msg/RosMsgWrapper` | protobuf-wrapped BMS — opaque |
| `/aima/hal/joint/{leg,arm,head,waist}/state` | `aimdk_msgs/msg/JointStateArray` | joint state |
| `/aima/hal/imu/{chest,torso}/state` | `sensor_msgs/msg/Imu` | |
| `/aima/sm/system_state` | `aimdk_msgs/msg/SmSystemState` | |
### ⚠ QoS — the trap that costs an afternoon
`/aima/mc/leg_odometry` publishes with:
```
Reliability: BEST_EFFORT
Durability: TRANSIENT_LOCAL
```
rclpy defaults subscribers to **`RELIABLE`**, which is **incompatible** with a
`BEST_EFFORT` publisher. The failure mode is silent: the subscription is
created, no exception is raised, no warning is logged — messages simply never
arrive and the field stays `null` forever. Meanwhile `ros2 topic echo` works
fine, so the topic looks healthy.
A `BEST_EFFORT` subscriber can read from **either** publisher type, so the agent
defaults to it (`X2_ROS_QOS=best_effort`). Only set `reliable` if a specific
topic demands it.
### `aimdk_msgs` is not in base ROS
It lives in an overlay. To introspect these types:
```bash
source /opt/ros/humble/setup.bash
source /home/agi/aimdk/install/local_setup.bash # ← required for aimdk_msgs
ros2 interface show aimdk_msgs/msg/PmuState
```
Note `ros2 interface show` fails for a few types (`JointNoRealTimeStateArray`,
`ros2_plugin_proto/msg/RosMsgWrapper`) — their `.idl` isn't installed. Read the
`.msg` files under
`/home/agi/aimdk/install/aimdk_msgs/share/aimdk_msgs/msg/` instead.
Sourcing `setup.bash` under `set -u` **aborts** — it reads unbound variables
(`AMENT_TRACE_SETUP_FILES`, `COLCON_TRACE`). Always `set +u` first. This is why
the systemd unit and the probe script both do so.
---
## 4. Why `motor_temp` is permanently `null`
Searching every message definition in the overlay:
```bash
grep -rl -i 'temp' /home/agi/aimdk/install/aimdk_msgs/share/aimdk_msgs/msg/*.msg
# -> PmuState.msg (only this one)
```
`PmuState` carries `battery_voltage`, six rail currents, and an over-temperature
*status bit* — it is the **power unit**, not the motors. The per-joint message
is:
```
aimdk_msgs/msg/JointState:
string name
float64 position
float64 velocity
float64 effort
uint16 error_code
```
No temperature field anywhere. The X2 does not publish per-motor temperatures,
so `motor_temp` reports `null`. Putting `pmu_temp` in that field would be
fabricating data.
---
## 5. Alternative: full ROS 2 backend
If the Control Dash is ever unavailable, the agent can read state over ROS 2
instead (`X2_SOURCE=ros2`). Caveats:
- Battery **SOC is not available over ROS** in plain form — `PmuState` has
voltage/current only, and the real BMS arrives protobuf-wrapped inside
`ros2_plugin_proto/msg/RosMsgWrapper`, which needs AgiBot's protobuf
definitions to decode.
- Custom types must be importable inside whatever runs the agent, i.e. the
`aimdk` overlay must be sourced.
- The same `X2_ROS_QOS=best_effort` rule applies to every subscription.
For those reasons **http remains the recommended state source**, with ROS 2 used
only for position.
---
## 6. AimRT
`aimrt_py` **is** installed
(`/usr/local/lib/python3.10/dist-packages/aimrt_py/`), and AgiBot's open-source
X1 stack is built on AimRT — so it is very likely the underlying middleware here.
The agent deliberately does **not** bind AimRT channels natively: the X2's
channel names and message definitions are not public, and guessing them yields
an agent that imports cleanly, builds, and heartbeats `battery: null` forever —
a failure only visible on the robot. AimRT ships a ROS 2 plugin, and the bridged
topics above are what the agent uses instead.

159
tools/probe_x2.sh Normal file
View File

@ -0,0 +1,159 @@
#!/usr/bin/env bash
# probe_x2.sh — read-only discovery for an AGIBOT X2 (or any unknown robot).
#
# tools/probe_x2.sh <ip> <ssh-user>
# ./fleet_install.sh probe x2 <ip> --user <ssh-user> # same, via the installer
#
# WHY THIS EXISTS
# The X2 exposes no fixed topic contract, so sanad_api_x2.py reads every topic
# name and field path from .env instead of hard-coding them. This script finds
# the real values to put there.
#
# It ONLY READS: no install, no write, no package manager, no motion. Safe to run
# on a live robot. Everything it prints maps to a variable in agents/x2/.env.example.
set -uo pipefail
# No default login user is assumed for the X2 — its login account is a property
# of the robot, not something to guess. Pass it explicitly.
IP="${1:-}"; USER_="${2:-}"
[ -n "$IP" ] && [ -n "$USER_" ] \
|| { echo "usage: $0 <ip> <ssh-user>" >&2
echo " the X2's login user is not assumed — pass the account you SSH in as" >&2
exit 1; }
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new)
[ -n "${FLEET_SSH_KEY:-}" ] && SSH_OPTS+=(-i "$FLEET_SSH_KEY")
ssh -n "${SSH_OPTS[@]}" "$USER_@$IP" true 2>/dev/null \
|| { echo "ERROR: cannot SSH to $USER_@$IP (key auth?)" >&2; exit 1; }
echo "=============================================================="
echo " AGIBOT X2 probe — $USER_@$IP"
echo "=============================================================="
# NOTE: no -n here. The probe body is piped in on stdin, and -n would redirect
# stdin from /dev/null, so `bash -s` would read an empty script and print nothing.
ssh "${SSH_OPTS[@]}" "$USER_@$IP" 'bash -s' <<'REMOTE'
set -uo pipefail
sec(){ echo; echo "── $* ──────────────────────────────────────────"; }
have(){ command -v "$1" >/dev/null 2>&1; }
sec "1. PLATFORM -> software.* / firmware.* in telemetry"
. /etc/os-release 2>/dev/null && echo "os: ${PRETTY_NAME:-?} (VERSION_ID=${VERSION_ID:-?})"
echo "kernel: $(uname -r)"
echo "arch: $(uname -m) # arm64 => build natively on the robot"
for f in /sys/firmware/devicetree/base/model /proc/device-tree/model; do
[ -r "$f" ] && { echo "board: $(tr -d '\0' < "$f")"; break; }
done
[ -r /etc/nv_tegra_release ] && echo "l4t: $(head -1 /etc/nv_tegra_release)"
echo "python3: $(python3 -V 2>&1)"
sec "2. DEPLOY PREREQS -> can fleet_install.sh work here?"
if have docker; then
echo "docker: $(docker --version 2>&1)"
docker ps >/dev/null 2>&1 \
&& echo " OK '$USER' can talk to dockerd (no sudo needed)" \
|| echo " !! '$USER' CANNOT use docker — add to the docker group"
else
echo "docker: !! NOT INSTALLED — required"
fi
systemctl --user show-environment >/dev/null 2>&1 \
&& echo "systemd: OK user bus available (user-level service, no sudo)" \
|| echo "systemd: !! no user bus — auto-start needs another mechanism"
echo "linger: $(loginctl show-user "$USER" 2>/dev/null | grep -i linger || echo '?')"
sec "3. NETWORK -> MAC_INTERFACE, REMOTE_HOST"
ip -o -4 addr show 2>/dev/null | awk '{printf " %-10s %s\n", $2, $4}'
echo " MACs:"
for n in /sys/class/net/*; do
i="$(basename "$n")"; [ "$i" = lo ] && continue
echo " $(printf '%-10s' "$i") $(cat "$n/address" 2>/dev/null)"
done
sec "4. SERIAL NUMBER -> SN (keys the robot on the fleet server)"
for f in /sys/class/dmi/id/product_serial /proc/device-tree/serial-number \
/etc/machine-id /etc/robot_sn /etc/agibot/sn; do
[ -r "$f" ] && echo " $f = $(tr -d '\0' < "$f" 2>/dev/null | head -1)"
done
echo " (prefer the serial printed on the robot / in AgiBot's tooling over machine-id)"
sec "5. ROS 2 -> X2_SOURCE=ros2, X2_TOPIC_*, ROS_DOMAIN_ID"
if [ -d /opt/ros ]; then
echo "distros: $(ls /opt/ros 2>/dev/null | tr '\n' ' ')"
D="${ROS_DISTRO:-$(ls /opt/ros 2>/dev/null | head -1)}"
echo "using: $D ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0}"
if [ -f "/opt/ros/$D/setup.bash" ]; then
# ROS setup.bash reads unbound vars (AMENT_TRACE_SETUP_FILES, COLCON_TRACE,
# …). Under `set -u` that is FATAL and kills this whole probe silently, so
# relax nounset across the source and restore it after.
set +u
# shellcheck disable=SC1090
. "/opt/ros/$D/setup.bash" 2>/dev/null || true
echo "--- ros2 topic list (5s) ---"
timeout 5 ros2 topic list 2>/dev/null | sed 's/^/ /' || echo " (none / timed out)"
echo "--- types for likely telemetry topics ---"
for t in $(timeout 5 ros2 topic list 2>/dev/null | grep -Ei 'batt|power|bms|joint|motor|odom|imu|state|fsm|mode'); do
printf " %-42s %s\n" "$t" "$(timeout 3 ros2 topic info "$t" 2>/dev/null | awk -F': ' '/Type/{print $2; exit}')"
done
set -u
fi
else
echo " no /opt/ros — the ros2 backend is unavailable unless AgiBot ships one"
fi
sec "6. AIMRT / VENDOR SDK -> X2_SOURCE=aimrt (or the ros2 bridge)"
python3 -c 'import aimrt_py, sys; print(" aimrt_py importable:", aimrt_py.__file__)' 2>/dev/null \
|| echo " aimrt_py not importable for this python3"
ls -d /opt/aimrt /opt/agibot /usr/local/aimrt ~/aimrt* ~/agibot* 2>/dev/null | sed 's/^/ path: /'
python3 -c 'import agibot, sys; print(" agibot module:", agibot.__file__)' 2>/dev/null || true
sec "7. LISTENING PORTS -> X2_STATE_URL (http backend), REMOTE_PORTS"
(ss -lntp 2>/dev/null || netstat -lntp 2>/dev/null) | sed 's/^/ /' | head -30
echo " probe a candidate: curl -s http://127.0.0.1:<port>/ | head -c 400"
sec "8. RUNNING WORKLOAD -> PROJECT_LOG_CONTAINER, what publishes state"
have docker && docker ps --format ' {{.Names}} {{.Status}} ({{.Image}})' 2>/dev/null
echo " top processes:"
ps -eo comm,pcpu --sort=-pcpu 2>/dev/null | head -12 | sed 's/^/ /'
sec "9. MAPS -> MAPS_DIR / EXTRA_MAP_DIRS"
find "$HOME" -maxdepth 5 \( -name '*.pgm' -o -name 'map*.yaml' -o -name '*.db' -o -name '*.posegraph' \) \
2>/dev/null | head -20 | sed 's/^/ /'
echo " (the agent wants the DIRECTORY holding a matching .pgm + .yaml pair, or a .db)"
sec "10. DISK -> storage.* in telemetry"
df -h / 2>/dev/null | sed 's/^/ /'
REMOTE
cat <<'NEXT'
==============================================================
NEXT STEPS
==============================================================
1. Pick a backend from what appeared above:
section 5 found battery/joint/odom topics -> X2_SOURCE=ros2
section 7 found a vendor JSON API -> X2_SOURCE=http
neither -> X2_SOURCE=none
(identity + maps + logs
still work; battery null)
2. Install with the real serial from section 4:
./fleet_install.sh install x2 <ip> --sn <serial> \
--token <device-token> --server-url https://eco.yslootahrobotics.com
ros2 backend? build against a ROS base matching the robot's distro:
X2_BASE_IMAGE=ros:humble-ros-base ./fleet_install.sh install x2 <ip> ...
3. Put the discovered names in the robot's .env and restart:
ssh <user>@<ip> 'nano ~/sanad_api_x2/.env' # X2_TOPIC_* / X2_FIELD_*
ssh <user>@<ip> 'systemctl --user restart sanad-api-x2'
4. Confirm what it is actually sending:
./fleet_install.sh data x2 <ip>
5. VERIFY THE TWO UNIT CONVENTIONS on real hardware:
- battery % looks right -> else set X2_SOC_SCALE=fraction|percent
- "charging" true while charging -> else set X2_CURRENT_SIGN=-1
NEXT