175 lines
7.2 KiB
Markdown
175 lines
7.2 KiB
Markdown
# 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` (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 <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.
|