# 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 ``` 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//-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.