engineai_fleet/docs/PM01_INTERFACE.md
2026-08-27 16:24:12 +04:00

263 lines
9.7 KiB
Markdown

# What the EngineAI PM01 actually exposes
Everything here was read off the live robot at `10.210.136.150` on 2026-08-27,
not from a datasheet. Each section names the `.env` variable it feeds.
---
## 1. The ROS 2 environment — the part that silently breaks
The PM01's stack does **not** run on the default ROS domain, and it does not use
the default DDS transport:
```bash
# /app/applications/install/bringup/ros_env.sh (the robot's own file)
export ROS_DOMAIN_ID=69
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export CYCLONEDDS_URI=file:///app/applications/install/bringup/cyclonedds.xml
source /opt/ros/humble/setup.bash
source /app/applications/install/setup.bash --extend
source /app/applications/install/bringup/product.env # PRODUCT=t800
```
and the CycloneDDS config pins discovery to one NIC:
```xml
<NetworkInterface name="eth1" /> <!-- 192.168.0.162 -->
<Peer address="192.168.0.163" /> <!-- the motion controller -->
```
Source only `/opt/ros/humble/setup.bash` and `ros2 topic list` returns **two**
topics (`/parameter_events`, `/rosout`) — no error, just an empty robot. Source
`ros_env.sh` and the same command returns **44**.
That is why the systemd unit sources `ros_env.sh` rather than restating
`ROS_DOMAIN_ID=69` itself: if EngineAI ever changes the domain, the interface
pinning or the peer address, the agent follows automatically.
`set +u` is required before sourcing — ROS's `setup.bash` reads unbound
variables and aborts the unit under `set -u`.
---
## 2. Topics the agent reads
All four are published continuously by the robot's own stack, verified with
`ros2 topic hz`:
| topic | type | rate | feeds |
|---|---|---|---|
| `/hardware/power_info` | `interface_protocol/msg/PowerInfo` | 20 Hz | `battery`, `charging`, `battery_detail` |
| `/hardware/motor_debug` | `interface_protocol/msg/MotorDebug` | 100 Hz | `motor_temp`, motor faults |
| `/hardware/joint_state` | `interface_protocol/msg/JointState` | 500 Hz | `status: moving` |
| `/motion/motion_state` | `interface_protocol/msg/MotionState` | 5 Hz | `control.mode`, `control.switchable_modes` |
The agent **subscribes only**. It never publishes, never calls a service, and
never requests a motion transition.
### The message definitions
```
# PowerInfo # MotorDebug
bool enable float32[] mos_temperature
float32 percentage float32[] motor_temperature
float32 voltage float32[] voltage
float32 current float32[] current
float32 current_limit int32[] error_code
int32 error_code uint8[] offline
uint8[] enable
# JointState # MotionState
std_msgs/Header header string current_motion_task
float64[] position string[] available_transition_motions
float64[] velocity
float64[] torque
```
### Rate decimation is not optional
`joint_state` at 500 Hz and `motor_debug` at 100 Hz mean 600 Python callbacks
per second on the robot's own Jetson — for values the agent resamples once every
2 s. `ENG_JOINT_MIN_PERIOD=0.05` and `ENG_MOTOR_MIN_PERIOD=0.2` decimate them to
20 Hz and 5 Hz. A monitoring agent must not tax the machine it monitors.
### QoS must stay `best_effort`
A `RELIABLE` subscriber receives **nothing** from a `BEST_EFFORT` publisher —
the subscription is created, no error is raised, and the field stays null
forever. A `BEST_EFFORT` subscriber reads from either kind, so
`ENG_ROS_QOS=best_effort` is the only setting that cannot silently fail.
---
## 3. Battery — the sign convention is inverted
`PowerInfo.percentage` is already `0..100`, so `ENG_SOC_SCALE=percent`.
The current sign is the trap. Measured over 15 minutes with the robot sitting
idle and **not** on a charger:
```
19:35:52 percentage: 27.0 voltage: 54.744 current: 1.865
19:36:18 percentage: 26.0 voltage: 54.693 current: 1.766
19:37:38 percentage: 26.0 voltage: 54.582 current: 1.703
19:52:16 percentage: 22.0 voltage: 53.3 current: 2.01
```
Percentage falls, voltage falls, **current stays positive**. So on the PM01
positive current means *discharging* — the opposite of the ROS `BatteryState`
convention the agent's default assumes.
Hence **`ENG_CURRENT_SIGN=-1`**. With the default `+1` the robot would report
`charging: true` and `status: "charging"` permanently while its battery drained
to zero — the failure would look like healthy telemetry, which is exactly the
kind of bug that survives review.
`PowerInfo` carries **no** pack temperature, state-of-health or cycle count, so
`battery_detail.temp_c` is null and `soh`/`cycles` are 0. Those fields are left
unmapped rather than pointed at a plausible-looking wrong field.
---
## 4. Motor temperature — real here, unlike the X2
The X2 agent reports `motor_temp: null` because that robot publishes no
per-motor temperature at all. The PM01 publishes **25 motor temperatures and 25
driver MOSFET temperatures at 100 Hz**, so this field carries real data:
```json
"motor_temp": { "max": 55.1, "avg": 29.5, "min": 24.0,
"count": 25, "mos_max": 45.6, "mos_avg": 29.9 }
```
Readings outside `0 < t <= 200 °C` are dropped: `0.0` means "slot not
reporting", and averaging it in would drag the fleet-wide average down and hide
a genuinely hot joint.
`MotorDebug` also carries per-motor `error_code[]` and `offline[]`, which become
the `MOTOR_FAULT` and `MOTOR_OFFLINE` alerts — real hardware fault channels the
X2 had no equivalent for.
---
## 5. Motion mode — the robot names its own state
The X2 had no documented FSM id scheme, so its `control.mode` reported
`"unknown"`. The PM01 publishes the mode **as a string**, together with the
exact set of transitions it will currently accept:
```yaml
current_motion_task: pd_sitdown
available_transition_motions:
- passive
- rl_mimic_sitdown_to_stance
```
which maps directly onto the telemetry `control` block with no lookup table to
guess:
```json
"control": { "mode": "pd_sitdown",
"switchable_modes": ["passive", "rl_mimic_sitdown_to_stance"],
"remote_switch_enabled": false,
"source": "ros2:/motion/motion_state" }
```
Observed task names so far: `passive`, `idle`, `pd_sitdown`,
`rl_mimic_sitdown_to_stance`, `rl_amp`, `rl_basic` (the last two from
`robot_manager`'s `notifier.yaml`).
`remote_switch_enabled` is `false` and `CONTROL_ENABLE=0`: the transitions are
**reported, never requested**. Switching motion mode on a humanoid is a motion
command, and this agent is read-only by design.
---
## 6. Position — genuinely unavailable
There is **no odometry topic on this robot**. `ros2 topic list` shows no
`/odom`, no `/tf`, no `amcl_pose`. The PM01's motion stack is a whole-body
controller, not a navigation stack: `/motion/data_monitor/base/*` carries yaw
and pelvis velocity for gait control, but nothing integrates a world pose.
The Sanad app's nav module is present but not running:
```json
{"bringup_alive": false, "rosbridge_alive": false, "reachable": false,
"mode": null, "active_map": null, "mode_label": "IDLE"}
```
So `ENG_POSITION_SOURCE=none` and `position` reports `null`. **Null means "not
available", never `{x: 0, y: 0}`** — a fabricated origin would put the robot at
the map corner on the fleet dashboard and look like real data.
Three ways to turn it on the day localisation runs, all `.env`-only:
```ini
ENG_POSITION_SOURCE=ros2 # + ENG_TOPIC_ODOM=/odom
ENG_POSITION_SOURCE=http # reads the Sanad /api/nav/status pose
ENG_POSITION_SOURCE=rosbridge # reads /odom over the rosbridge websocket
```
---
## 7. Maps
No saved maps exist on this robot: no `.pgm`/`.yaml` set and no RTAB-Map `.db`
anywhere under the Sanad data dir, and `/api/nav/maps` returns `[]`. The map
field therefore reports:
```json
"map": { "uploaded": false, "state": "no_map", "maps_found": 0,
"error": "no saved map found (maps_dir=…, robot=sanad)" }
```
The scanner is live and unchanged from the X2 agent — the moment a map is saved
under `MAPS_DIR` it is rendered to PNG and uploaded once per server.
---
## 8. Host / platform
| | |
|---|---|
| Board | NVIDIA Jetson AGX Orin Developer Kit |
| Board serial | `1421326045624` |
| L4T | R36.4.3, kernel `5.15.148-6-engine-tegra` |
| OS | Ubuntu 22.04.5 LTS, Python 3.10.12 |
| ROS | Humble, domain 69, CycloneDDS on `eth1` |
| Product tag | `t800` (config dir `pm01`) |
| Disk | 250.6 GB total, ~201 GB free |
| NIC (identity) | `wlP1p1s0``10.210.136.150`, MAC `6c:d5:52:cc:73:c4` |
| Other NICs | `eno1` 192.168.100.162, `eth1` 192.168.0.162 (DDS) |
| Timezone | **Asia/Shanghai** — the clock is correct in UTC, but local time reads +8. `TZ_OFFSET_HOURS=4` renders Dubai time in the `time` field. |
### Services on the robot
| port | what |
|---|---|
| 8014 | **Sanad Dashboard** (container `sanad-t8`) — registered as the fleet remote URL |
| 9001 | supervisord web UI |
| 9002 | EngineAI dashboard node |
| 9003 | Foxglove Studio (caddy) |
| 9004 | code-server |
| 8765 | foxglove_bridge |
The robot's own ROS apps run under **supervisord** (`/etc/supervisor/conf.d/ros_apps.conf`),
not systemd. The fleet agent deliberately does not join that group: a crash or
restart of the agent must never be able to take the robot's motion stack with it.
---
## 9. Project logs need root
The Sanad app runs in the docker container `sanad-t8`, and its log is at
`/var/lib/docker/containers/<id>/<id>-json.log` — root-owned, mode 600. The
`ubuntu` user gets `EACCES` and `project_logs` would stay null forever.
This is one of the two reasons the agent runs as a **root system service**
rather than a user service. The other is reboot survival: `Linger=no` on this
image and polkit denies `loginctl enable-linger` to a non-root user, so a
`--user` unit would not come back after a power cycle — the open item still
outstanding on the X2 deployment.