Update 2026-08-04 15:14
This commit is contained in:
commit
2757229abc
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
state/
|
||||
maps/
|
||||
230
README.md
Normal file
230
README.md
Normal 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
5
agent/.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
README.md
|
||||
166
agent/.env.example
Normal file
166
agent/.env.example
Normal 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
174
agent/AGENT_README.md
Normal 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` (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.
|
||||
43
agent/Dockerfile
Normal file
43
agent/Dockerfile
Normal 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
19
agent/entrypoint.sh
Normal 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
10
agent/requirements.txt
Normal 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
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
190
docs/X2_INTERFACE.md
Normal 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 0–100 (→
|
||||
`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
159
tools/probe_x2.sh
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user