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

Safety layers

  1. Startup: the ESC signal goes to 0 % in the first line of setup(); the bench starts LOCKED
  2. MOTOR_MAX_PERCENT = 50 %: checked in motor.cpp, the only module that drives the ESC
  3. The motor only turns with the bench UNLOCKED: updateMotor() checks it on every loop
  4. Unlocking is hard: UNLOCK command + all checks OK + the physical button held 2 s
  5. Locking is easy: one tap, STOP, battery unplugged or low, server silent for 1 s (heartbeat)
  6. Watchdog (interrupt mode, 250 ms): if loop() hangs, the motor is set to 0 %

REPOSITORY

Folder / fileWhat it is
benchcode/Arduino Nano firmware (the folder name must match benchcode.ino)
servidor/communication.pyClass Nano: serial link, threads, heartbeat, and one method per command
servidor/run.pyScratch 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)
.gitignoreFiles git does not save (__pycache__/, build files...)

FIRMWARE

setup() and loop()

setup(), once after each reset:

  1. watchdog off (MCUSR = 0, wdt_disable())
  2. initialMotorSetup(): ESC signal at 0 %, always the first thing
  3. Serial.begin(115200) + 1 s wait
  4. banner, pinMode() of LEDs, buzzer, button and D6
  5. READY, always the last line printed
  6. watchdog on, in interrupt mode (~250 ms)

loop(), thousands of times per second (nothing waits, no delay()):

  1. wdt_reset(): "I am alive" for the watchdog
  2. updateBench(): battery, pot, button, lock state, LEDs
  3. updateMotor(): 0 % if not UNLOCKED; follows the pot in POT mode
  4. processInput(): reads a complete line, runs the command, replies
  5. sendEvents(): EVT POT, EVT ESC, EVT BENCH

Modules

FileRoleMain functions
pins.hAll pins and constants (times, limits, voltages)—
pot.cppPotentiometer (A6): burst average + filter, zero with hysteresisupdatePot() potValue() potAtZero()
battery.cppBattery: plugged in (D6), voltage (A0), yellow LED, low-voltage cutupdateBattery() batteryConnected() batteryArmed() batteryVolts()
motor.cppThe ONLY module that drives the ESC (D9). Modes POT / SERVERinitialMotorSetup() updateMotor() motorSet() motorStop()
bench.cppLock state machine LOCKED / UNLOCKING / UNLOCKED, button, red/green LEDsupdateBench() requestUnlock() lockBench() isBenchUnlocked()
safety.cppServer heartbeat: when the last line arrivedserverSeenConnection() serverLostConnection()
inputReader.cppSerial line reader and parserreadInput() parseInput() paramToLong()
commands.cppCommand table, one handler per command, eventsprocessInput() sendEvents()
benchcode.inosetup(), 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.

PartWhat 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 methodsTranslate 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)

  1. the method of the command (e.g. nano.battery_status()) calls _command("<COMMAND>")
  2. _command calls send("<COMMAND>")
  3. send locks _send_lock (one send at a time), empties old replies from the queue, writes <COMMAND>\n to the serial port, saves _last_send (heartbeat) and waits on the queue replies (max 1 s)

2. Nano (benchcode/, inside loop())

  1. processInput() calls readInput(cmd): joins bytes until \n
  2. parseInput() splits the line with strtok: cmd.name = the command name, cmd.params = the values
  3. serverSeenConnection() (safety.cpp): any line counts as a sign of life
  4. looks for the name in the table COMMANDS[] (strcmp) and checks the number of values
  5. calls the handler of the command (e.g. cmdBat(cmd)), which does its work and prints ONE line: OK ... or ERROR ...

3. Python, coming back

  1. the reader thread _read_loop() reads the line with _read_line(): EVT ... goes to _handle_event(), anything else goes into the queue replies
  2. send takes it from the queue and returns it (no reply in 1 s: TimeoutError)
  3. _command checks it starts with OK (otherwise: NanoError)
  4. 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:

  1. cmdBat() calls batteryVolts() (battery.cpp): settleAnalog(A0), 16 × analogRead(A0), average, × VREF / 1023, undoes the divider
  2. compares with BAT_NONE_V, BAT_LOW_V, BAT_WEAK_V → prints the state and the volts
  3. Python: battery_status() takes words 2 and 3, removes the V, 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:

  1. cmdEsc() calls escState(): batteryConnected() (D6) and batteryArmed() (connected for BAT_ARM_MS)
  2. escStateName() → NONE / ARMING / ARMED
  3. event: sendEscEvent() (in sendEvents(), every loop) uses the same escState() and prints EVT ESC when it changes
  4. Python: esc_state() takes word 2. The event goes to _handle_event(), which sets nano.esc and the flag nano.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:

  1. cmdPot() calls potValue() (pot.cpp): returns the value already filtered by updatePot() (no new reading here)
  2. updatePot() runs in updateBench(), every POT_SAMPLE_MS: readBurst() (8 readings of A6) + exponential filter + updateZero()
  3. event: sendPotEvent() prints EVT POT when it moved POT_EVENT_MIN_CHANGE or more
  4. 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:

  1. cmdBench() calls printBenchState() (bench.cpp)
  2. benchStateName() → the state; if LOCKED, also lockReason (or STARTUP) and printProblems(lockProblems)
  3. event: bench.cpp counts every change in stateVersion (in lockFor(), requestUnlock() and the unlock); sendBenchEvent() prints EVT BENCH + printBenchState() when benchStateVersion() changes
  4. 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:

  1. Python: bench_unlock() first waits on the flag esc_armed (raised by EVT ESC ARMED), then sends UNLOCK
  2. cmdUnlock() calls requestUnlock() (bench.cpp), which calls unlockProblems(): batteryConnected(), batteryArmed(), batteryLastVolts(), potAtZero(), batteryLowCut(), serverLostConnection()
  3. no problems: short beep, benchState = BENCH_UNLOCKING → OK UNLOCK UNLOCKING. Problems: printUnlockProblems() → ERROR UNLOCK not ready: ...
  4. then, in every loop, updateLock() reads the button (readButton(), debounced): held UNLOCK_HOLD_MS → BENCH_UNLOCKED + long beep (the ONLY place that unlocks)
  5. Python: bench_unlock() saves the new state in nano.bench at once; wait_unlocked() sleeps on the condition _bench_changed, woken by each EVT 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:

  1. read: cmdMotor() calls motorPercent() (motor.cpp)
  2. set: cmdMotor() checks motorMode() = SERVER and the value (paramToLong, 0..MOTOR_MAX_PERCENT), then motorSet(p)
  3. motorSet() checks again (mode, limit, isBenchUnlocked()) and calls writePercent() → esc.writeMicroseconds(1000 + p × 10) (Servo library, Timer1)
  4. also every loop: updateMotor() puts 0 % if the bench is not UNLOCKED (and follows the pot in POT mode)
  5. 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:

  1. read: cmdMode() calls motorModeName()
  2. set: cmdMode() compares the text (strcmp with POT / SERVER), then motorSetMode(m)
  3. motorSetMode() only accepts with isBenchLocked(); changes mode and calls motorStop()
  4. Python: set_mode() checks POT/SERVER before 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:

  1. cmdTone() reads the values with paramToLong and checks TONE_MIN_HZ..TONE_MAX_HZ and TONE_MAX_MS
  2. 0 → noTone(); with ms → tone(PIN_BUZZER, freq, ms); without → tone(PIN_BUZZER, freq) (Arduino, Timer2, in the background)
  3. 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:

  1. cmdStop() calls lockBench() (bench.cpp) → lockFor("STOP")
  2. lockFor() calls makeSafe() (motorStop() + noTone()), sets BENCH_LOCKED and saves the reason
  3. 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:

  1. serverSeenConnection() already ran in processInput() (for every line); cmdPing() only prints OK PING
  2. in every loop, updateLock() asks serverLostConnection() (safety.cpp): more than SERVER_TIMEOUT_MS → lockFor("SERVER_LOST")
  3. Python: the thread _heartbeat_loop() calls send("PING") when nothing was sent for HEARTBEAT_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:

  1. cmdHelp() goes through the table COMMANDS[] and prints each name

TESTS

ScriptMotor turns?What it tests
tests/test_commands.pynoEvery command and reply, automatically (no person needed)
tests/test_motor.pyyes (no propeller)Unlock with the button, motor, tap, STOP, POT mode, heartbeat
tests/test_ensaio.pyyes30 s parabola profile with a live plot (run it in MobaXterm, for the window)
tests/test_scripts.pyyes (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 taskWhat it does
Update FirmwareCloses whatever holds the port, compiles and uploads benchcode/ (Ctrl+Shift+B)
MonitorSerial monitor at 115200 baud
Run Serverpython3 servidor/run.py
Run DocsServes 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).