A 6-wheel, 4-corner-steering Mars exploration rover (Opportunity-style rocker-bogie steering geometry) with a 5-axis arm, a microscope module at the 3rd wrist joint, a full sensor suite, Xbox 360 teleop, and a browser-based ground control console.
This workspace was rebuilt from scratch against a new, simplified
hardware spec. It intentionally does not carry over assumptions from
any earlier iteration of this project (e.g. the main perception camera
is now a plain USB/OpenCV webcam per the current spec, not the
previously-selected depth camera — see rover_description).
Host OS: Ubuntu 22.04.5 LTS · ROS 2 Humble.
Three documents, three different jobs: this README is the
engineering reference (architecture, design decisions, why things are
built the way they are); docs/INSTALL.md is the one-time
from-scratch build and bring-up guide; docs/USER_MANUAL.md is
the day-to-day operating guide for a rover that's already built and
running — start-up, driving, arm/mast/microscope controls, and what
the telemetry means.
| Board / device | Connects | Controls | ROS package |
|---|---|---|---|
| Arduino Mega #1 | /dev/rover/base |
6x drive motor (2x with encoders - see Navigation below), 3x DRI0002 Dual H-Bridge V1.4 (L298N) driver, 4x steering servo via PCA9685 (I2C, address 0x40 - factory default, deliberately unjumpered), 2x FZ0430 voltage sensor (drive rail on A0, steering rail on A1), DS18B20 temperature sensor (1-Wire, pin A4), cooling fan (MOSFET driver module, hardware PWM, pin 44, automatic/thermostatic) | rover_base |
| Arduino Mega #2 | /dev/rover/arm |
5x NEMA17 + EBA-17-M planetary gearbox (120:1); J1-J3 TB6600, J4-J5 A4988; 5x calibration switch (per-joint configurable homing direction/order/offset - see "Explicit assumptions"); FZ0430 voltage sensor (A0); DS18B20 temperature sensor (1-Wire, pin 20); cooling fan (MOSFET driver module, hardware PWM, pin 44, automatic/thermostatic); latching emergency stop and 3 predefined poses (initial/transport/service) | rover_arm |
| Arduino Uno #3 | /dev/rover/mast |
2x NEMA17 head yaw/pitch (TB6600 driver, + calibration switches), DC lift (HW-039 driver, erect/stow), 2x lift limit switch, FZ0430 voltage sensor (A0), DS18B20 temperature sensor (1-Wire, pin A4), cooling fan (MOSFET driver module, software PWM, pin A2, automatic/thermostatic) | rover_mast |
| Arduino Uno #4 | /dev/rover/microscope |
DRV8825 + 24BYJ-48 focus/zoom stepper (4-wire bipolar wiring), dimmable LED, SG90 lens cover, DS18B20 temperature sensor (1-Wire, pin 11), cooling fan (MOSFET driver module, hardware PWM, pin 3, automatic/thermostatic) | rover_microscope |
| Arduino Uno #5 | /dev/rover/antenna |
2x NEMA17 + EBA-17-M planetary gearbox (120:1) + TB6600, high-gain-antenna gimbal (azimuth G1 + elevation G2), 2x calibration switch, FZ0430 voltage sensor (A0), DS18B20 temperature sensor (1-Wire, pin A4), cooling fan (MOSFET driver module, hardware PWM, pin 9, automatic/thermostatic) | rover_antenna |
| Arduino Uno #6 | /dev/rover/power |
2x INA226 voltage+current monitor (one per 24V battery, behind a TCA9548A I2C mux, A4/A5 - see "Explicit assumptions" for why the mux is needed and a real caveat about current-reading accuracy), DS18B20 temperature sensor (1-Wire, pin 2, monitors the onboard computer, not this board's own enclosure), cooling fan for that computer (MOSFET driver module, hardware PWM, pin 3, automatic/thermostatic). Telemetry-only - no command message, this board controls nothing an operator sends it | rover_power |
| Microscope USB camera | /dev/rover/microscope_cam |
live view, snapshot, recording | rover_microscope |
| Main USB camera | /dev/rover/main_cam |
forward perception (OpenCV) | rover_sensors |
| BNO086 IMU (SparkFun Qwiic breakout, UART-RVC jumper mode) | /dev/rover/imu (Waveshare USB-TTL (B), CH343G) |
orientation + accel, no Arduino | rover_sensors |
| Waveshare L76X GPS | /dev/rover/gps (USB, NMEA0183) |
position/velocity, no Arduino | rover_sensors |
| RPLIDAR C1 | /dev/rover/lidar |
360° scan for autonomous nav | external rplidar_ros (or sllidar_ros2) |
| Xbox 360 controller | USB/wireless receiver | mode-switched drive/arm/mast/microscope teleop | rover_teleop (on top of joy) |
The last five rows need no Arduino at all — the IMU and GPS modules free-run straight to a USB-serial adapter, the cameras are plain UVC/OpenCV devices, and the LIDAR ships its own ROS driver.
All nine serial/video devices are referenced by the stable
/dev/rover/* names above; see rover_bringup/config/udev/99-rover-serial.rules
for the udev rules that create them, and tools/identify_rover_devices.py
for generating them interactively from your own hardware rather than
hand-editing placeholders (including an important caveat about the
three Mega 2560 boards possibly sharing an identical or empty USB
serial number).
These show pin groupings and counts, not exact per-pin schematics —
cross-reference the exact pin constants in each board's .ino file
(linked from the hardware topology table above) before wiring, and see
that same file's header comment for power-supply notes (the DRI0002
logic-jumper caveat, servo power needing a separate 5-6V rail, the
lift motor's different dual-direction-pin H-bridge convention) that
don't fit in a pin-level diagram.
src/
rover_protocol/ shared checksummed serial framing + SerialLink, used by every bridge
rover_msgs/ custom interfaces (Base/Arm/Mast/Microscope/Antenna Command+State, BoardStatus, DriveMode) + HomeJoint.srv
rover_description/ URDF/xacro model (chassis, arm, mast, sensors, solar panels, antenna) + RViz display launch
rover_base/ Mega #1 bridge: cmd_vel -> 4-corner-steering kinematics -> serial, + wheel odometry
rover_arm/ Mega #2 bridge: joint-space ArmCommand -> serial, calibration homing, MoveIt trajectory bridge
rover_arm_moveit_config/ MoveIt2 planning config for the 5-axis arm (SRDF, kinematics, OMPL, controllers)
rover_mast/ Uno #3 bridge: pan/tilt head (calibration homing) + erect/stow lift -> serial
rover_microscope/ Uno #4 bridge + USB camera publisher w/ snapshot & recording services
rover_antenna/ Uno #5 bridge: 2-axis (azimuth/elevation) HGA gimbal -> serial
rover_sensors/ BNO086 (UART-RVC) IMU driver, L76X GPS driver, main camera publisher
rover_teleop/ Xbox 360 -> drive/arm/mast/microscope command mapping (built on `joy`)
rover_web_gui/ FastAPI + WebSocket ground control console (see below)
rover_navigation/ SLAM (slam_toolbox), sensor fusion + GPS (robot_localization), Nav2 config and launch files
rover_bringup/ top-level launch file, udev rules, serial-port config
firmware/
common/RoverProtocol/ Arduino-side counterpart to rover_protocol (install as a library)
base_mega1/ base_mega1.ino
arm_mega2/ arm_mega2.ino
mast_uno3/ mast_uno3.ino
microscope_uno4/ microscope_uno4.ino
tools/
validate_workspace.py syntax/parse sweep (Python/XML/YAML/JS/brace-balance/msg) over the whole tree
identify_rover_devices.py interactive udev VID:PID/serial capture -> generates 99-rover-serial.rules
raw_serial_probe.py ROS-free probe: does a board reply to a raw frame at all? (bypasses rclpy/SerialLink)
The base supports three steering-geometry modes, selected via
rover_msgs/DriveMode on the rover_base/drive_mode topic (default
ACKERMANN if nothing has been received yet). geometry_msgs/Twist on
cmd_vel is reinterpreted differently depending on which mode is
active — see rover_base/rover_base/kinematics.py for the exact math.
| Mode | Twist fields used | What it does |
|---|---|---|
ACKERMANN (0) |
linear.x, angular.z |
Normal driving — exact ICR-tangent corner steering, front/rear pairs mirror for coordinated arcs |
POINT_TURN (1) |
angular.z only |
Rotate about the rover's own center — forward component forced to zero |
STOP (2) |
none (Twist ignored) | Unconditional zero throttle, centered steering |
Both from the Xbox controller (in DRIVE subsystem mode: X toggles
ACKERMANN↔POINT_TURN, Y forces STOP immediately — see
rover_teleop/config/xbox_teleop.yaml) and from the web GUI (mode
buttons at the top of the DRIVE panel, plus a draggable on-screen
joystick for proportional control — see static/app.js) publish to
the same topic, so either can drive and either can see what mode is
currently active (rover_base/command_echo's drive_mode field is
the ground truth, which both the web GUI's telemetry and — if you
build one — any autonomy stack should read rather than assume).
Both input devices scale stick/joystick deflection by the same
max_linear_mps/max_angular_radps/deadzone values, loaded from
one shared file (rover_teleop/config/drive_sensitivity.yaml) rather
than two separately hardcoded copies — see docs/INSTALL.md Section
9.4 for how to tune them.
Hardware caveat, not a bug (documented in detail as a code comment
on point_turn_wheel_commands()): POINT_TURN is bounded by the same
±60° servo limit as ACKERMANN corner steering, so it can't reach a
perfect zero-radius pivot for wheels close to the vehicle's center
(the real angle needed there exceeds 90°) — the achieved motion is the
closest approximation the hardware allows, same limitation already
noted for ACKERMANN's own point-turn-via-zero-forward-velocity case.
Wheel odometry, IMU/encoder fusion (robot_localization), GPS
conversion services, SLAM (slam_toolbox), and autonomous navigation
(Nav2) are all wired together. Sensor fusion runs unconditionally as
part of normal bringup (it strictly improves what SLAM/Nav2 already
consume); SLAM and Nav2 navigation are both off by default and
mutually exclusive with each other — build a map first, then navigate
against it, not both at once.
encoders (ML, MR) ──┐
├─→ rover_base/odometry_node ─→ wheel_odom ──┐
IMU (BNO086) ────────┼──────────────────────────────────────────┤
│ ├─→ local EKF ─→ odom (TF: odom → base_link)
└────────────────────────────────────────────┘
GPS (L76X) ──→ navsat_transform_node ──→ /odometry/gps (telemetry) + /fromLL, /toLL (services)
slam_toolbox (mapping) or AMCL (navigating) ─→ TF: map → odom
- Wheel odometry (
rover_base/odometry_node) reads only the two fixed middle wheels (ML/MR) — the only pair whose rolling axis never changes with corner steering. Seerover_base/rover_base/odometry.py's module docstring for the full reasoning. It no longer broadcasts any TF itself; it feeds the EKF instead (see below). - Local EKF (
robot_localization'sekf_node,rover_navigation/config/ekf_local_params.yaml) fuses that wheel odometry with the BNO086 IMU and becomes the sole publisher of theodom → base_linkTF — smoother and more accurate than either sensor alone, which directly improves slam_toolbox's scan matching and Nav2's local costmap. Fusion fields are matched to what each sensor actually measures rather than a generic "fuse-everything" template: wheel odometry contributes velocity only (not its own heading estimate, so the IMU's independently measured heading corrects drift instead of reinforcing the same error twice); the IMU contributes yaw and linear acceleration but not roll/pitch (redundant undertwo_d_mode, which already constrains the filter's own state to zero regardless) or angular velocity (the BNO086 in UART-RVC mode doesn't provide it — seebno086_rvc_node.py). - GPS (
navsat_transform_node,rover_navigation/config/navsat_transform_params.yaml) deliberately does not feed a second, GPS-fusing "global" EKF — that's the commonrobot_localizationpattern for outdoor robots, but it would fight slam_toolbox/AMCL for ownership of themap → odomTF (both would try to publish the same edge). Instead GPS is used two conflict-free ways: telemetry (/odometry/gps, a Cartesian rendering of "where GPS thinks the rover is," useful to log/compare against the SLAM-based estimate) and GPS waypoint goals —rover_navigation/scripts/gps_goal.pyconverts a lat/lon into the current map-frame position via the/fromLLservice, then sends that straight to Nav2'snavigate_to_poseaction. Nav2 and AMCL never need to know GPS was involved at all.(Needs Nav2 already running against a saved, geo-referenced map — the conversion is relative to wherever the datum got set, i.e. wherever the rover had a GPS fix whenros2 run rover_navigation gps_goal.py 42.4373 -86.9436
navsat_transform_nodefirst started.) robot_state_publisher(already running as part of normal bringup) fills inbase_link → ...sensor framesfrom the URDF, includingbase_link → lidar_link.slam_toolbox(while mapping) or Nav2's AMCL (while navigating a saved map) supply the remainingmap → odomlink — the full chain (map → odom → base_link → lidar_link) is what lets the LIDAR's/scandata actually place obstacles on a consistent map.
ros2 launch rover_bringup bringup.launch.py use_slam:=trueDrive the rover around (Xbox teleop or the web GUI) until RViz shows a complete map, then save it:
ros2 run nav2_map_server map_saver_cli -f ~/mars_rover_ws/src/rover_navigation/maps/my_mapros2 launch rover_bringup bringup.launch.py use_navigation:=true \
nav_map:=$HOME/mars_rover_ws/src/rover_navigation/maps/my_map.yamlSend goals from RViz (rover_navigation/rviz/navigation.rviz has the
"2D Pose Estimate" and "Nav2 Goal" tools already added), via
ros2 action send_goal /navigate_to_pose ..., or by GPS coordinate
with gps_goal.py above.
Not vendored in this workspace — see docs/INSTALL.md:
slam_toolbox, navigation2, nav2_bringup, robot_localization,
geographic_msgs. slam_toolbox/navigation2/nav2_bringup are
only actually required at runtime if you pass use_slam:=true or
use_navigation:=true; robot_localization and geographic_msgs are
needed unconditionally, since sensor fusion always runs.
rover_navigation itself (a lightweight config/launch/scripts-only
package) is always present.
rover_navigation/config/nav2_params.yaml ships with standard,
reasonable starting values (footprint sized to the rover's actual
wheel extents, velocity limits matching rover_base), not something
pre-tuned against your specific space. ekf_local_params.yaml's
process noise covariance is robot_localization's own published
default, not independently re-derived; the measurement covariance
rover_base/odometry_node.py publishes on its wheel odometry
(_TWIST_VARIANCE_*) is a reasonable placeholder in the same spirit,
not characterized against this rover's actual encoders. Expect to
adjust all three after watching the rover drive and navigate a few
times.
Task-space planning for the 5-axis arm (drag an interactive marker to
a target pose, plan a collision-aware path, execute it) via MoveIt2,
off by default (use_moveit:=false).
RViz "MotionPlanning" panel / any MoveGroupInterface client
│ plans against the SRDF's "arm" group (shoulder_yaw..wrist_roll)
▼
move_group (rover_arm_moveit_config)
│ planned trajectory (radians), via the FollowJointTrajectory action
▼
arm_controller/follow_joint_trajectory ─ rover_arm's trajectory_action_server
│ converts each waypoint: radians → steps (joint_conversion.py),
│ reorders to this rover's own joint order (MoveIt doesn't
│ guarantee its joint_names arrives in that order)
▼
ArmCommand (joint_target_steps) ─ exactly what teleop/manual control already sends
▼
rover_arm_bridge → 'A' frame → arm Mega #2 → AccelStepper per joint
rover_arm_bridge and the firmware never change: MoveIt's trajectories
arrive as ordinary ArmCommand messages indistinguishable from
teleop's. The bridge between the two — rover_arm's
trajectory_action_server — exists specifically because the firmware
already does its own velocity/acceleration profiling on-device
(AccelStepper); writing a full ros2_control hardware interface
just to satisfy MoveIt's usual execution path would mean reimplementing
that. moveit_simple_controller_manager's FollowJointTrajectory
support is built for exactly this case — a robot controller that
already speaks that action interface directly.
Each waypoint is sent as an absolute joint-space target and the bridge
waits until the arm's within tolerance (goal_tolerance_rad, default
0.02) or the waypoint's planned time has elapsed, whichever comes
first, before advancing — not a precise replication of MoveIt's planned
velocity profile. Fine for a slow, deliberate sample-inspection arm;
worth knowing if trajectory timing fidelity ever matters more than it
does here.
The steps_per_joint_rev calibration (rover_arm/config/arm_topology.yaml)
existed as declared-but-unused placeholder data (200 full steps × 1/16
microstepping × 5:1 gear = 16000) since the arm's very first build —
this is what finally puts it to use.
ros2 launch rover_bringup bringup.launch.py use_moveit:=true
ros2 launch rover_arm_moveit_config moveit_rviz.launch.pyIn RViz's MotionPlanning panel: drag the interactive marker at the
arm's tip to a target pose, Plan, check the preview, Execute.
Or send a named target — home (all-zero, matching the arm's own
homed position) is defined in the SRDF.
The arm must have finished its own startup homing sequence
(rover_arm_bridge's home_on_startup, see "How does the calibration
homing sequence work" — ArmState.homed) before trajectory_action_server
will accept a goal at all; it rejects goals otherwise rather than
commanding an un-homed arm to a fabricated "zero" that isn't real.
Not vendored — see docs/INSTALL.md: ros-humble-moveit,
ros-humble-moveit-configs-utils. Only required at runtime if you
pass use_moveit:=true; rover_arm_moveit_config itself (config/launch
only) is always present, matching the same pattern SLAM/Nav2 already use.
Each joint's calibration-switch homing can now be triggered on demand — either individually or all five at once — not just automatically at bridge startup.
Web GUI "CALIBRATE J3" button
│ POST /api/arm/home/2
▼
rover_arm_bridge's rover_arm/home_joint service (rover_msgs/srv/HomeJoint)
│ validates joint_index locally, writes a 'Z' frame with that index
▼
arm Mega #2 → startHoming(2) → seeks J3's limit switch, zeroes it,
leaves J1/J2/J4/J5's homed status and position untouched
HomeJoint.srv (int8 joint_index in; bool accepted + string message out) is this project's first custom service — everything
before this was messages/topics only. joint_index=-1 homes all 5
joints in one sequential pass (the same behavior home_on_startup
has always triggered once at boot); 0-4 homes just that one joint,
leaving every other joint's calibration and position alone —
ArmState.joint_homed (bool[5]) reports this per-joint, not as one
shared flag, precisely so partial calibration is visible rather than
collapsed into a single ambiguous "homed: false".
The service reports whether the request was successfully written
to the serial link, not whether the firmware will act on it — that's
a real boundary, not a shortcut: this layer can't see whether the
Arduino, say, silently ignores the request because a homing run is
already in progress. ArmState.joint_homed (via ros2 topic echo /rover_arm/state or the web GUI's per-joint ✓/✗ indicators) is the
actual source of truth for whether/when homing completed.
Every joint must be individually homed — and no homing run in
progress — before any joint-move command is accepted, even though
homing itself is now per-joint. A command that moved only the
already-homed joints while silently ignoring the rest would let the
arm move through configurations nothing has actually verified are
safe for the not-yet-homed joint's real position; waiting for a fully
calibrated arm before accepting any motion at all is simpler and
safer. ArmState.homed (bool) is all(joint_homed), computed by
rover_arm_bridge — not sent over the wire, and kept specifically so
MoveIt's trajectory_action_server (which already gated goals on this
exact field) needed no changes for this to work.
Two things about the homing process that used to be a single, uniform
assumption applied to all five joints are now independently
configurable per joint, as constants in arm_mega2.ino — both
currently PLACEHOLDER values matching the previous, only behavior,
pending real mechanical verification on the bench:
kHomingDirection[5]— which way each joint's stepper seeks toward its own limit switch (+1or-1). Previously a single hardcoded-1applied to every joint regardless of its own actual mechanical layout.kHomingOrder[5]— the sequence joints are homed in during an all-5 run (joint_index=-1). Previously a fixed J1 → J2 → J3 → J4 → J5 loop with no way to change it; a permutation of 0-4, useful for a mechanical/safety sequence where one joint genuinely needs to home before another (e.g. shoulder before wrist, to avoid a collision partway through the sweep). Only affects the all-5 case — a single-joint request (0-4) always just homes that one joint, unaffected by this order.
Neither is sent over the wire or configurable from the web GUI — they're physical-calibration facts about the hardware itself, set once in firmware to match the real joints, not something an operator chooses per homing run.
What this file used to call an "offset" (kHomingOffsetSteps — a
small, per-joint, separately-tuned distance from an assumed-zero
switch position to a joint's true reference point) has been replaced
entirely by a physically-grounded model, not just renamed. Three new
constants, none of them invented fresh — all three sourced directly
from numbers this project had already established elsewhere:
kStepsPerDegree[5]— derived from, and must be kept in sync with,rover_arm/config/arm_topology.yaml's ownsteps_per_joint_rev(currently 384000 for all five: 200 full steps × 1/16 microstepping × 120:1 EBA-17-M planetary gearbox — see that file's own comment). 384000 / 360 ≈ 1066.667 steps/degree.kMinDeg[5]/kMaxDeg[5]— each joint's own operational range in degrees, relative to true center. Sourced directly fromrover_description/urdf/arm.xacro's own joint<limit>tags (shoulder_yaw/elbow_pitch: ±150°, shoulder_pitch/wrist_pitch: ±100°, wrist_roll: ±170°) — the same numbers MoveIt itself already plans against, not a separately-guessed pair.kLowerLimitSteps[5]— the value assigned viasetCurrentPosition()the instant a joint's limit switch trips. The limit switch's own physical trigger point is now understood to sit at each joint's own operational lower bound, not at an arbitrary "zero" the waykHomingOffsetStepstreated it. PLACEHOLDER values currently assume the switch sits exactly atkMinDeg[j](converted to steps, rounded) — a reasonable starting assumption, not a bench-verified one. Kept independently adjustable fromkMinDegin code (not derived from it), so a real mechanical safety margin between "where the switch actually trips" and "the joint's own declared operational minimum" can be introduced later without touching either constant's own meaning.
Once the switch trips and kLowerLimitSteps[j] is assigned,
serviceHoming()'s own two-phase state machine (SEEKING_LIMIT →
MOVING_TO_CENTER, renamed from the old model's MOVING_TO_OFFSET
now that the destination is always exactly 0, not a per-joint tuned
value) drives the remaining distance to absolute step 0 using the
normal accel/speed profile — 0 now means each joint's own true center,
not an arbitrary reference point. This is still the same "seek limit,
then move to a real reference position" pattern mast_uno3.ino's and
antenna_uno5.ino's own post-calibration sequences use, just now
landing on a physically meaningful center rather than an arbitrary
offset destination.
Every regular joint command, and every preset move, is now clamped
to [kMinDeg, kMaxDeg] (converted to steps via kStepsPerDegree)
before being accepted — clampToOperationalRange(), applied at both
call sites. This is a real, firmware-side backstop, not merely a
courtesy: since MoveIt already plans within these same limits (sourced
from the same arm.xacro), this should rarely if ever actually clamp
anything reaching the firmware via a MoveIt-planned trajectory — it
exists for whatever else might reach it: a raw command from the web
GUI's own sliders, a hand-typed test frame, a future client with no
knowledge of these limits at all. The web GUI's own 5 joint sliders
were updated to match these same real bounds (±160000 / ±106667 /
±181333 steps) as a UI-side backstop alongside the firmware's own,
replacing an old, disconnected ±16000 placeholder that turned out to
trace back to the same stale 5:1 gear-ratio assumption arm_topology.yaml
had already moved past.
Keeping these numbers in sync matters for a real reason, not just
tidiness: a mismatch between arm.xacro's own limits and this
firmware's kMinDeg/kMaxDeg would mean MoveIt could plan a
trajectory this firmware's own clamp then silently truncates — the arm
would quietly stop short of where MoveIt believes it commanded it to
go. There's no automatic single source of truth across the
URDF/firmware/ROS-config/browser boundary here — arm.xacro's own
<limit> tags, arm_topology.yaml's own steps_per_joint_rev,
arm_mega2.ino's own kStepsPerDegree/kMinDeg/kMaxDeg/
kLowerLimitSteps, and the web GUI's own slider bounds are four
separate places carrying related numbers that all need updating
together by hand if any of them ever change.
Three fixed five-joint poses — kInitialPoseSteps, kTransportPoseSteps,
kServicePoseSteps in arm_mega2.ino — each reachable with a single
'P' frame (rover_arm/arm_preset service, rover_msgs/srv/ArmPreset)
rather than requiring the operator to know and send all 5 target steps
by hand. All three are currently PLACEHOLDER (all-zero, every joint at
its own homed zero) pending real-world calibration — not yet claimed
to be safe, useful, or even reachable poses. Deliberately implemented
as firmware-side constants, addressable by index, rather than computed
or stored at the ROS/web-GUI layer: this way the poses stay reachable
even if the ROS stack is down but the serial link to the Arduino
itself still works, and there's exactly one place these values live
rather than a web-GUI-side copy that could drift from a firmware-side
one. Subject to the same "fully homed, not mid-homing, not e-stopped"
gate as a regular joint command — a preset move is still new movement,
no different from an operator manually driving the sliders as far as
safety gating is concerned.
The web GUI's arm panel exposes these as three buttons (INITIAL POSITION, TRANSPORT POSITION, SERVICE POSITION) — this replaced
an existing button (RETURN HOME) that used to send a hardcoded
[0,0,0,0,0] directly as a regular joint command, rather than leave
two parallel, potentially-diverging ways to reach a default pose.
A latching emergency stop, triggered/cleared via a 'X' frame
(rover_arm/emergency_stop service, rover_msgs/srv/EmergencyStop) -
ArmState.estop_active reports the firmware's own current state.
Deliberately does NOT de-energize the drivers - a real safety
trade-off, not an oversight. Conventional e-stop behavior cuts power
to the actuators; this one instead calls AccelStepper::stop() on
every joint (a fast, controlled deceleration using each joint's own
configured acceleration - not an instantaneous halt, since forcing an
abrupt step-rate change instead risks the motor losing synchronization
with its driver) and leaves every driver energized throughout. This is
a gravity-loaded arm: de-energizing mid-air risks an uncontrolled
drop, judged worse than holding position under load, the same
philosophy this board's own watchdog-timeout behavior already applies
(and mast/antenna's own watchdogs share).
Once triggered, the e-stop blocks every source of new movement
(regular joint commands, preset requests) until an explicit clear is
received - the firmware itself is the authoritative source of truth
for this, not the ROS bridge node upstream of it, specifically so a
bridge-node restart or hiccup after an e-stop can't silently resume
motion. The web GUI's arm panel exposes this as two buttons (E-STOP,
styled distinctly - a solid, high-contrast button rather than this
project's usual outline style, matching real e-stop button
conventions - and CLEAR E-STOP), plus a live status readout in the
telemetry panel driven by estop_active itself, not by which button
was last clicked - so a page reload or another operator's own action
stays in sync automatically.
Web GUI (arm panel): CALIBRATE J1-CALIBRATE J5 for a single joint,
CALIBRATE ALL 5 for the full sequence, INITIAL POSITION to move to
the arm's predefined starting pose (a preset move, not a calibration
action — only meaningful once actually homed, and safely a no-op
otherwise since the firmware ignores it like any other joint command
would be). Driver enable/disable is a single toggle (label and color
both follow the firmware's own reported drivers_enabled state, not
a locally-tracked guess) — this brings the arm in line with the same
toggle convention the mast, antenna, and microscope panels already use
for their own driver control; the arm was the one panel still using
two separate buttons for it before this.
Directly:
ros2 service call /rover_arm/home_joint rover_msgs/srv/HomeJoint "{joint_index: 2}" # home J3 only
ros2 service call /rover_arm/home_joint rover_msgs/srv/HomeJoint "{joint_index: -1}" # home all 5A 2-axis high-gain-antenna pointing mechanism, top rear left of the rover, modeled on the real Mars Exploration Rover (Spirit/Opportunity) HGA gimbal — G1 (azimuth, primary axis, normal to the deck) and G2 (elevation, secondary axis, parallel to the deck), with the antenna disk on a short arm at the end and the beam radiating perpendicular to the disk face.
The spec for this subsystem gave two different ranges per axis: G1
[-90°, 90°] / G2 [90°, 270°], and — specifically for the
deployed configuration — g1 [15°, 285°] / g2 [0°, 180°]
("software-limited"). Those two pairs don't reconcile numerically as a
simple offset (180° span vs. 270° span for azimuth), so before
building anything, this was checked against the real MER HGAG
engineering paper (Sokul et al., ESMATS 2004) rather than guessed at.
That paper confirms the real mechanism: a pyrotechnic-released,
one-way spring-gate deployment (the drive "can no longer return to the
original stowed position" once through the gate), with 280° azimuth
and 234° elevation as the real, published post-deployment rotation
spans — closely corroborating g1's 270° span and confirming g2's 180°
is a deliberate software restriction of what's mechanically possible
(real elevation capability is 234°), matching this project's own
"software-limited" framing exactly.
g1 [15°, 285°] and g2 [0°, 180°] are what's implemented and
enforced (antenna_uno5.ino's constrain() calls,
antenna_topology.yaml's min/max_azimuth_deg/min/max_elevation_deg).
G1/G2 (uppercase) reads as a different reference frame — not
reconciled into a second active constraint here, since doing so would
require guessing at a transformation the source material doesn't
specify. Flagged here rather than silently picked one interpretation.
Only the two gimbal axes' operational range is implemented — there's no launch-lock, pyrotechnic pin puller, or deploy-gate mechanism modeled (out of scope for what was asked: 2 stepper axes + calibration switches, not a deployment sequencer). The antenna is treated as already deployed for all purposes here.
Each calibration switch is assumed mounted at that axis's own
operational minimum (15° azimuth, 0° elevation) — a real design
choice, not verified against an actual mechanical drawing, flagged
here and in antenna_uno5.ino's header comment so it's easy to
correct against real hardware. This makes homing simpler than the
mast's: the switch position is each axis's minimum, not offset from
a separately-centered zero, so setCurrentPosition() at trigger time
directly establishes "home" — no follow-on move-to-a-different-
reference step the way the mast's own (corrected) calibration sequence
needs one.
Same actuator/driver combination as the arm's joints — 2x NEMA17 +
EBA-17-M planetary gearbox (120:1) + TB6600, on an Arduino Uno with 2
calibration switches and an FZ0430 voltage sensor. steps_per_deg
and the speed/acceleration/homing-speed constants in antenna_uno5.ino
are copied directly from arm_mega2.ino rather than re-derived — same
actuator, already-vetted numbers. The two TB6600 drivers share one
enable pin (kGimbalEnablePin), safe for the same reason the mast's
own two TB6600s share one: two opto-isolated ENA inputs draw
comfortably under a typical GPIO's safe sink rating, unlike the arm's
three TB6600s, which get independent pins instead.
Web GUI (antenna panel): azimuth/elevation sliders (range fetched from
antenna_topology.yaml via GET /api/config, same mechanism the
mast's transport-position preset uses) and a CLOSE DRIVER (ENABLE) /
OPEN DRIVER (DISABLE) toggle — starts disabled after homing, same
convention as the mast, until explicitly enabled.
Xbox controller (LB until in ANTENNA mode): left stick jogs azimuth (X) and elevation (Y) from the current position, rather than an absolute-position mapping the way the mast's right stick works — the antenna's operational range isn't centered around 0° the way the mast's is, so mapping stick deflection straight to an absolute angle would be a much less natural control feel here.
These are the calls made where the spec was silent or where "simplify" pointed toward a lighter-weight choice. Flag any of these back if they don't match the real hardware:
-
Arm: driver-enable state is now real telemetry, not just a command the host remembers sending - and the web GUI's own button for it changed from two to one to match.
arm_mega2.ino's owndriversEnabledwas already tracked internally; it just never made it into the outgoing'S'frame, so the only place that state existed was whatever the host last commanded via the'A'frame's ownenablefield. That gap was real, not hypothetical:startHoming()enables drivers automatically before seeking, with no operator action involved, so a value the web GUI only remembered sending would have silently drifted out of sync with reality the moment homing started on its own. Addeddrivers_enabledas a new, 20th field on the'S'frame (threaded through the protocol layer,ArmState.msg, the bridge node, and the web GUI's own state dict) so this is now the firmware's own actual, current state, always - not a command echo.The web GUI's own two separate buttons (
ENABLE DRIVERS/DISABLE (FREE-SPIN)) became one toggle, at the user's own explicit request - and this turned out to be less a departure from this project's own convention than a correction of one: the mast, antenna, and microscope panels already use a singleCLOSE DRIVER (ENABLE)/OPEN DRIVER (DISABLE)toggle for the exact same kind of state; the arm was the one panel still using two separate buttons for it. Kept the arm's own, more explicit wording (ENABLE DRIVERS/DISABLE DRIVERS (FREE-SPIN), the button's own label switching with the state rather than the other three panels'CLOSE/OPENphrasing) since the free-spin consequence is a genuinely useful thing to say out loud for a 5-joint arm specifically, not just tidied away for consistency's sake - the toggle mechanism is what needed to match, not necessarily the exact words. The button's label and color both followdata.arm.drivers_enabledfrom live telemetry on every render, the same "driven by the firmware's own reported state, not a locally-tracked guess" approach already used for this panel's own E-STOP status readout.Verified this compiles cleanly against the real toolchain before calling it done, not just reviewed by eye: installed the actual AVR cross-compiler (
gcc-avr, matching the Arduino IDE's own Atmel-flavored 7.3.0) plusArduinoCore-avr,AccelStepper,OneWire, andDallasTemperaturefrom their real source repositories, and compiledarm_mega2.inowith-Wall- zero errors, zero warnings, both before and after this session's own change tosendStateFrame(). -
Arm: a physically-grounded homing/coordinate model replacing an arbitrary one, and a real operational-range clamp - sourced from numbers this project had already established, not invented fresh. Full technical detail lives in "Arm calibration" above (see "Steps-per-degree, the real lower limit, and the operational range"); this entry is about the sourcing decision itself, which is the part most worth flagging back if it's wrong.
Rather than invent placeholder degree/range numbers the way most of this project's other uncalibrated constants default (all-zero, or "matches the previous behavior exactly"), this instead reused two sets of numbers this project already had on record before this session touched anything:
rover_arm/config/arm_topology.yaml's ownsteps_per_joint_rev(384000, already correctly reflecting the 120:1 gearbox) forkStepsPerDegree, androver_description/urdf/arm.xacro's own joint<limit>tags (±150°/ ±100°/±150°/±100°/±170°) forkMinDeg/kMaxDeg- both were real, specific, already-intentional values, not placeholders themselves, so treating them as placeholders here and inventing different numbers would have created a genuine inconsistency (MoveIt planning against one set of limits while the firmware clamped against another) rather than resolved one.kLowerLimitSteps(what the limit switch's own trigger point gets labeled as) uses the same numbers askMinDeg, converted to steps, as its own placeholder - physically reasonable (switch mounted right at the operational minimum) but not itself bench-verified, and kept as an independently adjustable constant rather than computed fromkMinDegin code, so a real mechanical margin between the two can be introduced later without changing what either constant means.Found and fixed two genuine, unrelated staleness issues while sourcing these numbers, both traced to the same root: a stale 5:1 gear-ratio assumption (200 full steps × 1/16 microstepping × 5:1 = 16000) that predated this project's move to the real 120:1 EBA-17-M gearbox (384000) but was never fully swept away.
joint_conversion.py's own module docstring still cited the old 16000 figure; the web GUI's 5 joint sliders still used a±16000bound that turned out to be exactly one motor revolution's worth of steps at that same old ratio- both fixed, the sliders updated to the real,
arm.xacro-derived bounds (±160000/±106667/±181333steps) as a UI-side backstop alongside the firmware's own new clamp, not a replacement for it.
A historical "Explicit assumptions" bullet from an earlier session (this file's own "Calibration direction, order, and post-limit- switch offset..." entry, further below) referenced
kHomingOffsetStepsby name - since that constant no longer exists, updated that entry in place to redirect here rather than leave it factually describing code that isn't there anymore. - both fixed, the sliders updated to the real,
-
The actual root cause of base/arm never showing telemetry in the web GUI - found only after ruling out the firmware, the physical serial link, and the ROS topic layer entirely, each independently confirmed healthy first.
rover_web_gui/ros_bridge.py's own_on_base_state()/_on_arm_state()callbacks converted fixed-size ROS array fields (BaseState.encoder_ticks/encoder_delta_ticks,ArmState.joint_position_steps/limit_switch_triggered/joint_homed) with a barelist(msg.field). rclpy backs every fixed-size array message field withnumpy.ndarrayinternally, solist()on one producesnumpy.int32/numpy.bool_elements, not plain Pythonint/bool- andjson.dumps()cannot serialize either numpy scalar type at all; it raisesTypeErrorunconditionally. Reproduced directly, not just reasoned about:json.dumps([numpy.int32(1)])raisesTypeError: Object of type int32 is not JSON serializable, confirming the mechanism exactly.Why this took multiple sessions and several ruled-out hypotheses to actually find, rather than being obvious from the symptom alone:
server.py's own_telemetry_sender()builds one combined snapshot for every board and serializes it in a singlejson.dumps()call inside oneasynciotask, previously with no exception handling of its own around that call. An uncaught exception in anasynciotask doesn't crash the process - it just silently stops that task, logged only as "Task exception was never retrieved" server-side, with nothing the browser's own console would ever show (which is exactly why a browser dev-tools check came back completely clean - the failure never reached the browser to produce an error there in the first place). Every board's telemetry rode on that same one task and one combined snapshot, so base or arm's own bad array field silently killed telemetry for every board at once, not just its own - mast, microscope, antenna, and power looked like they still worked only because the browser kept displaying whatever their own last successfully-sent values had been, frozen, from before the task died; they were never actually still updating. Base and arm are also the only two boards whose own state messages contain any array field at all - checked directly against every board's own.msgfile, not assumed - which is why this affected exactly and only those two, matching the actual reported symptom precisely.Fixed with an explicit per-element cast at both call sites (
[int(t) for t in ...],[bool(t) for t in ...]) rather than a generic serialization workaround, matching the same fix already applied once before to this exact bug class in a different file (arm_bridge_node.py's own_on_timer(), from an earlier session) - a second, independent occurrence of the same underlying rclpy behavior, not a regression of the first fix. Also hardened_telemetry_sender()itself with its own per-tick exception handling around the send, specifically so a future, different field with the same problem can't silently take down every board's telemetry again the same way - it now logs clearly and skips that one tick instead of dying for the rest of the connection's lifetime. -
A real, project-wide bug found via a live log capture, not code review:
SerialLinkcould hang a bridge node's entire executor thread indefinitely on a single write, with no error and no further log output at all.rover_protocol/serial_link.py's underlyingserial.Serialconnection was constructed with a readtimeoutbut nowrite_timeout- pyserial's own default when that's left unset isNone, meaning a write blocks forever if the OS-level output buffer fills and the board on the other end isn't draining its own serial RX fast enough to keep up. This is shared infrastructure every bridge node (base, arm, mast, microscope, antenna, power) is built on, so the exposure was project-wide, not specific to any one board - but the arm was the one that actually hit it during testing, and for a specific, identifiable reason: its homing sequence legitimately runs far longer than this project's usual command-response turnaround (a 120:1 gearbox slows real joint motion, andserviceHoming()'s own seek phase doesn't yield back to frame handling as promptly as a quick command-and-respond cycle does), during which the bridge node's own continuous, periodic writes kept arriving while the firmware wasn't getting back around to draining its input quickly enough - eventually filling the board's tiny 64-byte hardware RX buffer and blocking the write call forever.Diagnosed from an actual log capture, after code review alone had been exhausted without finding it - a user-provided log showed the arm bridge node logging "sent homing request to arm Mega" and then going completely silent forever, no error, no crash, no further output of any kind - the exact signature of a thread stuck on a blocking syscall, not a program error, which produces neither. Since
_publish_status()sits after the write call in the same function, a hung write there meantBoardStatuswas never published even once - explaining a status lamp stuck at its default "never received" state (which reads as red) rather than a lamp that turns green and then goes stale, and why the arm specifically was the board most likely to actually trigger this given its own homing duration, even though the underlying vulnerability was never arm-specific at all.Fixed with a bounded
write_timeout(defaulting to 0.2s, matching the existing read timeout's own general magnitude) threaded throughSerialLink's constructor to the underlying connection - a write that can't complete now raisesserial.SerialTimeoutException, caught by the same exception handler every other write failure already goes through, rather than hanging the calling thread indefinitely. Verified every one of the six bridge nodes constructsSerialLinkusing keyword arguments exclusively before considering this fix backward-compatible - none needed any changes themselves, since a purely-keyword call site can't be affected by a new parameter's position in the signature. Two dedicated regression tests confirmwrite_timeoutis actually threaded through to the underlying connection, not just accepted and silently dropped, and that it defaults to a real, bounded value rather than theNonethat was the root cause here. -
Arm: a latching emergency stop, and calibration made independently configurable per joint where it used to be a single hardcoded assumption applied to all five. Also found and fixed a real documentation gap while updating this table: the arm's own cooling fan (present in firmware for several sessions) had never made it into this hardware topology row at all.
Emergency stop deliberately does NOT de-energize the drivers - a real, non-obvious safety trade-off, not an oversight. Conventional e-stop behavior cuts power to the actuators; this one instead calls AccelStepper's own
stop()on every joint (a fast, controlled deceleration using that joint's own configured acceleration, not an instantaneous halt - forcing an abrupt step-rate change instead risks the motor losing synchronization with its driver, arguably a worse outcome than a fast but controlled stop) and leaves every driver energized throughout. This is a gravity-loaded arm: de- energizing mid-air risks an uncontrolled drop, which this project has already judged worse than holding position under load in the near-identical watchdog-timeout situation this same board's firmware already had (and mast/antenna's own watchdogs share the same philosophy). Once triggered, the e-stop is latching and blocks every source of new movement (regular joint commands, preset requests) until explicitly cleared - the firmware itself is the authoritative source of truth for this state, not the ROS bridge node upstream of it, specifically so a bridge-node restart or hiccup after an e-stop can't silently resume motion.Calibration direction and order are now independently configurable per joint, as PLACEHOLDER firmware constants (
kHomingDirection,kHomingOrderinarm_mega2.ino) pending real mechanical verification on the bench - each currently set to match the previous, only behavior (uniform direction, sequential J1-J5 order) rather than a guessed-at "improved" default.UPDATE (later session): the post-limit-switch "offset" this bullet originally described here has since been replaced entirely, not merely adjusted -
kHomingOffsetStepsno longer exists.kLowerLimitSteps,kMinDeg/kMaxDeg, andkStepsPerDegreetook its place, along with a firmware-side clamp on every joint command and preset move - see "Steps-per-degree, the real lower limit, and the operational range" above for the complete, current model; not duplicated here to avoid two descriptions drifting apart from each other over time.Three predefined poses (initial/transport/service), reachable via a new firmware-level command, not just a web-GUI convenience. All three are ALL-PLACEHOLDER (all-zero, every joint at its own homed zero) pending real-world calibration - not claimed to be safe, useful, or even reachable poses yet. Deliberately implemented as fixed constants in
arm_mega2.inoitself, addressable by index over the wire, rather than joint angles computed or stored at the ROS/ web-GUI layer - this way the poses stay reachable even if the ROS stack is down but the serial link to the Arduino itself still works, and there's exactly one place these values live rather than a web-GUI-side copy that could drift from a firmware-side one. This replaced, rather than duplicated, an existing endpoint: the web GUI already had a "RETURN HOME" button sending a hardcoded[0,0,0,0,0]directly - upgraded to call the new firmware-owned INITIAL preset instead of leaving two parallel, potentially- diverging ways to reach a default pose. The "SERVICE" pose's actual intended purpose (what it needs to clear or reach) wasn't specified further when this was added - kept as a generically-named preset pending that detail, rather than guess at a real value without knowing what it's actually for. -
Microscope: the LED/cover "protect the optics" fail-safe is removed entirely, at the user's own explicit request - a genuine safety trade-off, not a cosmetic change, worth stating plainly rather than folding into a routine changelog line. Two independent implementations of the same behavior existed - the firmware's own watchdog (
microscope_uno4.ino'sloop(), forcing the LED off and the cover closed whenever no command had arrived for over a second) and the bridge node's own parallel version at the ROS layer (microscope_bridge_node.py's_on_timer(), forcing the same values into every command frame it sent whenever the ROS command stream itself went stale) - both found and removed together; removing only one would have left the other silently reintroducing the exact behavior being removed; the firmware has no way to distinguish a genuine operator command from the bridge node's own override.What this actually means: the LED and cover now stay in whatever state they were last explicitly commanded to, indefinitely, even if the serial link between the bridge node and the Uno drops, or the ROS command stream from the web GUI stops entirely. Previously, either kind of link loss would have automatically turned the LED off and closed the cover within about a second. If the LED is left on or the cover left open when a link drops now, nothing in this software will turn it off or close it on its own - that's on the operator (or whatever's upstream) to notice and handle. The focus stepper was never part of this fail-safe either way - it already just holds its last commanded position on a dropped link, with no separate timeout logic of its own, unaffected by this change.
Removed cleanly, not just disabled:
kWatchdogTimeoutMs/lastCommandMillisin the firmware,command_timeout_sec/_last_command_time(and the now-unusedtimeimport) in the bridge node, and thecommand_timeout_secconfig parameter inmicroscope_topology.yamlare all gone, not left declared-but- unused - a note in that yaml file explains the parameter's absence explicitly, since every other board's own config still has one and its absence here could otherwise look like an oversight rather than a deliberate choice. -
Microscope: most of a multi-part request turned out to already be done, verified rather than assumed. Asked for: remove ServoEasing from the cover servo, add explicit OPEN/CLOSE cover buttons, add explicit LED ON/OFF buttons, and express LED dimming in 0-5V rather than raw PWM. Checked the actual current state of each part before doing any work rather than trust memory of what had been built: the ServoEasing removal (firmware header explicitly records it as already done "at the user's own request"), the cover buttons, and the LED on/off buttons were all already fully implemented and wired up, in both
app.jsandindex.html. Only the 0-5V display change was genuinely outstanding.The LED change is display-only, not a wire-protocol or hardware change - the microscope's LED is still driven by plain
analogWrite()PWM (0-255 duty cycle), not a true analog output or DAC; nothing in the request mentioned new hardware to enable one. 0-255 duty cycle on a 5V logic pin genuinely is 0-5V of effective average output, so relabeling the existing range this way is accurate, not approximate. Changing the wire protocol itself to carry millivolts would have meant a firmware-side conversion back to PWM for no real gain, and could misleadingly imply a precision the hardware doesn't actually have - soled_pwmstays exactly what it was (0-255) all the way from the slider's own raw value through the wire protocol toanalogWrite(); only the label the operator sees changed, via a small sharedformatLedVoltage()helper used consistently by the slider, both buttons, and the telemetry panel's own LED readout, rather than four separate copies of the same conversion. -
Base: a second FZ0430, splitting one shared voltage reading into two independent rail readings. Requested as "add a second FZ0430 with the same configuration... designate the first for driving current and the second for steering current" - the FZ0430 measures voltage, not current, so this was confirmed rather than guessed at before any code changed: the user's own follow-up, "I confirm my mistake; I meant the voltage capture," settled it as two voltage rails, not current sensing (which would have meant a different part entirely, like the ACS712 or INA226 already used elsewhere in this project).
The existing FZ0430 (A0) is now explicitly the drive motors' own supply rail - previously just a generic "main supply" reading, since there was only ever one rail to report before this. A second, identically-configured FZ0430 (A1, same 5:1 divider, same conversion math) reports the steering servos' own supply rail. This is a genuine rename, not just an addition:
BaseState.msg'ssupply_voltage_mvbecamedrive_voltage_mv, withsteering_voltage_mvadded alongside it - every layer (firmware, protocol, message, bridge node, web GUI) updated together for the rename, not just the new field, including proactively checkingros_bridge.py's own state-capture callback before it could become another instance of the same bug class prior sessions have repeatedly found there.Two other pre-existing, unrelated staleness issues were caught and fixed while working in this same firmware section:
base_protocol.py's own 'D' frame docstring still said steering was sent to "direct Mega pins A4-A7, no PCA9685," despite that migration happening several sessions ago; and the voltage section's own top-of-block comment still said "voltage sensor" (singular) after this session's own second sensor was added, missed in an earlier pass through the same edit. Fixed both rather than let stale text stand next to newly correct code. -
Board-status lamps could never actually show a "connected but receiving garbage" state, on any board, until now — found while investigating a report of missing power-panel telemetry. The green lamp's own meaning had been misleading the whole time:
connectedbecomestruethe moment a serial port opens successfully, completely independent of whether any valid frame has ever been received - that's a separate counter (rx_frame_count) entirely. A three-state helper function (boardStatusPillClass(), fault/ warn/ok) already existed inapp.js, correctly written, but was never actually called anywhere - the real lamp-rendering loop used a simpler connected-only check instead, so a board silently producing nothing but checksum errors (most plausibly a/dev/rover/Xsymlink resolving to the wrong physical device - a real risk anywhere two boards share a VID:PID;power_uno6now shares this risk with mast, microscope, and antenna, all four genuine Unos reporting the identical 2341:0043, see that board's own bullet above for the history) would show exactly the same plain green as a board that's genuinely working, with nothing to visually distinguish the two.Wired the existing function into the actual rendering loop, added the missing CSS for the state that never had it (
--amber, already an established color in this project's palette, reused rather than introducing a new one), and surfacedchecksum_error_countdirectly in the lamp's own meta text (e.g. "12 rx, 340 bad") so the distinction is visible without needing to check a ROS topic directly. This applies to every board in the project, not just the one whose report surfaced it - any board that's ever been "green but not actually working" was experiencing exactly this same gap. -
power_nano6.inofailed to compile in the Arduino IDE: "error: 'BatteryReading' does not name a type" — a real, documented Arduino toolchain bug, not a mistake in the code as written. The struct itself was correctly defined, with its semicolon, before its first use - reading the file top to bottom, a human would see nothing wrong. The actual cause: the Arduino IDE/arduino-cli automatically generates forward declarations (prototypes) for every function in a sketch and inserts them immediately after the file's#includelines, before any type declared later in the file - including a custom struct that's fully defined earlier than the function using it, from the actual source file's own perspective. This is a known, long-open toolchain issue (seearduino/arduino-cli#2696andarduino/Arduino#8014/#8050, the oldest reports going back over a decade), not specific to this project or this session's code.Fixed by removing the custom
BatteryReadingstruct entirely and rewritingreadBattery()to use output parameters (int32_t &voltageMv, int32_t ¤tMa) instead of returning a struct by value - every type in the function's signature is a built-in type already known before any auto-generated prototype could need it, so there's nothing left for the toolchain's ordering bug to trip over. The officially documented workaround (move the struct to a separate header file, since the auto-prototype step only scans the.inofile itself) was available too, but would have introduced a multi-file sketch structure no other board in this project uses - the output-parameter rewrite fixes the same problem while keeping every board's firmware a single, self- contained file.Swept every other firmware file afterward for the same pattern (any custom
struct/enumused as a function parameter or return type, not just declared for local variables) rather than assume this was an isolated case - found none; everyenumelsewhere in this project is only ever used forint8_t-typed variables and their own enumerator constants, never in a function signature, so none of them were ever at risk of this same failure. -
New subsystem: power/environmental monitoring, board #6 — two 24V batteries' own voltage and current, onboard computer temperature, and that computer's automatic cooling fan. The sixth board in this project, and structurally different from the other five: it has no command message at all. Every other board's bridge node sends commands and receives state in response; this one only ever receives, since there's nothing for an operator to command here - both sensors are read-only and the fan is fully automatic, matching every other fan in this project. The firmware proactively sends its own state frame every ~200ms rather than reactively, in response to an incoming command, the way every other board's
sendStateFrame()is triggered - seepower_uno6.ino's own header comment for the reasoning.UPDATED: the original FZ0430 + ACS712 sensor suite was replaced with two INA226 voltage+current monitors behind a TCA9548A I2C multiplexer, after the FZ0430's real safety concern (a hard 25V ceiling that a nominal 24V pack could plausibly exceed in normal operation - see the earlier version of this bullet's own history in
docs/journal.mdfor the full original reasoning) led to a direct recommendation for the INA226 instead: its 0-36V bus range gives genuine headroom above the ~28-29V worst case a 24V pack can realistically reach, not a replaced-but-still-tight margin. This also gives each battery its own current reading for the first time, rather than the single ACS712's combined draw across both - a real, if secondary, improvement that fell out of the switch rather than the primary reason for it.Why a TCA9548A multiplexer, given the INA226 already supports 16 addresses via its own A0/A1 pins: both INA226 units in this design sit at their shared, unmodified default address (0x40, both address pins grounded) - the mux is specifically what makes two identical, off-the-shelf breakouts workable together on one bus, without needing to reconfigure either one's address pins. The mux itself defaults to 0x70, unjumpered - see the next bullet for why that number specifically needed its own explicit callout.
0x70 now appears in this project for two unrelated reasons, on two physically separate boards — the PCA9685's own built-in "All Call" address on the base board (see that bullet above), and now the TCA9548A's own default address on the power board. These don't conflict with each other in any way - separate chips, separate Arduinos, separate I2C buses - but 0x70 has already caused one real, documented confusion in this project (an earlier session mistakenly jumpered a PCA9685 toward 0x70, precisely the address that should never be a device's primary one), so a second, unrelated appearance of the same number is named explicitly here and in
power_uno6.ino's own header, rather than left as a coincidence someone has to notice on their own later.A real, must-verify hardware concern, not routine calibration: the INA226 measures current via the voltage drop across an external shunt resistor, and its own shunt-voltage measurement range is hard-capped at ±81.9mV. Many generic INA226 breakouts ship with a fixed, onboard 0.1Ω shunt - with that value, the maximum current such a board could ever report before its shunt voltage saturates is 81.9mV / 0.1Ω ≈ 0.82A, far below what this rover's batteries actually need to measure (the ACS712 this replaced was rated for 30A).
kInaShuntOhmsinpower_uno6.inois set to 0.002Ω - a smaller, higher-current-range value some INA226 breakouts use specifically for this reason - as a placeholder, not a verified fact about the actual boards in hand. Check what shunt resistor your specific breakout boards actually have before trusting any current reading beyond a rough one - if readings clip at a suspiciously round, low number regardless of real load, this is almost certainly why, not a firmware bug.Library: INA226 (by Rob Tillaart, MIT licensed) - confirmed directly from the library's own repository page, not assumed. No dedicated library for the TCA9548A itself: channel selection is a single I2C write of one byte (
Wire.beginTransmission(0x70); Wire.write(1 << channel); Wire.endTransmission();), simple enough that a library would add a dependency for less code than it saves, the same reasoning this project has applied to other single-purpose I2C writes elsewhere.UPDATED: this board is now an Arduino Uno, not a Nano. Swapped after repeated, unresolved hardware trouble with the Nano units in hand - the user's own words, "with which we have often had difficulties," without a single, isolated root cause identified (the missing-telemetry investigation two sessions ago found the code correct at every layer; the compile error before that was a genuine Arduino toolchain bug, not the board's fault - neither incident actually indicts the Nano hardware itself, but repeated, hard-to-pin-down trouble with a specific batch of units is a legitimate reason to just swap them, whether or not any single session's root cause was ever conclusively the board). No pin reassignment was needed for the swap: the Nano and Uno share the same ATmega328P, the same D2-D13 digital range, the same D3/D5/D6/D9/D10/D11 PWM pins, and the same A4/A5 I2C pins - every pin this board's own firmware actually uses. The Nano's two bonus analog-only pins (A6/A7), which the Uno lacks, were never used by this board's sensor suite either way, so nothing was lost.
The CH340-clone-vs-GPS-adapter USB collision risk from the original Nano build is retired by this swap, not carried forward
- that risk was specific to the Nano's own USB-serial chip
situation (a genuine FTDI-based Nano vs. a common CH340-based clone
reporting completely different VID:PIDs). A different, but
already-documented and already-understood, collision risk takes
its place: this board now reports the same VID:PID (2341:0043)
as every other genuine Arduino Uno in this project. Found while
updating the udev rules for this swap that this was actually a
fourth instance of that exact situation, not a third - mast,
microscope, and antenna were already sharing this VID:PID, but an
earlier version of the mast's own udev entry had a real, separate,
previously-uncaught bug: it matched
idProduct0042(the Mega's ID) with a placeholder literally namedMAST_MEGA_SERIAL, left over from before the mast itself became an Uno many sessions ago. As written, that rule could only ever have matched base's or arm's own Mega - never the mast's actual hardware. Fixed alongside this session's own change, not left standing next to it.
- that risk was specific to the Nano's own USB-serial chip
situation (a genuine FTDI-based Nano vs. a common CH340-based clone
reporting completely different VID:PIDs). A different, but
already-documented and already-understood, collision risk takes
its place: this board now reports the same VID:PID (2341:0043)
as every other genuine Arduino Uno in this project. Found while
updating the udev rules for this swap that this was actually a
fourth instance of that exact situation, not a third - mast,
microscope, and antenna were already sharing this VID:PID, but an
earlier version of the mast's own udev entry had a real, separate,
previously-uncaught bug: it matched
-
Arm: a real driver-enable race condition, found while answering a question about the state machine's behavior, not while looking for bugs specifically.
arm_mega2.ino'shandleJointCommand()used to callsetDriversEnabled(enable)unconditionally, before its ownhomed/homingInProgressgate -mast_uno3.ino's andantenna_uno5.ino's equivalent handlers already gate this correctly (setGimbalEnabled/setHeadEnabledonly insideif (homed)), a fix built specifically to prevent this exact problem several sessions ago on those two boards. The arm's own handler predates that fix and never received the equivalent treatment.arm_bridge_node.pysends 'A' frames continuously at its own control rate, including while homing is in progress - by design, and normally harmless, since the frame'senablefield is meant to be ignored during homing. But with the gate in the wrong place, it wasn't ignored: the bridge's own default (enable=False, until an operator has actually touched the arm panel) meant every regular frame arriving mid-homing would immediately undo theenable=truestartHoming()had just set. On a real, previously-untouched boot, this meant the drivers could be toggled enabled (once, bystartHoming()) and disabled (repeatedly, by every regular frame arriving during the entire seek) throughout homing - not just an inert flag flipping back and forth, but the physical drivers actually losing power whileAccelStepperkept counting step pulses as if they'd been executed, risking the exact kind of position-tracking corruption per-joint homing exists to establish in the first place.Fixed by moving
setDriversEnabled()inside the same gate the joint-target application already used, matching mast/antenna's pattern exactly rather than inventing a new one. Also corrected the bridge node's own comment, which had asserted the firmware already handled this correctly - it didn't, and a comment describing intended behavior as already-true is worse than no comment once it's wrong. -
Microscope's driver enable/disable button never actually worked, since the day it was built — the other three (mast, antenna, arm) did, and were verified to, not assumed to. Reported as "enable/ disable doesn't physically work" without specifying which subsystem, so every driver-enable path got checked end to end (web GUI →
server.py→ros_bridge.py→ bridge node → wire protocol → firmware) rather than guessed at: arm's, mast's, and antenna's chains were all confirmed correct at every layer, including firmware-side gating logic and each bridge node's command-resend behavior. Microscope's wasn't.app.jshad been correctly sendingdriver_enablein every microscope command since the button was built — the bug was entirely on the backend:server.py's WebSocket dispatch for"microscope"never read it from the payload, andros_bridge.py'ssend_microscope()didn't even accept it as a parameter. The value was silently dropped before ever reaching the ROS message, which meant every microscope command carrieddriver_enable=False(a ROS bool field's own default) regardless of which button was clicked or what the button's label said. Clicking "enable" updated the button's own text and toggle state — which is why it could look like it was working — without ever actually being able to energize the driver, since the one value that mattered never left the browser. Confirmed the rest of the chain (bridge node, firmware) was already correct and just waiting for a real value to arrive - fixed at the two points responsible, not worked around further downstream. -
Mast: automatic, thermostatically-controlled cooling fan — a generic N-channel MOSFET driver module (IRF520-style: SIG/VCC/GND control header, V+/V- screw terminals for the load), entirely closed-loop against the mast's own DS18B20 reading, not operator-commanded — no field for it on
MastCommand, only telemetry onMastState(fan_duty_percent, 0-100).Wiring is a low-side switch, worth being explicit about since it's a common mix-up: the fan's positive lead goes directly to the external supply (the module's V+/Vin, permanently on), and the MOSFET switches the fan's negative lead (through V-/OUT) to ground — not the fan's positive side the way a more intuitive high-side design might suggest. The module's VCC pin only powers its own onboard status LED and can be left disconnected; only GND and SIG are actually required for switching.
No hardware PWM pin was available for this — the mast Uno's entire PWM budget (D3/D5/D6/D9/D10/D11) was already committed to the yaw/pitch/lift functions before this was added.
kFanPwmPin(A2) runs a simplemillis()-based software PWM instead (updateFanPwm(), 50Hz) rather than force a conflict — a MOSFET module switching a slowly-responding thermal load doesn't need hardware-timer precision the way a stepper pulse train does.Thermostat thresholds are placeholders (
kFanOnTempDeciC=35°C,kFanOffTempDeciC=30°C,kFanMaxTempDeciC=50°C,kFanMinDutyPercent=30%) — bench-tune once the real enclosure's actual thermal behavior is known, the same as every other uncalibrated constant in this project.kFanOffTempDeciCsits belowkFanOnTempDeciCdeliberately (hysteresis): once running, the fan stays on until temperature drops to the lower threshold, not the one it turned on at, to avoid rapid on/off cycling right at a single boundary. BelowkFanOnTempDeciCthe fan is off; between the ON and MAX thresholds its duty cycle ramps linearly, clamped to at leastkFanMinDutyPercentonce running — many small DC fans won't reliably start or stay spinning much below that, so this avoids a smooth ramp landing right at a near-zero, potentially- stalling duty cycle.A deliberate fail-safe choice, not an oversight: if the DS18B20 itself isn't responding (
cachedTemperatureDeciCat its own-9999sentinel), the fan defaults to running atkFanMinDutyPercentrather than staying off. Unnecessary fan noise is a minor cost; an overheating board with no way to know it's overheating is a real one — the two failure modes aren't symmetric, so the default shouldn't be either.Found and fixed while documenting this, not introduced by it: all four protocol files, all four
.msgfiles, and oneros_bridge.pystate callback still described the temperature sensor as a BMP280 over I2C — leftover language from before that sensor was replaced with the DS18B20 two sessions ago, missed at the time because the swap's own review focused on the firmware and the primary docs, not every inline field comment referencing it. Fixed all of it, not just the mast's own copy. -
Temperature sensor on base, arm, mast, and antenna (not microscope) is a DS18B20, not a BMP280 — the BMP280/I2C version documented in an earlier session was fully replaced, not layered alongside; nothing about the temperature feature itself changed (same field, same wire format, same sentinel convention, same web GUI display), only the sensor hardware and the firmware talking to it.
DS18B20, TO-92 package, communicates over 1-Wire on a single digital data pin rather than I2C's two-pin shared bus — no dedicated hardware pin requirement the way I2C's TWI peripheral has, so pin choice was open on all four boards. Reused each board's now-freed former I2C SDA pin rather than pick a new one (Mega: pin 20, Uno: A4) — avoids leaving a pin idle for no reason. Requires an external 4.7kΩ pull-up resistor between DQ and VDD — a real, physical component to add, not optional and not achievable via the Arduino's internal
INPUT_PULLUP(documented as too weak to hold reliable 1-Wire bus timing).Libraries: OneWire (Paul Stoffregen, MIT) and DallasTemperature (Miles Burton, LGPL-2.1) — a third license in these firmware files alongside this project's own Apache-2.0. LGPL is generally more permissive than GPL-3.0 about linking against differently-licensed code, but that distinction matters less for a statically-linked, monolithic firmware binary than for a desktop application dynamically linking a shared library — worth its own explicit licensing review regardless, not waved off as equivalent to OneWire's own MIT license just because they're installed together. (This bullet used to also compare against ServoEasing's GPL-3.0 on the base board specifically - stale even before this session, since base's steering moved off ServoEasing several sessions ago, and the library is gone from this project entirely as of this session; see the dedicated servo-easing bullet elsewhere in this section.)
Read via a non-blocking two-phase state machine, not the library's own default blocking pattern. The DS18B20's conversion takes up to ~750ms at full (12-bit) resolution — the library's own documented example simply calls
delay(750)between requesting a conversion and reading it, which would stallloop()for 750ms on every read on boards whose actual job is real-time motor control. Instead:requestTemperatures()(non-blocking, viasetWaitForConversion(false)) starts a conversion and returns immediately; a separate check on a laterloop()iteration reads the result once the worst-case conversion time has elapsed, without ever blocking. Still on the same outer 1-second cadence as before (kTemperatureReadIntervalMs) for starting a new cycle — the underlying reason is unchanged: board temperature moves over seconds-to-minutes, not tens-of-milliseconds, so there's nothing to gain from polling faster than that regardless of how fast any given read technically could be.A genuine improvement over the BMP280 version, not just a like-for-like swap: DallasTemperature reports a specific
DEVICE_DISCONNECTED_Csentinel on any read that fails, checked fresh on every cycle — unlike the BMP280 version's one-timebmp.begin()check at boot, a DS18B20 that's disconnected and later reconnected recovers on its own next read, with no reset needed. The wire-level sentinel this maps to (-9999, i.e. -999.9°C) is unchanged from before, still comfortably outside the DS18B20's real range (-55 to +125°C, wider than the BMP280's own -40 to +85°C, for what it's worth) — the web GUI'sformatTemperature()needed no changes at all to keep rendering it as a plain "N/A". -
Two real, pre-existing bugs found and fixed while building the antenna's web GUI/Xbox plumbing, not introduced by it — both meant the mast's
driver_enablefield, added a couple of sessions back, never actually worked outside the one path that happened to get tested by hand:- The web GUI's mast panel sent
driver_enablein its WebSocket payload, butserver.py's dispatch never read it andros_bridge.py'ssend_mast()didn't even accept it as a parameter — the value was silently dropped before ever reaching the ROS message._on_mast_state()'s telemetry capture had the same gap in reverse (never readdriver_enabledback out of the state message), so the web GUI'sDRIVER: ENABLED/DISABLEDreadout was always showingDISABLEDregardless of the real state. Fixed in both directions. - Separately,
xbox_teleop_node.py's_handle_mast()never setcmd.driver_enableat all, leaving it at its unset default (False, a ROS bool field's default). Since the firmware gates movement and enable/disable on the same command, this meant Xbox controller mast control was completely non-functional sincedriver_enablewas introduced — every joystick command disabled the drivers at the exact moment it tried to move them. Fixed by settingdriver_enable = Truewhen actively jogging, matching how_handle_armalready setscmd.enable = Truefor the same reason. Applied the same pattern to the antenna's own_handle_antennafrom the start, rather than propagate the gap into new code.
- The web GUI's mast panel sent
-
A third instance of the same underlying bug class, this one only found by actually running the system, not caught in review —
ros_bridge.py'sget_snapshot()is a hand-constructed dict listing each subsystem's key explicitly, and "antenna" was simply missing from it, even thoughself._state["antenna"]was being correctly populated by_on_antenna_state()the whole time. The data existed server-side and never reached the frontend — every other piece (the telemetry panel's HTML, the JS rendering code, the ROS subscription) was correct in isolation, which is exactly why static review across several passes never caught it: nothing was individually wrong, one dict just never got the new key added to it. Fixed, and added a comment linkingget_snapshot()toself._state's own declaration explicitly, so the next subsystem addition has an actual pointer to keep these two in sync rather than relying on remembering to. -
A fourth instance, same bug class again, this time affecting every subsystem's new
board_temperature_decicfield at once — when that field was added (base, arm, mast, antenna), the ROS message, bridge nodes, and frontend rendering all got updated correctly, butros_bridge.py's four_on_X_state()callbacks — the code that actually unpacks an incoming ROS message into the dictget_snapshot()later serves — were never touched. The field existed on the wire and in the ROS message; it just never got copied out of it. Reported by the user as "antenna telemetry appears, but temp is missing" — checking confirmed it was missing from all four panels, not just the one that happened to get noticed first. Fixed all four, and extended the existing sync comment (see the bullet above) to name this specific class of callback as a third place that has to stay in sync, not just the twoget_snapshot()/self._statealready called out — this bug was in exactly the kind of spot that comment didn't yet cover. -
A fifth instance, this one the actual explanation for what turned out to be two sessions of "the Antenna label is missing" reports —
app.js'sBOARD_NAMESarray (four hardcoded[board_name, display_label]pairs, driving the left rail's board-status lamps) was never given a fifth entry for the antenna when that subsystem was built. Not a rendering bug, a CSS issue, or a stale cache, all of which got ruled out first, thoroughly, across the two prior reports — antenna's board-status lamp didn't render in the wrong state, it simply never existed, since the loop that builds each lamp row iterates this exact array and had no fifth board to iterate over. The backend side was already correct the whole time (rover_antenna/board_statuswas properly subscribed to and published), which is exactly why nothing on that side ever looked wrong under investigation — the gap was purely in this one frontend array. Confirmed the fix's key (antenna_uno5) matches exactly whatantenna_bridge_node.pyactually publishes before wiring it in, the same care this project has taken with board-name string matches since the mast's own Mega-to-Uno migration first surfaced this exact failure mode. Swept the rest ofapp.js/ros_bridge.py/server.pyfor any other hardcoded four-board list afterward rather than assume this was the only one — found none. -
A sixth instance, in
index.htmlitself, missed by that same sweep — asked directly ("did you also forget index.html on the left rail?") after the fifth instance above, because that sweep only coveredapp.js/ros_bridge.py/server.pyand, in hindsight, had no real reason to have covered the HTML too when the bug it was chasing was in a JS array. It should have swept wider.index.htmlhas its own four static, hardcoded lamp rows for exactly the same left-rail list — a design detail worth understanding correctly:app.jsclears and fully rebuilds this container (lampList.innerHTML = "") on every telemetry update, so in normal operation with JS running these static rows are invisible, replaced before a user ever sees them. That does not make leaving them wrong acceptable — they're still incorrect markup, they're what actually renders if JS fails to load or errors out before this code runs, and the file should describe the real page regardless of whether something else immediately overwrites it. Fixed by adding the missing fifth row. Also found and fixed a second, unrelated, pre-existing staleness in the same four rows while there: the mast's saidMAST / MEGA #3, unchanged since long before the antenna ever existed — the mast became an Uno many sessions ago, andapp.js's ownBOARD_NAMESarray had already been corrected toMAST / UNO #3at the time, just never mirrored back into this static duplicate. A concrete example of why keeping duplicated content in sync needs an explicit reason to trust it has been, not an assumption that it has. -
Mast: "RETURN HOME" and "TRANSPORT POSITION" web GUI buttons — both are ordinary
MastCommands (yaw/pitch only,lift_modeuntouched), not new firmware or protocol capability: Return Home sends 0°/0°, Transport Position sends whatever's configured inrover_mast/config/mast_topology.yaml's newtransport_head_yaw_deg/transport_head_pitch_deg(placeholder 0.0/0.0 — dead ahead, level — until a real safe transport orientation is known; tune those two values once it is, no code change needed either way). Deliberately doesn't also command the lift — sequencing "re-center the head, then stow" vs. "stow, then re-center" is left to the operator via the existing Stow button, rather than baked into one fixed order. -
max_head_yaw_deg/max_head_pitch_degdeduplicated — found while adding the above: this pair was declared independently in bothrover_mast/config/mast_topology.yaml(unused there —rover_mast_bridgenever actually read it) androver_teleop/config/xbox_teleop.yaml(the one actually used, for MAST-mode stick-to-angle scaling), with no shared source of truth between the two copies. Consolidated intomast_topology.yaml's own/**:shared block — the same fixdrive_sensitivity.yamlalready applied tomax_linear_mps/max_angular_radps/deadzone— andxbox_teleop.launch.pyupdated to load it from there instead.rover_web_guinow loads this same file too, for the transport position values above. -
Mast pitch range corrected: ±60° was wrong, real range is ±180° — confirmed against real hardware while adding the post-calibration sequence below (needed a genuine target position, 170°/180°, that turned out to exceed the old placeholder). Updated everywhere that figure was used:
mast.xacro's joint limit,mast_topology.yaml'smax_head_pitch_deg,joy_mapping.py's fallback default, the web GUI's pitch slider range, and — because pitch is now the larger of the two ranges (360° total vs. yaw's 340°) — the reasoning behindmast_uno3.ino'skHomingMaxTravelStepssafety margin, which used to size itself specifically against yaw being the bigger one. The numeric margin (500°) still comfortably covers the new range without needing to change, but the comment explaining why did. -
Mast: calibration now offsets to each axis's minimum, then drives to true zero from there — not "zero at the switch, then a separate verification move" — corrected from an earlier version of this same feature (see
docs/journal.mdfor that history) that had it backwards: each limit switch is physically at that axis's minimum bound (-170° yaw, -180° pitch — real, bench-confirmed values, not placeholders), not its center, soserviceHoming()now recognizes triggering it as reaching that minimum (setCurrentPosition()to the minimum in steps, not to 0), then drives from there to true zero. Only once both axes actually arrive doeshomedbecome true — this is genuinely what "home" means here, the centered position reached from the minimum, not the minimum itself — and the TB6600 drivers disable themselves (servicePostCalibration(), now a single move-to-zero phase instead of the previous two-phase verify-then-zero).MastCommand'sdriver_enablefield and the web GUI's matchingCLOSE DRIVER (ENABLE)/OPEN DRIVER (DISABLE)toggle are unchanged by this correction — both are still needed so the mast stays usable after the sequence auto-disables it.The gating logic this required is unchanged too, and the reasoning behind it matters enough to spell out regardless of which version of the sequence is running:
rover_mast_bridgesendsMastCommandframes continuously at its own control rate once past its one-shot homing request — using whatever's in its last-known command, which starts as all-zero/disabled before the operator has ever touched the mast panel. Applyingdriver_enableunconditionally on every frame (the way the arm's own enable field works) would disable the drivers mid-seek or mid-move-to-zero from nothing more than the bridge's own routine resend — indistinguishable at the protocol level from a genuine command — stranding a stepper that can't move de-energized. Bothdriver_enableand yaw/pitch targets are gated on a single check now (if (homed)), which is enough on its own:homedis already false for the entire seek-then-move-to-zero sequence, so there's no separate "is a sequence currently active" flag needed the way an earlier version of this required. -
Base steering servos: per-corner calibration, not one shared set —
base_mega1.ino'skServoMinUs/kServoMaxUs/kServoNeutralUsbecame 4-entry arrays (FL, FR, RL, RR), replacing one set of constants shared across all four. The file's own comment had always said to "bench-test each servo," but the code only ever supported tuning them all identically — real 40kg servos, even same model and batch, vary enough in actual center/travel that a shared calibration was only ever a starting point. Fixed a real accuracy bug in the same pass: the angle-to-pulse-width conversion always scaled off the max-side span (kServoMaxUs - neutral), even for negative angles, which is only correct if neutral happens to sit exactly centered between min and max. A real bench-calibrated servo often won't - using the wrong span for negative angles doesn't just misreport the angle, it silently shrinks the achievable range on whichever side is shorter. Now uses the min-side span (neutral - kServoMinUs) for negative angles and the max-side span for positive ones. All four corners still start at identical placeholder values (600/2400/1500us) - intentional, not an oversight, until each is actually bench-tested. -
Servo movement: eased, not instant, on both axes that move a physical servo — and, as of this session, neither one via ServoEasing anymore. The microscope's lens cover moved off ServoEasing too, at the user's own explicit request - unlike the base's steering servos below, this wasn't a technical necessity (ServoEasing genuinely can't drive a PCA9685; the cover servo is still direct-pin, so ServoEasing itself would have kept working fine here) but a deliberate choice. Smoothed movement was preserved anyway, reimplemented as the same kind of small, non-blocking custom ramp (
updateCoverEasing()inmicroscope_uno4.ino, mirroringbase_mega1.ino's ownupdateSteerEasing()almost exactly) rather than dropped to an instant snap - this project has consistently valued eased servo motion, and removing one specific library was never a reason to lose it.kCoverEaseSpeedDegPerSec= 60°/s (slower on purpose, since the cover is a rarely-triggered binary toggle rather than a continuously-recommanded value under responsiveness pressure) is unchanged in value and meaning, just read by a different mechanism now - still a placeholder like every other uncalibrated constant in this project, pending bench-tuning.The base's 4 steering servos moved off ServoEasing entirely when they moved to a PCA9685 (see the dedicated bullet on that below) - ServoEasing doesn't work with an I2C PWM driver, only direct-pin, standard-Servo-library-style control. Smoothed movement was reimplemented as a small, non-blocking custom ramp (
updateSteerEasing()inbase_mega1.ino) rather than dropped - every corner's commanded position moves toward its latest target at a fixed rate (kSteerEaseSpeedUsPerSec, 3000us/sec - a direct translation of the previous 300°/s ServoEasing rate into this board's own pulse-width terms, not a re-guess) eachloop()iteration, rather than jumping there in one write. Both axes still share the same underlying fix from when steering first got its own per-corner calibration (previous bullet): a servo's actual physical position at power-on is unknown to either mechanism, so both seed their own tracking at each servo's calibrated neutral/closed position and write it immediately at boot, rather than let the first real command jump from an assumed, possibly-wrong starting point.Licensing note, stated plainly rather than as a legal opinion: ServoEasing is GPL-3.0, while the rest of this project's ROS packages are Apache-2.0 - this used to be worth flagging per-board (base's steering lost it several sessions ago; the microscope's cover kept it until this one). As of this session, ServoEasing no longer appears anywhere in this project at all - confirmed directly (
grep -rn "ServoEasing" firmware/) rather than assumed from memory. A firmware binary that compiles in GPL-3.0 code is generally itself subject to GPL-3.0's copyleft terms if that binary (or the sketch producing it) is ever distributed to others - this concern is now retired for this project's firmware entirely, not just reduced on one board. -
Microscope: cover open/close became two explicit buttons, not one toggle; LED gained on/off buttons alongside its existing slider. Both requested together with the ServoEasing removal above. The cover's web GUI control changed from a single button that flipped its own label and state each click to two separate, stateless buttons (
OPEN COVER/CLOSE COVER) - matching this project's own established pattern for a binary physical state (see the mast's ownERECT/STOWbuttons for the precedent this follows) rather than inventing a new one. The actual current state is read from the telemetry panel's ownCOVERfield, not tracked by which button was clicked last.LED PWM interpretation, stated explicitly so it can be corrected if wrong: requested as "change the PWM function to 0~5V for dimming" - standard Arduino PWM on a 5V board already swings between 0V and 5V (duty-cycle-modulated, not a filtered analog level), which is exactly what this board's existing
analogWrite()-based LED dimming already does. Read as describing that existing mechanism rather than requesting new filtering hardware (an RC low-pass filter or similar, to produce a true analog DC level rather than a switched signal) - no firmware or wiring change was made to the LED output itself, only the web GUI gainedLED ON/LED OFFbuttons alongside the existing brightness slider, setting it to 255 or 0 respectively and keeping the slider's own displayed value in sync either way it's driven. Flag this back if an actual analog dimming circuit was intended instead - that would be a real hardware addition, not a firmware-only change. -
Base steering: moved from direct Mega pins to a PCA9685, reversing an earlier explicit decision — an earlier session's own header comment said plainly "no PCA9685 (middle wheels are fixed)"; this session added one back, deliberately.
base_mega1.ino's 4 steering servos now connect to a PCA9685 16-channel I2C PWM driver (channels 0-3 -> FL/FR/RL/RR) instead of Mega pins A4-A7 directly.CORRECTED: address is 0x40 (factory default, unjumpered), not 0x70 as an earlier version of this same entry said — a real mistake in that earlier session, caught by the user's own I2C scan turning up 0x70 on a board that hadn't been jumpered yet. 0x70 isn't a normal, jumper-configurable slave address on this chip at all - it's the PCA9685's built-in "LED All Call" address, enabled on every unit at power-up regardless of A0-A5 jumper state (stated directly in the chip's own datasheet: "the default LED All Call I2C-bus address... must not be used as a regular I2C-bus slave address since this address is enabled at power-up"; Adafruit's own FAQ says it more plainly still - "set it to 0x71 or anything other than the default 0x70"). This project only ever has one PCA9685, so there's no reason to move it off 0x40 at all - no jumpers to solder,
kPca9685Addressin firmware matches the factory-default address directly.A real pin conflict had to be resolved, not just a new pin chosen: I2C on a Mega is hardwired to pins 20 (SDA) and 21 (SCL) - not configurable the way 1-Wire is - and pin 20 was already committed to the DS18B20's own data line (moved there in an earlier session specifically because it was the Mega's former, then-unused SDA pin). Moved the DS18B20 to A4 instead - one of the pins the steering servos vacated moving to the PCA9685 - rather than search for a third free pin or leave a genuine conflict unresolved.
The PCA9685's V+ terminal restores a dedicated servo power rail that the direct-pin version of this same steering setup never had and had to call out separately as a gap to fill: wire V+ to its own adequately-rated 5-6V supply (four 40kg-class digital servos can draw well beyond what the Mega's onboard 5V regulator safely delivers), sharing ground with the Arduino, never from the Mega's own 5V pin.
Library: Adafruit PWM Servo Driver Library (BSD licensed, confirmed against the library's own source headers directly rather than trusted to a third-party index that shows "NOASSERTION" for it
- a known quirk of automated license detection against a
non-standard header format, not an actual absence of a license).
writeMicroseconds()needed no rework of the existing per-cornerkServoMinUs/kServoMaxUs/kServoNeutralUscalibration arrays at all - the library's own example code happens to use the identical 600-2400us convention this project's placeholders already used, and the angle-to-microseconds math itself is entirely independent of which mechanism ultimately receives the resulting value.
- a known quirk of automated license detection against a
non-standard header format, not an actual absence of a license).
-
Arm actuators: EBA-17-M planetary gearbox, 120:1, per joint — updates the arm's originally-placeholder 5:1 gear assumption throughout:
steps_per_joint_rev(200 full steps × 1/16 microstepping × 120:1 = 384000,rover_arm/config/arm_topology.yamlandtrajectory_action_server.py's fallback default), the firmware's homing safety-cutoff (arm_mega2.ino'skHomingMaxTravelSteps, scaled to preserve the same ~675° angular safety margin as before), andarm.xacro/joint_limits.yaml's velocity/acceleration limits (recomputed from the firmware's own unchanged motor-shaft speed cap through the new, much higher reduction — not independently chosen). Worth being explicit about the real consequence, not just the numbers: achievable joint speed drops from what the old 5:1 placeholder implied (~45°/s) to about 1.9°/s at the same motor step rate — the genuine tradeoff of a 24× higher reduction ratio (more torque and precision, less speed). All five joints now show the same velocity/acceleration limit rather than the previous per-joint spread, correctly — that spread was never grounded in anything joint-specific, and all five share the same motor + gearbox- firmware speed cap. One more thing worth double-checking on the bench: the publicly documented ratio for the EBA-17/EBA-17-S product line (ToolBoxRobotics) is 38.4:1, not 120:1 — 120:1 is what was specified for the "M" variant actually in use here, but worth confirming against the datasheet/nameplate if these numbers ever look off in practice.
-
User-supplied URDF template: a user-uploaded xacro file (differential/bogie suspension, "head" instead of the pan/tilt mast,
arm_j1-arm_j5joint naming, solar panels, high-gain antenna, mesh-based visuals) was corrected — it had several real bugs independent of anything project-specific: empty wheel-generating macros producing no geometry at all, arm transmissions referencing joint names that didn't exist, a duplicate transmission name, an entire right-side suspension typedfixedwith zero-width limits (permanently rigid) while the left side was fully functional, duplicate chassis links, missing mass/inertia, and 15<material>references with no matching declaration anywhere. The corrected version is real and validated (xacroexpansion plus a full link/ joint/transmission consistency check both pass cleanly) but lives atrover_description/reference/opportunity_style_template.urdf.xacro, not wired into the active model — its wheel/arm architecture and naming conflict directly withrover_base's kinematics and the MoveIt config built in an earlier session, both keyed to this project's actual, hardware-tested joint names and topology. Swapping it in live would have silently broken both. The two genuinely new, non-conflicting pieces from that template — solar panels and a high-gain antenna — were added to the active model (rover_description/urdf/accessories.xacro), as primitive geometry rather than the template's mesh references (no.stlfiles were supplied, and this project's whole model has always used primitives — seerover_description/meshes/README.md). Both arefixedjoints, notrevolute: neither has real actuation hardware or firmware anywhere in this project, so making them look plannable would overstate what's actually there. A full migration to the corrected template's own architecture — if that turns out to be what's actually wanted — is real, scoped, deferred work; see "Known gaps." -
FZ0430 voltage sensors: one per Arduino Mega (base, arm, mast - not the microscope Uno, which wasn't asked for one), on analog pin A0 on each board, reporting the main battery supply in millivolts. "FZ043" in the original request is treated as shorthand for the FZ0430, a common 5:1 resistive-divider breakout (0-25V range on a 5V-logic board) - flag this if a different sensor was actually meant. Conversion math (
(raw / 1023.0) * 25000.0→ millivolts) assumes the standard 5V AREF and the module's documented 5:1 divider ratio; if your specific board's divider resistors measure differently, that's a one-line change to each firmware'sreadSupplyVoltageMv(). Voltage-only, no current sensing - see "Known gaps" for what that would take. -
IMU board: SparkFun VR IMU Breakout - BNO086 (Qwiic). Its factory I2C address is 0x4B, but that's inert here — its PS0/PS1 jumpers must be strapped for UART-RVC mode (not I2C), since it's wired via its UART edge pins to a Waveshare "USB TO TTL (B)" (CH343G), which is a plain USB-to-UART bridge and can't carry I2C at all. If you ever rewire it for genuine I2C, this UART-RVC driver no longer applies and you'd need an address-aware I2C driver instead — and note SparkFun's own docs advise against 8-bit AVR (Uno/Mega) hosts for that chip over I2C; they recommend an ESP32 or SAMD51.
-
Base wheel/steer kinematics: exact instant-center-of-rotation geometric steering, standard differential/skid throttle mixing rather than an exact zero-slip velocity model. See "Base drive modes" above and
rover_base/rover_base/kinematics.py. -
Mast erect/stow: modeled as a single revolute "pivot" joint driven by the lift DC motor (0° = horizontal/transport, 90° = vertical/service), since the spec describes it as "laterally pivoting," not as a linear lift.
-
Mast: Arduino Uno (was a Mega), HW-039 lift driver, yaw/pitch calibration switches — the mast's controller was originally a Mega with no head calibration switches (head zeroed to whatever orientation it powered up in) and an assumed-generic 2-direction-pin H-bridge for the lift motor (no specific driver part number had been given). All three changed together: it's an Uno now, which meant a full pin remap under a genuinely tight budget (11 of the Uno's 12 usable digital pins committed, one spare) since none of the old Mega-only pins (22, 23, 30, 31) exist on an Uno at all; yaw and pitch each got their own calibration switch and now home on startup through a sequential state machine mirroring
rover_arm's exact pattern (mast_uno3.ino'sstartHoming()/serviceHoming()), not a new design invented from scratch; and the lift now drives through an HW-039 (dual BTS7960B half-bridge) module, whose native RPWM/LPWM interface is genuinely different from both the base's DRI0002 boards (single direction pin + PWM) and the previous generic H-bridge assumption (PWM + 2 direction pins) — simplified to a single shared enable pin (R_EN/L_EN tied together) specifically to fit the Uno's pin budget, with R_IS/L_IS (current sense) left unconnected since this application doesn't need them. The lift itself still doesn't home — it has no step-relative position to zero, just directly-read limit switches — so lift commands work immediately regardless of yaw/pitch homing state, same as before. -
Mast yaw/pitch: TB6600 driver (was A4988) — a follow-up to the Uno rebuild above, prompted by a real debugging session: the original A4988 wiring never had an explicit enable pin driven by firmware at all, relying on whatever that specific breakout's floating-EN default happened to be. TB6600's opto-isolated ENA input is less forgiving of being left floating, so
mast_uno3.inonow drives it explicitly (kHeadEnablePin) — shared between both axes, since this commits the very last available Uno pin (13), leaving the board's digital pin budget fully spent (12 of 12). Wiring is assumed common-anode (PUL+/DIR+/ENA+ to +5V, the three "-" lines to the Arduino pins, ENA active-low) — the standard way to drive one of these from 5V logic, but flip the enable polarity in firmware if wired common-cathode instead. TB6600 sets microstepping via DIP switches on the driver rather than firmware, same idea as an A4988's MS1/MS2/MS3 pins but physical rather than software-controlled —kStepsPerDegYaw/kStepsPerDegPitch(already placeholder values, not yet bench-calibrated) need to match whatever those switches are actually set to. STEP/DIR pin assignments themselves didn't change — TB6600's PUL/DIR inputs are functionally identical to STEP/DIR from AccelStepper's perspective, so this was a wiring-and-firmware-enable change, not a pin-remap. -
Arm: mixed drivers — J1-J3 TB6600, J4/J5 still A4988 — unlike the mast's two TB6600s, which share one enable pin, each of the arm's three TB6600 drivers gets its own independent enable pin (
arm_mega2.ino'skTb6600EnablePin[3], pins 13/14/15). This is a real electrical distinction, not inconsistency for its own sake: TB6600's opto-isolated ENA input sinks real current per board (roughly 10-15mA, driver-dependent) when driven active, and ganging three of them onto one Arduino pin risks exceeding a single GPIO's safe sink rating (~20-40mA depending on the chip) — a risk the mast's two-board case didn't carry as heavily, and one the Mega's abundant spare pins (unlike the mast's tightly-budgeted Uno) meant there was no real reason to accept anyway. J4 and J5's A4988s keep sharing one enable pin between the two of them (kA4988EnablePin, still pin 12) — A4988's EN is a simple direct-logic input, not opto-isolated, and safely shareable the same way all five joints used to share this exact pin before the split. One real consequence worth flagging:arm_topology.yaml'ssteps_per_joint_revis still five identical values, but that's no longer guaranteed correct — TB6600 sets microstepping via DIP switches, and there's no guarantee those end up matched to the A4988s' own microstepping setting. If they don't, this needs five genuinely different values, not five copies of one;joint_conversion.pyalready handles that correctly per-joint (see its own tests), so it's a "measure and update the yaml" task, not a code change, whenever the real hardware is bench-verified. -
Microscope focus/zoom: modeled as a single combined stepper axis (as literally specified: "a stepper motor ... for focusing and zooming") rather than two independent axes. No calibration switch either — same power-on-zero convention the mast head used to use, before this session's calibration switches gave it a real one.
-
Microscope: DRV8825 replaces ULN2003, 24BYJ-48 replaces 28BYJ-48 — a real compatibility question, not just a part swap: DRV8825 is a bipolar-only driver, and both the 28BYJ-48 and 24BYJ-48 ship as 5-wire unipolar motors. They're only usable together with a specific wiring technique — connect just the four coil-end wires to the driver's two coil outputs and leave the center-tap (common) wire completely disconnected, effectively treating the motor as a 4-wire bipolar one. This is a documented, real technique, not something improvised for this project, but it's an easy wire to connect by mistake, and doing so can short part of a coil — flagged prominently in
microscope_uno4.ino's header, worth reading before wiring this up. Also requires the 12V-rated 24BYJ-48 variant (DRV8825 needs 8.2-45V on its motor supply; the 5V variant won't work). Upside: this unifies the microscope's stepper control with the arm and mast's own STEP/DIR/ENABLE pattern (AccelStepper::DRIVER), replacing ULN2003's 4-pin phase sequencing — one less pin needed, and a driver with a real enable pin where the old one had none. -
"Open/close driver" read as enable/disable — DRV8825's new enable pin (
MicroscopeCommand.driver_enable) is exposed as a web GUI toggle, labeledCLOSE DRIVER (ENABLE)/OPEN DRIVER (DISABLE)— mapped to the electrical open-circuit (no current, disabled) / closed-circuit (current flows, enabled) convention. Flagging this explicitly since the mapping isn't the first thing everyone reaches for; the parenthetical on each button label exists specifically so the actual effect is never ambiguous regardless of which interpretation someone starts from. Starts disabled at boot, same convention as the arm/mast boards. -
Lens cover open/close — already existed (
scope-coverbutton, full firmware/protocol/GUI wiring) before this session; confirmed rather than rebuilt. -
Focus/zoom presets are client-side only — the 3 "record"/"go to" button pairs remember slider positions in the browser's own JS state, with no firmware, protocol, or backend involvement at all. Deliberately lightweight for what's fundamentally a convenience feature: presets don't survive a page reload, and there's no persistence across sessions. If that turns out to matter, the natural upgrade is moving preset storage server-side (
ros_bridge.py) so it survives a browser refresh — not a firmware change either way, since presets are just rememberedfocus_target_stepsvalues, not a new hardware capability. -
Arm inverse kinematics: now available via MoveIt2 (
rover_arm_moveit_config+rover_arm'strajectory_action_server) — see "Arm motion planning" below.ArmCommanditself is still joint-space (motor steps) only; MoveIt plans in task space and the trajectory action server converts each planned waypoint before it ever reachesrover_arm_bridge, which has no idea MoveIt is involved at all. -
LIDAR/GPS/joystick drivers: reused from the ROS ecosystem (
rplidar_ros/sllidar_ros2,joy) rather than reimplemented — install these separately, see below.
For a full walkthrough (fresh-machine ROS 2 install, rosdep gaps,
Arduino firmware flashing, udev rules, per-subsystem verification,
troubleshooting table) see docs/INSTALL.md. Condensed version:
cd ~/mars_rover_ws
rosdep install --from-paths src --ignore-src -r -yA few rosdep keys are worth a heads-up (mirroring issues hit on this project before):
python3-pynmea2may not resolve via rosdep on all Ubuntu 22.04 setups — if it fails,pip3 install pynmea2directly.python3-fastapi/python3-uvicornlikewise — if rosdep can't find them,pip3 install fastapi uvicorn.rplidar_rosis not in this workspace; clone it (orsllidar_ros2) intosrc/yourself before building, or passuse_lidar:=falseto bringup if you don't have the hardware yet.- Arduino firmware needs the AccelStepper library
from the Arduino Library Manager (no longer ServoEasing as of
this session - it was previously needed for base_mega1's steering
and microscope_uno4's lens cover servo, both of which now use a
small custom easing ramp instead; see README's "Explicit
assumptions" for the history. The standard Servo library, which
ships bundled with the AVR core, is still used directly by both -
nothing separate to install for that part), plus
firmware/common/RoverProtocolinstalled as a library (copy that folder into yourArduino/libraries/directory, or Sketch → Include Library → Add .ZIP Library on it).
colcon build --symlink-install
source install/setup.bash# Everything: base/arm/mast bridges, microscope, sensors, LIDAR, Xbox teleop, web GUI
ros2 launch rover_bringup bringup.launch.py
# Bench-testing without a controller or the LIDAR installed yet:
ros2 launch rover_bringup bringup.launch.py use_teleop:=false use_lidar:=false
# Visualize the URDF alone (no hardware needed):
ros2 launch rover_description display.launch.pyGround control console: open http://<rover-host>:8080/ in a browser.
The microscope panel has an "open in new tab" link
(http://<rover-host>:8080/microscope) for a dedicated, full-page
microscope view with its own snapshot/recording controls, per the
project brief.
Xbox 360 controller: hold RB (deadman) for any command to leave
neutral, LB cycles DRIVE → ARM → MAST → MICROSCOPE. See
rover_teleop/config/xbox_teleop.yaml for the full mapping and a
reminder to verify axis/button indices against your actual
driver (ros2 topic echo /joy) before relying on it.
Every serial protocol, the base kinematics, the BNO086 UART-RVC frame
parser (checksum verified against the datasheet's own worked example),
the GPS NMEA parser, the Xbox teleop mapping, and the udev device
identification/rules-generation logic all have unit tests that run
with plain pytest — no ROS 2 install, hardware, or colcon build
required:
cd ~/mars_rover_ws
PYTHONPATH=src/rover_protocol python3 -m pytest src/*/test tools/test -q207 tests as of the last update. Before touching firmware or protocol code again, also run the syntax/parse sweep:
python3 tools/validate_workspace.py- A corrected, validated, but not active alternative rover model
(differential/bogie suspension,
arm_j1-arm_j5naming) sits atrover_description/reference/opportunity_style_template.urdf.xacro. If that architecture is actually wanted over the current one, the real work is reconciling it withrover_base's kinematics (currently built for 4-corner-steering + 2 fixed middle wheels, not a differential/bogie linkage),rover_arm's joint names/limits (arm_topology.yaml,joint_conversion.py), the MoveIt SRDF, and every wiring diagram — a deliberate migration, not a file swap. See "Explicit assumptions" for what was and wasn't done with it. - Voltage monitoring (base/arm/mast, FZ0430 sensors) is voltage-only —
no current sensing, and no low-voltage warning in the web GUI. The
latter was deliberately left out rather than guessed at: a sensible
threshold depends on the actual battery chemistry/cell count, which
isn't specified anywhere in this project yet. Cheap to add
(
formatVoltage()inapp.js) once that's known. udevrules ship with placeholder VID:PID/serial values captured from no particular hardware — runpython3 tools/identify_rover_devices.py(see docs/INSTALL.md Section 8) to generate a real, filled-in rules file interactively instead of hand-editing the placeholders.- SLAM/Nav2 are wired up but ship with standard, untuned starting parameters (see "Navigation" above) — expect to adjust costmap inflation and controller behavior against your actual environment.
- MoveIt2's arm planning config (
rover_arm_moveit_config) is hand-authored, not generated by MoveIt Setup Assistant, and several values are explicitly-flagged placeholders rather than measured from the real hardware — acceleration limits injoint_limits.yaml, and the collision matrix in the SRDF, which covers the arm's own chain but isn't an exhaustive whole-rover pass. See "Arm motion planning" below and the comments in those files for specifics. rover_web_gui's ROS-facing code has no automated tests (it needs a live rclpy environment to exercise meaningfully); the pure protocol and kinematics logic it depends on is fully covered instead.