# sanad_api_eng — EngineAI PM01. Copy to .env and fill in. # ONE agent = telemetry + map + logs + alerts + remote, posted to EVERY server. # # Every topic name and field path used to read robot state is an env var in this # file. Nothing is hard-coded in the agent. Discover the real ones with: # # bash tools/probe_eng.sh 10.210.136.150 ubuntu # # then set them below and restart — no code change, no rebuild. # ═════════════════════════════════════════════════════════════════════════════ # FLEET SERVERS — the same payload is posted to EVERY enabled server # ═════════════════════════════════════════════════════════════════════════════ # Each server has its OWN token. eco and eco-dev maintain INDEPENDENT token # stores: a token minted on one is rejected with 401 by the other. Reusing a # single token across both silently 401s forever on whichever server did not # issue it — which is why the token is per-slot, not global. # ── server 1 (primary) ─────────────────────────────────────────────────────── SERVER_URL=https://eco-dev.yslootahrobotics.com DEVICE_TOKEN=REPLACE_WITH_ECO_DEV_TOKEN SERVER_NAME=eco-dev SERVER_ENABLE=1 # ── server 2 ───────────────────────────────────────────────────────────────── # Configured and ready. Paste the token eco issues for this robot and flip # SERVER_2_ENABLE to 1 — that is the whole change, then restart the service. SERVER_2_URL=https://eco.yslootahrobotics.com SERVER_2_TOKEN= SERVER_2_NAME=eco SERVER_2_ENABLE=0 # Set to 1 ONLY if eco is ever configured to accept the primary DEVICE_TOKEN. SERVER_2_SHARE_TOKEN=0 # ── servers 3..5 (unused; same three keys each) ────────────────────────────── #SERVER_3_URL= #SERVER_3_TOKEN= #SERVER_3_ENABLE=0 # ── identity ───────────────────────────────────────────────────────────────── # The robot's serial — keys the robot on the server ({sn} routes). Changing it # later creates a SECOND robot entry on the server rather than renaming this one. SN=REPLACE_WITH_PM01_SERIAL # Friendly display name shown on the dashboard. ROBOT_NAME=pm01_150 ROBOT_BRAND=engineai ROBOT_TYPE=humanoid ROBOT_MODEL=pm01 # Maps subdir (//…) + X-Robot-Name header. ROBOT=sanad # Optional: app data dir whose size is reported inside storage. Empty = omit. STORAGE_DATA_PATH=/home/ubuntu/sanad_t8/data # ═════════════════════════════════════════════════════════════════════════════ # PM01 STATE SOURCE — the one robot-specific section # ═════════════════════════════════════════════════════════════════════════════ # auto = ros2 if rclpy imports, else http if ENG_STATE_URL is set, else none # (heartbeat: battery null, status offline). # ros2 = subscribe ENG_TOPIC_* — the deployed setting. # http = poll ENG_STATE_URL for one JSON object; map fields with ENG_FIELD_*. # none = never read; always heartbeat. ENG_SOURCE=ros2 # ── backend: ros2 ──────────────────────────────────────────────────────────── # Verified live on the robot with `ros2 topic list -t` (see docs/PM01_INTERFACE.md). # The types are the vendor's own interface_protocol messages; they import only # after the ROS overlay is sourced, which the systemd unit does via ros_env.sh. ENG_TOPIC_POWER=/hardware/power_info ENG_TYPE_POWER=interface_protocol/msg/PowerInfo ENG_TOPIC_MOTORS=/hardware/motor_debug ENG_TYPE_MOTORS=interface_protocol/msg/MotorDebug ENG_TOPIC_JOINTS=/hardware/joint_state ENG_TYPE_JOINTS=interface_protocol/msg/JointState ENG_TOPIC_MOTION=/motion/motion_state ENG_TYPE_MOTION=interface_protocol/msg/MotionState # The PM01 publishes NO odometry topic — leave empty (position reports null). # Fill this in the day a nav stack starts publishing one. ENG_TOPIC_ODOM= ENG_TYPE_ODOM=nav_msgs/msg/Odometry # Must match the robot's domain or ROS 2 discovery silently sees nothing. # The PM01 stack runs on 69 with CycloneDDS pinned to eth1 — the unit sources # /app/applications/install/bringup/ros_env.sh, which sets all three. ROS_DOMAIN_ID=69 # Subscription reliability. Keep best_effort: a RELIABLE subscriber receives # NOTHING from a BEST_EFFORT publisher (the subscription is created, no error is # raised, and the field silently stays null forever), while a BEST_EFFORT # subscriber reads from either kind. ENG_ROS_QOS=best_effort # ── decimation (protects the robot's own CPU) ──────────────────────────────── # /hardware/joint_state publishes at 500 Hz and /hardware/motor_debug at 100 Hz. # Telemetry resamples every 2 s, so running a Python callback on every message # would burn the robot's compute for nothing. Seconds between PROCESSED messages: ENG_JOINT_MIN_PERIOD=0.05 ENG_MOTOR_MIN_PERIOD=0.2 # ── field mapping (applies to EVERY backend) ───────────────────────────────── # Dotted paths into the message/JSON. They work over both ROS message objects # and plain dicts, support list indices ("cell_temp[0]") and wildcards # ("joints[*].temp"), and yield null for any missing link — a wrong path # degrades a field, it never crashes the agent. Empty = field unavailable. ENG_FIELD_SOC=percentage ENG_FIELD_VOLTAGE=voltage ENG_FIELD_CURRENT=current ENG_FIELD_CURRENT_LIMIT=current_limit ENG_FIELD_POWER_ERR=error_code ENG_FIELD_POWER_ENABLE=enable # PowerInfo carries NO pack temperature / SOH / cycle count — leaving these # empty reports null. Filling them with a wrong path would fabricate data. ENG_FIELD_TEMP= ENG_FIELD_SOH= ENG_FIELD_CYCLES= # MotorDebug — the PM01 DOES publish per-motor temperatures (25 of them), plus # the driver MOSFET temperatures, plus per-motor fault/offline flags. ENG_FIELD_TEMPS=motor_temperature ENG_FIELD_MOS_TEMPS=mos_temperature ENG_FIELD_MOTOR_ERR=error_code ENG_FIELD_MOTOR_OFFLINE=offline # JointState velocities → the "moving" status. ENG_FIELD_VEL=velocity # MotionState → control.mode + control.switchable_modes (read-only). ENG_FIELD_MOTION=current_motion_task ENG_FIELD_TRANSITIONS=available_transition_motions ENG_FIELD_X=pose.pose.position.x ENG_FIELD_Y=pose.pose.position.y ENG_FIELD_FW= # ── unit conventions (verified on the real robot) ──────────────────────────── # PowerInfo.percentage is already 0..100 → percent (never scale). ENG_SOC_SCALE=percent # +1 = positive current means CHARGING (the ROS BatteryState convention). # MEASURED on this PM01: current stays POSITIVE (~2 A) while the pack drains # (27%→26%, 54.74→54.58 V) — i.e. positive = DISCHARGE. Hence -1. Setting this # to +1 would report a discharging robot as "charging" forever. ENG_CURRENT_SIGN=-1 # PowerInfo already publishes volts and amps. ENG_VOLTAGE_SCALE=1 ENG_CURRENT_SCALE=1 # ── position ───────────────────────────────────────────────────────────────── # none | ros2 | rosbridge | http. # The PM01's motion stack is a whole-body controller, not a navigation stack: # there is NO odometry topic and no localisation running, so position is null. # That is "not available", never a fabricated origin. # ros2 — set ENG_TOPIC_ODOM too, the day a nav bringup publishes one # http — read the Sanad nav API (works as soon as its bringup is alive) # rosbridge — read /odom over the rosbridge websocket ENG_POSITION_SOURCE=none ENG_POSITION_URL=http://127.0.0.1:8014/api/nav/status ENG_POSITION_FIELD_X=pose.x ENG_POSITION_FIELD_Y=pose.y ENG_POSITION_INTERVAL=2 ROSBRIDGE_URL=ws://127.0.0.1:9090 # ── identity / networking ──────────────────────────────────────────────────── # Which NIC's MAC is reported as the robot identity. wlP1p1s0 is the wifi NIC # that carries the robot's LAN address. MAC_INTERFACE=wlP1p1s0 # ── fault thresholds ───────────────────────────────────────────────────────── LOW_SOC=50 MOTOR_TEMP_MAX=85 MOS_TEMP_MAX=100 # max |joint velocity| above which status becomes "moving" MOVING_VEL=0.15 # ── cadence / transport ────────────────────────────────────────────────────── POLL_INTERVAL=2 VERIFY_TLS=1 HTTP_TIMEOUT=30 TZ_OFFSET_HOURS=4 # ── map sync (uploaded ONCE per content PER SERVER; status in telemetry "map") ── # pgm+yaml sets are rendered to PNG with pure stdlib; RTAB-Map .db is sent # as-is (skipped above the server's upload cap). MAPS_DIR=/home/ubuntu/sanad_t8/data/maps EXTRA_MAP_DIRS= DATA_DIR=/home/ubuntu/sanad_t8/data STATE_DIR=/var/lib/sanad_api_eng MAP_SELECT=all MAP_UPLOAD_MODE=multipart MAP_POLL_INTERVAL=30 MAP_MAX_UPLOAD_MB=7 WEB_NAV3_URL= # ── logs + alerts ──────────────────────────────────────────────────────────── LOGS_INTERVAL=60 # auto = find a RUNNING sanad* docker container and tail its json-log. On this # robot that is "sanad-t8". Reading it needs root, which the system service has. PROJECT_LOG_CONTAINER=auto PROJECT_LOG_PATH= PROJECT_LOG_LABEL= PROJECT_LOG_BACKFILL=100 ALERT_SCAN_INTERVAL=10 ALERT_LOG_COOLDOWN=300 # ── remote (register a dashboard URL + ssh for the fleet UI) ───────────────── # 8014 is the Sanad Dashboard on this robot. 9002/9003 are EngineAI's own # dashboard and Foxglove — kept as fallbacks if Sanad is ever stopped. REMOTE_ENABLE=1 REMOTE_PORTS=8014,8001,9002,9003,8000,8080 REMOTE_KIND=web REMOTE_INTERVAL=60 REMOTE_URL= REMOTE_HOST= SSH_REGISTER=1 # The robot's LOGIN user. Left empty, ssh registration is skipped rather than # registering a wrong user that silently fails for whoever tries it. SSH_USER=ubuntu SSH_PORT=22 # ── control panel (READ-ONLY; remote mode-SWITCH is off by design) ─────────── # control.mode and control.switchable_modes come from /motion/motion_state — the # robot's own words, no id table to guess. This URL only adds arm/teleop detail. CONTROL_STATUS_URL=http://127.0.0.1:8014/api/controller/status CONTROL_ENABLE=0 # ── CPU: raw subscriptions for the high-rate topics ───────────────────────── # 1 (default) subscribes /hardware/joint_state and /hardware/motor_debug with # raw=True, so the rate gate above runs BEFORE deserialization and a dropped # message is never turned into a Python object. Measured 27.2% -> 23.8% of one # core. Set 0 to use ordinary subscriptions (identical data, more CPU). # # Measured cost of each subscription, as % of ONE core (12 available): # all four topics 26.5% | without joint_state 6.5% | without both 2.2% # i.e. the 500 Hz joint_state is ~20% on its own. If you ever need that back, # set ENG_TOPIC_JOINTS= (empty): the agent drops to ~6% and "status" stops # reporting "moving" (control.mode still shows the real motion task). ENG_RAW_SUBSCRIBE=1 # How often a repeated POST failure to the same server is reported in full. # Suppressed occurrences are counted and shown on the next line that prints. ERROR_LOG_COOLDOWN=60