Skip to content

Firmware sketches

All sketches target the Arduino Nano (ATmega328) and live in firmware/.

Terminal window
arduino-cli compile --upload -p <PORT> \
--fqbn arduino:avr:nano:cpu=atmega328 \
firmware/<SketchName>

The standalone learned controller — the end state of the whole pipeline. Runs the distilled policy from policy_weights.h with no host attached, swings up from hanging, and balances.

  • Velocity-mode actions: the network’s tanh output is a velocity setpoint, converted to an acceleration command each tick by a saturating P-law.
  • Boot takes the pendulum’s resting pose as the encoder zero, so hold it still and hanging through the 1 s settle delay (LED solid).
  • Telemetry at 500,000 baud, scored by analyze_onboard.py.

Turns the Nano into a state/command server so a laptop can close the loop. Used for real-rig fine-tuning (step 2) by lowlevel_client.py. See serial protocols.

SketchVerifies
TestHeartbeatThe board runs at all — double-pulse LED, heartbeat count at 115200
TestEncoderAS5600 reading and magnet placement, prints pendulum_deg:<v>
TestMotorStepper motion, ±90° oscillation at 1/10 production speed
TestSerialSerial round-trip time (~2.5 ms, ~400 Hz theoretical)
TestServerRequest/response plumbing with sine/cosine waves

Walkthrough with expected output and failure modes: First power-on.

These must agree between the firmware and the simulation. The values below are extracted from RLControl.ino and pendulum_env.py when this page is built, so they cannot drift from the source the way prose can.

Control loop
Control rate50 Hz20.0 ms per tick
Action modevelocitypolicy output is a velocity setpoint
Max velocity3.5 rad/saction scale
Max acceleration150 rad/s²P-law saturation
Velocity correction λ0.1per tick, toward measured velocity
Action smoothing4 ticksboxcar, actuator side only
Observation4 × 6frame stack × frame width
Motor
Microsteps326400 steps/rev
Step resolution0.982 mradone microstep
Soft limit±125°firmware brakes past this
Hard limit±135°episode terminates
Encoder
Resolution12-bit4096 counts/rev
Step resolution1.534 mradone count
Velocity window8 msfinite-difference span
Geometry & physics
Arm length65 mmmotor axis to pendulum pivot
Arm mass30 g
Physics timestep1 msMuJoCo integrator step
Gravity9.81 m/s²
Deployed network
Architecture24 → 16 → 16 → 1ReLU, ReLU, tanh
Parameters6892756 bytes of flash
Distillation val MSE0.009301
Weights generated2026-08-04T21:03:36

Extracted at build time from firmware/RLControl/RLControl.ino,firmware/RLControl/policy_weights_rig1_tmc2209_32.h and policy/pendulum_env.py.

SignalPinNote
STEP9Required — FastAccelStepper needs Timer1 OC1A on ATmega328
DIR2
ENABLE5Active-low on both TMC2209 and DRV8825
AS5600 SDA/SCLA4 / A5I²C at 400 kHz (fast mode)

Pin 9 is not a preference. FastAccelStepper generates steps from Timer1’s output compare unit, and the sketch halts in setup() if the step pin is not Timer1-capable.

The encoder angle is an accumulator — guard it

Section titled “The encoder angle is an accumulator — guard it”

The AS5600 reports an absolute angle within one turn; every sketch that tracks multiple revolutions accumulates it, and that accumulator never resets. So a single corrupted I²C read does not produce one bad sample — it offsets the angle for the remainder of the run, and the controller then balances against a false vertical. The observed failure was 230 seconds of clean balancing followed by sustained flailing.

Every accumulating sketch therefore rejects implausible per-sample jumps (> 500 LSB, about 44°, far beyond anything the pendulum can do in one 2 ms sample) by returning the previous value:

  • Guarded: LowLevelServer, RLControl.
  • Deliberately not guarded: TestEncoder — seeing the raw glitches is the entire point of that sketch.

If you add a sketch that accumulates the angle, it needs the same guard.

MICROSTEPS must match how you wired the driver. The recommendation is 1/32 (6400 steps/rev) on either driver — TMC2209 with MS1 high and MS2 low, DRV8825 with M0, M1 and M2 all high. The pin levels differ because the two carriers decode the same positions differently; both tables are on the electronics page.

Steps per revolution, the speed cap and all radian↔step conversions derive from this one constant, so a mismatch silently scales the arm’s real speed and range while the firmware believes otherwise. It must also match MOTOR_MICROSTEPS in pendulum_env.py — that is what the simulation quantises the motor-position measurement to, and a policy trained against a coarser step than it deploys at sees more velocity noise on the rig than it ever trained on.

  • [FATAL] FastAccelStepper config rejected — the requested MOTOR_MAX_SPEED exceeds FastAccelStepper’s AVR ceiling of 50 kSteps/s for a single stepper.
  • Balances tethered but spins standalone — check the loop rate first. If analyze_onboard.py does not report the configured rate, inference is overrunning the tick budget and you are evaluating a broken deployment, not a bad policy.
  • Swings but never reaches upright — MOTOR_ACCELERATION differs between LowLevelServer.ino (used during fine-tuning) and RLControl.ino (used at deployment). They must agree, or the policy is trained against one plant and deployed against another.
  • Buzzing or grinding under load — step skipping. Drop MOTOR_ACCELERATION from 50 k to 30 k and re-flash.