Rotor Testbench
Software: architecture, repository and modules.
ARCHITECTURE
Each layer only talks to the layer next to it. The Raspberry Pi manages everything; the Nano only runs simple commands and protects the hardware.
PC (browser) dashboard (future)
| HTTP
Raspberry Pi API (FastAPI) (future)
runner: runs the tests (future)
communication.py: class Nano <- the ONLY part that talks to the serial port
| USB serial, 115200 baud, one line per command
Arduino Nano firmware (benchcode/) <- safety: lock, button, heartbeat, watchdog
| servo PWM, 50 Hz, 1000..2000 us
ESC -> motor
Serial protocol
- One command per line, ending in
\n:<NAME> [value1] [value2] ... - Every command gets exactly one reply line:
OK ...orERROR ... - Lines starting with
EVTare events: the Nano sends them on its own, at any time - Opening the port resets the Nano. It prints a banner and then
READY: only after that can commands be sent - The protocol words are always in English (the tests check the exact text)
Safety layers
- Startup: the ESC signal goes to 0 % in the first line of
setup(); the bench starts LOCKED MOTOR_MAX_PERCENT= 50 %: checked inmotor.cpp, the only module that drives the ESC- The motor only turns with the bench UNLOCKED:
updateMotor()checks it on every loop - Unlocking is hard:
UNLOCKcommand + all checks OK + the physical button held 2 s - Locking is easy: one tap,
STOP, battery unplugged or low, server silent for 1 s (heartbeat) - Watchdog (interrupt mode, 250 ms): if
loop()hangs, the motor is set to 0 %
REPOSITORY
| Folder / file | What it is |
|---|---|
benchcode/ | Arduino Nano firmware (the folder name must match benchcode.ino) |
servidor/communication.py | Class Nano: serial link, threads, heartbeat, and one method per command |
servidor/run.py | Scratch script to try the class Nano |
tests/ | Test scripts (see TESTS) |
webpage/ | These docs: index.html (home), software.html, hardware.html, style.css, toc.js |
.vscode/ | VS Code tasks and settings (see TOOLS) |
.gitignore | Files git does not save (__pycache__/, build files...) |
FIRMWARE
setup() and loop()
setup(), once after each reset:
- watchdog off (
MCUSR = 0,wdt_disable()) initialMotorSetup(): ESC signal at 0 %, always the first thingSerial.begin(115200)+ 1 s wait- banner,
pinMode()of LEDs, buzzer, button and D6 READY, always the last line printed- watchdog on, in interrupt mode (~250 ms)
loop(), thousands of times per second (nothing waits, no delay()):
wdt_reset(): "I am alive" for the watchdogupdateBench(): battery, pot, button, lock state, LEDsupdateMotor(): 0 % if not UNLOCKED; follows the pot in POT modeprocessInput(): reads a complete line, runs the command, repliessendEvents():EVT POT,EVT ESC,EVT BENCH
Modules
| File | Role | Main functions |
|---|---|---|
pins.h | All pins and constants (times, limits, voltages) | — |
pot.cpp | Potentiometer (A6): burst average + filter, zero with hysteresis | updatePot() potValue() potAtZero() |
battery.cpp | Battery: plugged in (D6), voltage (A0), yellow LED, low-voltage cut | updateBattery() batteryConnected() batteryArmed() batteryVolts() |
motor.cpp | The ONLY module that drives the ESC (D9). Modes POT / SERVER | initialMotorSetup() updateMotor() motorSet() motorStop() |
bench.cpp | Lock state machine LOCKED / UNLOCKING / UNLOCKED, button, red/green LEDs | updateBench() requestUnlock() lockBench() isBenchUnlocked() |
safety.cpp | Server heartbeat: when the last line arrived | serverSeenConnection() serverLostConnection() |
inputReader.cpp | Serial line reader and parser | readInput() parseInput() paramToLong() |
commands.cpp | Command table, one handler per command, events | processInput() sendEvents() |
benchcode.ino | setup(), loop() and the watchdog ISR | — |
SERVER
Class Nano (communication.py)
Opened once; Nano("/dev/ttyUSB0") only returns when the Nano says READY. Always close it in a finally.
| Part | What it does |
|---|---|
__init__ | Opens the port, waits for READY, starts the 2 threads |
reader thread _read_loop() | The ONLY reader of the port: EVT → _handle_event(), replies → queue replies |
heartbeat thread _heartbeat_loop() | Sends PING when nothing was sent for 250 ms |
send() | Writes one command and waits for its reply (lock: one send at a time; TimeoutError after 1 s) |
_command() | send() + only accepts OK; anything else raises NanoError |
_handle_event() | Prints the event; EVT ESC updates nano.esc and the flag nano.esc_armed; EVT BENCH updates nano.bench and wakes wait_unlocked() |
| command methods | Translate Python ↔ Nano (see COMMANDS) |
close() | STOP, stops the threads, closes the port |
Errors: NanoError = the Nano answered but refused. TimeoutError = the Nano did not answer.
A script with the class Nano
from communication import Nano, NanoError
nano = Nano("/dev/ttyUSB0") # opens and waits for READY
try:
print(nano.battery_status())
nano.bench_unlock() # waits for the ESC to be armed
if nano.wait_unlocked(): # someone holds the button 2 s
nano.set_motor(10)
except NanoError as e:
print("The Nano refused:", e)
finally:
nano.close() # STOP + close, whatever happens
COMMANDS
| NANO | API | Info |
|---|---|---|
| BAT | battery_status() | Gets the battery state and voltage |
| ESC | esc_state() | Gets the ESC state: NONE, ARMING or ARMED |
| POT | pot_reading() | Gets the potentiometer reading (0..1023) |
| BENCH | bench_state() | Gets the bench state, the lock reason and the failed checks |
| UNLOCK | bench_unlock() wait_unlocked() |
Requests the unlock, then waits for the button (held 2 s) |
| MOTOR | motor() set_motor(percent) |
Reads / sets the throttle (%) |
| MODE | mode() set_mode(mode) |
Reads / sets who controls the throttle: POT or SERVER |
| TONE | tone(freq, ms) | Plays a tone on the buzzer |
| STOP | stop() | Motor to 0 % and bench locked |
| PING | — (heartbeat) | Server sign of life (sent automatically every 250 ms) |
| HELP | — | Lists all the commands (for manual use in the Monitor) |
Common error: a wrong number of values, e.g. BAT 1 → ERROR BAT expects 0 values: BAT.
In Python, an ERROR reply raises NanoError (the Nano refused) and no reply in 1 s raises TimeoutError (the Nano did not answer).
How a command runs
Every command takes the same path. Only the step in bold is different for each command (see "Flow" in each one).
1. Python, going out (servidor/communication.py)
- the method of the command (e.g.
nano.battery_status()) calls_command("<COMMAND>") _commandcallssend("<COMMAND>")sendlocks_send_lock(one send at a time), empties old replies from the queue, writes<COMMAND>\nto the serial port, saves_last_send(heartbeat) and waits on the queuereplies(max 1 s)
2. Nano (benchcode/, inside loop())
processInput()callsreadInput(cmd): joins bytes until\nparseInput()splits the line withstrtok:cmd.name= the command name,cmd.params= the valuesserverSeenConnection()(safety.cpp): any line counts as a sign of life- looks for the name in the table
COMMANDS[](strcmp) and checks the number of values - calls the handler of the command (e.g.
cmdBat(cmd)), which does its work and prints ONE line:OK ...orERROR ...
3. Python, coming back
- the reader thread
_read_loop()reads the line with_read_line():EVT ...goes to_handle_event(), anything else goes into the queuereplies sendtakes it from the queue and returns it (no reply in 1 s:TimeoutError)_commandchecks it starts withOK(otherwise:NanoError)- the method splits the reply (
split()) and returns Python data
BAT
Measures the battery voltage through the balance lead (pin A0, 10k / 4.7k divider, average of 16 readings).
Nano: BAT → OK BAT GOOD 11.42V
Python: battery_status() → {"state": "GOOD", "voltage": 11.42}
Values: NONE (< 6 V) | LOW (< 10.2 V) | WEAK (< 10.8 V) | GOOD
Errors: none specific.
Flow:
cmdBat()callsbatteryVolts()(battery.cpp):settleAnalog(A0), 16 ×analogRead(A0), average, ×VREF/ 1023, undoes the divider- compares with
BAT_NONE_V,BAT_LOW_V,BAT_WEAK_V→ prints the state and the volts - Python:
battery_status()takes words 2 and 3, removes theV,float()
ESC
The ESC state, seen through pin D6 (the ESC's BEC gives 5 V when the battery is plugged in).
Nano: ESC → OK ESC ARMED
Python: esc_state() → "ARMED"
Values: NONE (battery unplugged) | ARMING (plugged in less than 3 s ago, BAT_ARM_MS: the 3 LEDs rotate) | ARMED
Event: EVT ESC <state> whenever it changes, and once after startup.
In Python it is kept in nano.esc and raises the flag nano.esc_armed.
Errors: none specific.
Flow:
cmdEsc()callsescState():batteryConnected()(D6) andbatteryArmed()(connected forBAT_ARM_MS)escStateName()→NONE/ARMING/ARMED- event:
sendEscEvent()(insendEvents(), every loop) uses the sameescState()and printsEVT ESCwhen it changes - Python:
esc_state()takes word 2. The event goes to_handle_event(), which setsnano.escand the flagnano.esc_armed
POT
The throttle potentiometer position (pin A6), filtered.
Nano: POT → OK POT 523
Python: pot_reading() → 523
Values: 0..1023. "At zero" when ≤ 15 (POT_ZERO), leaves zero above 30 (POT_LEFT_ZERO).
Event: EVT POT <value> when it moves 5 or more, at most every 100 ms, and once after startup.
Errors: none specific.
Flow:
cmdPot()callspotValue()(pot.cpp): returns the value already filtered byupdatePot()(no new reading here)updatePot()runs inupdateBench(), everyPOT_SAMPLE_MS:readBurst()(8 readings of A6) + exponential filter +updateZero()- event:
sendPotEvent()printsEVT POTwhen it movedPOT_EVENT_MIN_CHANGEor more - Python:
pot_reading()takes word 2,int()
BENCH
The bench lock state.
Nano: BENCH → OK BENCH LOCKED CHECK NO_BATTERY POT_NOT_ZERO
Python: bench_state() → {"state": "LOCKED", "reason": "CHECK", "problems": ["NO_BATTERY", "POT_NOT_ZERO"]}
(the 3 keys always exist: reason is None when not LOCKED, problems is [] unless the reason is CHECK)
States: LOCKED | UNLOCKING | UNLOCKED
Reasons (LOCKED only): STARTUP STOP BUTTON CHECK TIMEOUT NO_BATTERY LOW_VOLTAGE SERVER_LOST
Problems (CHECK only): NO_BATTERY ESC_ARMING LOW_VOLTAGE POT_NOT_ZERO BATTERY_CUT SERVER_LOST
Event: EVT BENCH <state> [reason] [problems] (the same text as the reply) whenever the state or the reason changes,
and once after startup. In Python it is kept in nano.bench (the same dictionary as bench_state()).
Errors: none specific.
Flow:
cmdBench()callsprintBenchState()(bench.cpp)benchStateName()→ the state; if LOCKED, alsolockReason(orSTARTUP) andprintProblems(lockProblems)- event:
bench.cppcounts every change instateVersion(inlockFor(),requestUnlock()and the unlock);sendBenchEvent()printsEVT BENCH+printBenchState()whenbenchStateVersion()changes - Python:
bench_state()and the event both use_parse_bench(): word 2 = state, word 3 = reason (if it exists), words 4.. = problems
UNLOCK
Starts the unlock: someone must then hold the button for 2 s (UNLOCK_HOLD_MS) within 30 s (UNLOCK_WAIT_MS).
The command alone never unlocks the bench.
Nano: UNLOCK → OK UNLOCK UNLOCKING (or OK UNLOCK UNLOCKED if it already was)
Python: bench_unlock() → "UNLOCKING". First waits up to 5 s for the ESC to be armed.
wait_unlocked(timeout=30) → True (unlocked) or False (back to LOCKED, or timeout). It asks nothing: it sleeps until an EVT BENCH says the bench left UNLOCKING.
Errors: ERROR UNLOCK not ready: NO_BATTERY POT_NOT_ZERO (the problems, as in BENCH)
Flow:
- Python:
bench_unlock()first waits on the flagesc_armed(raised byEVT ESC ARMED), then sendsUNLOCK cmdUnlock()callsrequestUnlock()(bench.cpp), which callsunlockProblems():batteryConnected(),batteryArmed(),batteryLastVolts(),potAtZero(),batteryLowCut(),serverLostConnection()- no problems: short beep,
benchState = BENCH_UNLOCKING→OK UNLOCK UNLOCKING. Problems:printUnlockProblems()→ERROR UNLOCK not ready: ... - then, in every loop,
updateLock()reads the button (readButton(), debounced): heldUNLOCK_HOLD_MS→BENCH_UNLOCKED+ long beep (the ONLY place that unlocks) - Python:
bench_unlock()saves the new state innano.benchat once;wait_unlocked()sleeps on the condition_bench_changed, woken by eachEVT BENCH, until the state is not UNLOCKING
MOTOR
The throttle, in %: 0 % = 1000 µs, 100 % = 2000 µs. Never above MOTOR_MAX_PERCENT = 50 % (1500 µs).
Nano: MOTOR → OK MOTOR 30 (read) | MOTOR 30 → OK MOTOR 30 (set)
Python: motor() → 30 | set_motor(30) → 30 (the value confirmed by the Nano)
Rules: setting only works in SERVER mode and with the bench UNLOCKED. MOTOR 0 is always accepted in SERVER mode.
Errors: ERROR MOTOR mode POT | ERROR MOTOR invalid value: 60 | ERROR MOTOR bench LOCKED STOP
Flow:
- read:
cmdMotor()callsmotorPercent()(motor.cpp) - set:
cmdMotor()checksmotorMode()= SERVER and the value (paramToLong, 0..MOTOR_MAX_PERCENT), thenmotorSet(p) motorSet()checks again (mode, limit,isBenchUnlocked()) and callswritePercent()→esc.writeMicroseconds(1000 + p × 10)(Servo library, Timer1)- also every loop:
updateMotor()puts 0 % if the bench is not UNLOCKED (and follows the pot in POT mode) - Python:
motor()/set_motor()take word 2,int()
MODE
Who controls the throttle. Starts in SERVER.
Nano: MODE → OK MODE SERVER (read) | MODE POT → OK MODE POT (set)
Python: mode() → "SERVER" | set_mode("POT") → "POT"
Values: POT (the potentiometer) | SERVER (the MOTOR command)
Rules: the mode only changes with the bench LOCKED (so the throttle never jumps).
In Python, set_mode raises ValueError for anything other than POT or SERVER, before sending.
Errors: ERROR MODE bench UNLOCKED | ERROR MODE invalid value: XPTO
Flow:
- read:
cmdMode()callsmotorModeName() - set:
cmdMode()compares the text (strcmpwithPOT/SERVER), thenmotorSetMode(m) motorSetMode()only accepts withisBenchLocked(); changesmodeand callsmotorStop()- Python:
set_mode()checksPOT/SERVERbefore sending (ValueError), then takes word 2
TONE
Plays a tone on the buzzer (pin D7).
Nano: TONE 2000 200 → OK TONE 2000 200
Python: tone(2000, 200)
Values: freq 31..10000 Hz, or 0 to stop | ms 0..5000 (0 or none = until TONE 0 or STOP)
Note: the sound runs in the background: OK arrives at once. STOP (and close()) silences it,
so wait the duration before closing.
Errors: ERROR TONE invalid value: 30
Flow:
cmdTone()reads the values withparamToLongand checksTONE_MIN_HZ..TONE_MAX_HZandTONE_MAX_MS0→noTone(); with ms →tone(PIN_BUZZER, freq, ms); without →tone(PIN_BUZZER, freq)(Arduino, Timer2, in the background)- Python:
tone()just sends; no data to return
STOP
Motor to 0 %, buzzer off and bench LOCKED STOP. Always accepted, in any state.
Nano: STOP → OK STOP
Python: stop() → True. close() sends it on its own.
Errors: none.
Flow:
cmdStop()callslockBench()(bench.cpp) →lockFor("STOP")lockFor()callsmakeSafe()(motorStop()+noTone()), setsBENCH_LOCKEDand saves the reason- Python:
stop();close()calls it before stopping the threads
PING
The server's sign of life (heartbeat). Any line counts: after 1 s without hearing anything (SERVER_TIMEOUT_MS),
the bench locks (SERVER_LOST).
Nano: PING → OK PING
Python: no method: the heartbeat thread sends it on its own when nothing was sent for 250 ms.
Errors: none.
Flow:
serverSeenConnection()already ran inprocessInput()(for every line);cmdPing()only printsOK PING- in every loop,
updateLock()asksserverLostConnection()(safety.cpp): more thanSERVER_TIMEOUT_MS→lockFor("SERVER_LOST") - Python: the thread
_heartbeat_loop()callssend("PING")when nothing was sent forHEARTBEAT_S
HELP
Lists all the commands, built from the firmware's command table (never out of date). For manual use in the Monitor.
Nano: HELP → OK POT TONE BENCH UNLOCK MOTOR MODE BAT ESC STOP PING HELP
Python: no method.
Flow:
cmdHelp()goes through the tableCOMMANDS[]and prints eachname
TESTS
| Script | Motor turns? | What it tests |
|---|---|---|
tests/test_commands.py | no | Every command and reply, automatically (no person needed) |
tests/test_motor.py | yes (no propeller) | Unlock with the button, motor, tap, STOP, POT mode, heartbeat |
tests/test_ensaio.py | yes | 30 s parabola profile with a live plot (run it in MobaXterm, for the window) |
tests/test_scripts.py | yes (no propeller) | Example with the class Nano: motor 2 s at 10 % |
Run from the project folder, with the Monitor and miniterm closed: python3 tests/test_commands.py
TOOLS
| VS Code task | What it does |
|---|---|
| Update Firmware | Closes whatever holds the port, compiles and uploads benchcode/ (Ctrl+Shift+B) |
| Monitor | Serial monitor at 115200 baud |
| Run Server | python3 servidor/run.py |
| Run Docs | Serves webpage/ on port 8080: http://192.168.1.206:8080 |
Board: arduino:avr:nano:cpu=atmega328old (old bootloader). Code on GitHub: joaogaspar00/rotor_testbench (private).