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