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

9.7 KiB

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:

# /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:

<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:

"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:

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:

"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:

{"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:

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:

"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) wlP1p1s010.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.