263 lines
9.7 KiB
Markdown
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.
|