Firmware sketches
All sketches target the Arduino Nano (ATmega328) and live in
firmware/.
arduino-cli compile --upload -p <PORT> \ --fqbn arduino:avr:nano:cpu=atmega328 \ firmware/<SketchName>Production sketches
Section titled “Production sketches”RLControl
Section titled “RLControl”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
tanhoutput 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.
LowLevelServer
Section titled “LowLevelServer”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.
Test sketches
Section titled “Test sketches”| Sketch | Verifies |
|---|---|
TestHeartbeat | The board runs at all — double-pulse LED, heartbeat count at 115200 |
TestEncoder | AS5600 reading and magnet placement, prints pendulum_deg:<v> |
TestMotor | Stepper motion, ±90° oscillation at 1/10 production speed |
TestSerial | Serial round-trip time (~2.5 ms, ~400 Hz theoretical) |
TestServer | Request/response plumbing with sine/cosine waves |
Walkthrough with expected output and failure modes: First power-on.
Shared constants
Section titled “Shared constants”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 rate | 50 Hz | 20.0 ms per tick |
|---|---|---|
| Action mode | velocity | policy output is a velocity setpoint |
| Max velocity | 3.5 rad/s | action scale |
| Max acceleration | 150 rad/s² | P-law saturation |
| Velocity correction λ | 0.1 | per tick, toward measured velocity |
| Action smoothing | 4 ticks | boxcar, actuator side only |
| Observation | 4 × 6 | frame stack × frame width |
| Microsteps | 32 | 6400 steps/rev |
|---|---|---|
| Step resolution | 0.982 mrad | one microstep |
| Soft limit | ±125° | firmware brakes past this |
| Hard limit | ±135° | episode terminates |
| Resolution | 12-bit | 4096 counts/rev |
|---|---|---|
| Step resolution | 1.534 mrad | one count |
| Velocity window | 8 ms | finite-difference span |
| Arm length | 65 mm | motor axis to pendulum pivot |
|---|---|---|
| Arm mass | 30 g | |
| Physics timestep | 1 ms | MuJoCo integrator step |
| Gravity | 9.81 m/s² |
| Architecture | 24 → 16 → 16 → 1 | ReLU, ReLU, tanh |
|---|---|---|
| Parameters | 689 | 2756 bytes of flash |
| Distillation val MSE | 0.009301 | |
| Weights generated | 2026-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.
Pin assignments
Section titled “Pin assignments”| Signal | Pin | Note |
|---|---|---|
| STEP | 9 | Required — FastAccelStepper needs Timer1 OC1A on ATmega328 |
| DIR | 2 | |
| ENABLE | 5 | Active-low on both TMC2209 and DRV8825 |
| AS5600 SDA/SCL | A4 / A5 | I²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.
Microstepping
Section titled “Microstepping”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.
Common failures
Section titled “Common failures”[FATAL] FastAccelStepper config rejected— the requestedMOTOR_MAX_SPEEDexceeds 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.pydoes 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_ACCELERATIONdiffers betweenLowLevelServer.ino(used during fine-tuning) andRLControl.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_ACCELERATIONfrom 50 k to 30 k and re-flash.