Hoverboard Motor Control

Why hoverboard motors

For the first prototype, I want to test the full navigation and control stack without committing to the final hardware. Hoverboard motors are cheap, well-documented, and easy to get. I got some off Facebook Marketplace for next to nothing and have been playing around with one of them for the past week or so.

The goal is simple: accept speed commands from the Raspberry Pi, spin the motor at that speed. Once I can command the motor to reliably and accurately spin at a given speed, then I can plug in the rest of the ROS2 navigation stack by running the Pi as a ROS2 node.

The motor controller

I picked up a ZS-X11H controller from Amazon for about $25. These are popular in the hoverboard hacking community because they’re so cheap and take a standard PWM input — which means an Arduino can drive them directly. The controller handles all the brushless commutation.

Here is a video explaining how to wire the controller and set-up the PID control loop.

The code

Arduino sketch
#include <PID_v1.h>

const int S_PIN = 2;
const int P_PIN = 9;
const int DIR_PIN = 4;
const float PULSES_PER_REV = 90.0;
const float SUPPLY_V = 12.7;
const float KV = 17.2;
const unsigned long STALE_TIMEOUT_US = 500000;
const unsigned long LOOP_INTERVAL = 20;
const unsigned long PRINT_INTERVAL = 200;
const double RAMP_STEP = 50.0;

volatile unsigned long pulseInterval = 0;
volatile unsigned long lastPulseTime = 0;

void onPulse() {
  unsigned long now = micros();
  pulseInterval = now - lastPulseTime;
  lastPulseTime = now;
}

float getRPM() {
  noInterrupts();
  unsigned long interval = pulseInterval;
  unsigned long lastPulse = lastPulseTime;
  interrupts();
  if (micros() - lastPulse > STALE_TIMEOUT_US) return 0;
  if (interval == 0) return 0;
  return 60000000.0 / (interval * PULSES_PER_REV);
}

double requestedRPM = 0, targetRPM = 0, currentRPM = 0, pidCorrection = 0;
double Kp = 0.5, Ki = 0.7, Kd = 0.0;
bool forward = true;
PID myPID(&currentRPM, &pidCorrection, &targetRPM, Kp, Ki, Kd, DIRECT);

void setDirection(bool fwd) {
  if (fwd == forward) return;
  forward = fwd;
  digitalWrite(DIR_PIN, forward ? HIGH : LOW);
  myPID.SetMode(MANUAL);
  pidCorrection = 0;
  myPID.SetMode(AUTOMATIC);
}

void handleSerial() {
  if (!Serial.available()) return;
  char cmd = Serial.peek();
  if (cmd == 'p' || cmd == 'i' || cmd == 'd') {
    Serial.read();
    double val = Serial.parseFloat();
    while (Serial.available()) Serial.read();
    if (cmd == 'p') Kp = val;
    else if (cmd == 'i') Ki = val;
    else if (cmd == 'd') Kd = val;
    myPID.SetTunings(Kp, Ki, Kd);
    Serial.print("Kp="); Serial.print(Kp);
    Serial.print(" Ki="); Serial.print(Ki);
    Serial.print(" Kd="); Serial.println(Kd);
  } else {
    requestedRPM = Serial.parseFloat();
    while (Serial.available()) Serial.read();
    Serial.print("Target: "); Serial.println(requestedRPM);
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(S_PIN, INPUT_PULLUP);
  pinMode(P_PIN, OUTPUT);
  pinMode(DIR_PIN, OUTPUT);
  digitalWrite(DIR_PIN, HIGH);
  analogWrite(P_PIN, 0);
  attachInterrupt(digitalPinToInterrupt(S_PIN), onPulse, CHANGE);

  myPID.SetMode(AUTOMATIC);
  myPID.SetOutputLimits(-80, 80);
  myPID.SetSampleTime(LOOP_INTERVAL);

  Serial.println("Commands: <rpm> (negative=reverse), p<val>, i<val>, d<val>");
}

unsigned long lastLoop = 0;
unsigned long lastPrint = 0;

void loop() {
  handleSerial();

  unsigned long now = millis();
  if (now - lastLoop < LOOP_INTERVAL) return;
  lastLoop = now;

  double absReq = abs(requestedRPM);
  bool reqFwd = requestedRPM >= 0;

  if (reqFwd != forward && targetRPM > 0) {
    targetRPM = max(0.0, targetRPM - RAMP_STEP);
    if (targetRPM == 0) setDirection(reqFwd);
  } else {
    setDirection(reqFwd);
    if (targetRPM < absReq) targetRPM = min(absReq, targetRPM + RAMP_STEP);
    else if (targetRPM > absReq) targetRPM = max(absReq, targetRPM - RAMP_STEP);
  }

  currentRPM = getRPM();
  myPID.Compute();

  float pwm_ff = (targetRPM / KV) / SUPPLY_V * 255.0;
  int pwm = (int)constrain(pwm_ff + pidCorrection, 0, 255);
  if (targetRPM <= 0) pwm = 0;

  analogWrite(P_PIN, pwm);

  if (now - lastPrint >= PRINT_INTERVAL) {
    lastPrint = now;
    float signedRPM = forward ? currentRPM : -currentRPM;
    int signedPWM = forward ? pwm : -pwm;
    Serial.print("RPM: "); Serial.print(signedRPM, 1);
    Serial.print(" | FF: "); Serial.print((int)pwm_ff);
    Serial.print(" | Corr: "); Serial.print((int)pidCorrection);
    Serial.print(" | PWM: "); Serial.println(signedPWM);
  }
}
Flask tuning dashboard (runs on the Pi)
from flask import Flask, Response, request, jsonify
import serial, threading, time, json, queue

app = Flask(__name__)

ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=1)
time.sleep(2)

state = {'rpm': 0.0, 'pwm': 0, 'corr': 0, 'target': 0.0, 'kp': 0.5, 'ki': 0.7, 'kd': 0.0}
clients = []
clients_lock = threading.Lock()

def serial_reader():
    while True:
        try:
            line = ser.readline().decode().strip()
            if line.startswith('RPM:'):
                parts = line.split()
                # format: RPM: x | FF: x | Corr: x | PWM: x
                state['rpm'] = float(parts[1])
                state['corr'] = int(parts[7])
                state['pwm'] = int(parts[-1])
                data = json.dumps({'rpm': state['rpm'], 'pwm': state['pwm'], 'corr': state['corr'], 'target': state['target']})
                with clients_lock:
                    for q in list(clients):
                        q.put(data)
        except Exception:
            pass

threading.Thread(target=serial_reader, daemon=True).start()

@app.route('/')
def index():
    return HTML.replace('__STATE__', json.dumps(state))

@app.route('/stream')
def stream():
    q = queue.Queue()
    with clients_lock:
        clients.append(q)
    def generate():
        try:
            while True:
                try:
                    data = q.get(timeout=15)
                    yield f'data: {data}\n\n'
                except queue.Empty:
                    yield ': keepalive\n\n'
        finally:
            with clients_lock:
                if q in clients:
                    clients.remove(q)
    return Response(generate(), mimetype='text/event-stream')

@app.route('/set', methods=['POST'])
def set_value():
    data = request.json
    cmd = None
    for key in ('kp', 'ki', 'kd', 'target'):
        if key in data:
            val = float(data[key])
            state[key] = val
            cmd = f"{key[1]}{val}\n" if key != 'target' else f"{val}\n"
            break
    if cmd:
        ser.write(cmd.encode())
    return jsonify({'ok': True})

HTML = '''<!DOCTYPE html>
<html>
<head>
<title>Motor PID</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
  * { box-sizing: border-box; }
  body { font-family: monospace; background: #151515; color: #ddd; margin: 0; padding: 20px; }
  h1 { margin: 0 0 16px; font-size: 1.2em; color: #fff; }
  .charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
  .chart-wrap { background: #1e1e1e; border-radius: 8px; padding: 16px; }
  .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  .card { background: #1e1e1e; border-radius: 8px; padding: 16px; }
  .card h3 { margin: 0 0 12px; font-size: 0.9em; color: #aaa; text-transform: uppercase; letter-spacing: 1px; }
  .slider-row { margin: 10px 0; }
  .slider-row label { display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 0.9em; }
  .slider-row label span { color: #7df; }
  input[type=range] { width: 100%; accent-color: #7df; }
  .stats { display: flex; gap: 24px; margin-bottom: 16px; font-size: 0.95em; }
  .stat { color: #888; }
  .stat span { font-weight: bold; }
  #stat-rpm { color: #4af; }
  #stat-target { color: #fa4; }
  #stat-pwm { color: #c8f; }
  #stat-corr { color: #f84; }
</style>
</head>
<body>
<h1>Motor PID Dashboard</h1>
<div class="stats">
  <div class="stat">RPM <span id="stat-rpm">0</span></div>
  <div class="stat">Setpoint <span id="stat-target">0</span></div>
  <div class="stat">PWM <span id="stat-pwm">0</span></div>
  <div class="stat">Corr <span id="stat-corr">0</span></div>
</div>
<div class="charts">
  <div class="chart-wrap">
    <canvas id="chart-rpm" height="120"></canvas>
  </div>
  <div class="chart-wrap">
    <canvas id="chart-pwm" height="120"></canvas>
  </div>
</div>
<div class="controls">
  <div class="card">
    <h3>Target RPM</h3>
    <div class="slider-row">
      <label>RPM <span id="target-val">0</span></label>
      <input type="range" id="target" min="-200" max="200" step="1" value="0" oninput="updateTarget()">
    </div>
  </div>
  <div class="card">
    <h3>PID Gains</h3>
    <div class="slider-row">
      <label>Kp <span id="kp-val">0.50</span></label>
      <input type="range" id="kp" min="0" max="5" step="0.01" value="0.5" oninput="updateGain(\'kp\')">
    </div>
    <div class="slider-row">
      <label>Ki <span id="ki-val">0.70</span></label>
      <input type="range" id="ki" min="0" max="2" step="0.01" value="0.7" oninput="updateGain(\'ki\')">
    </div>
    <div class="slider-row">
      <label>Kd <span id="kd-val">0.000</span></label>
      <input type="range" id="kd" min="0" max="1" step="0.001" value="0" oninput="updateGain(\'kd\')">
    </div>
  </div>
</div>
<script>
const MAX = 120;
const labels = [], rpmData = [], targetData = [], pwmData = [], corrData = [];

const chartOpts = (datasets, scales) => ({
  type: \'line\',
  data: { labels, datasets },
  options: {
    animation: false,
    interaction: { mode: \'index\', intersect: false },
    scales,
    plugins: { legend: { labels: { color: \'#aaa\', boxWidth: 20 } } }
  }
});

const chartRPM = new Chart(document.getElementById(\'chart-rpm\').getContext(\'2d\'), chartOpts(
  [
    { label: \'RPM\',      data: rpmData,    borderColor: \'#4af\', borderWidth: 1.5, tension: 0.2, pointRadius: 0 },
    { label: \'Setpoint\', data: targetData, borderColor: \'#fa4\', borderWidth: 1.5, tension: 0,   pointRadius: 0, borderDash: [6,3] },
  ],
  {
    x: { display: false },
    y: { min: -250, max: 250, title: { display: true, text: \'RPM\', color: \'#4af\' }, ticks: { color: \'#888\' }, grid: { color: \'#2a2a2a\' } }
  }
));

const chartPWM = new Chart(document.getElementById(\'chart-pwm\').getContext(\'2d\'), chartOpts(
  [
    { label: \'PWM\',  data: pwmData,  borderColor: \'#c8f\', borderWidth: 1.5, tension: 0.2, pointRadius: 0 },
    { label: \'Corr\', data: corrData, borderColor: \'#f84\', borderWidth: 1.5, tension: 0.2, pointRadius: 0 },
  ],
  {
    x: { display: false },
    y: { min: -255, max: 255, title: { display: true, text: \'PWM / Corr\', color: \'#c8f\' }, ticks: { color: \'#888\' }, grid: { color: \'#2a2a2a\' } }
  }
));

let t = 0;
const es = new EventSource(\'/stream\');
es.onmessage = e => {
  const d = JSON.parse(e.data);
  labels.push(t++);
  rpmData.push(d.rpm);
  targetData.push(d.target);
  pwmData.push(d.pwm);
  corrData.push(d.corr);
  if (labels.length > MAX) { labels.shift(); rpmData.shift(); targetData.shift(); pwmData.shift(); corrData.shift(); }
  chartRPM.update(\'none\');
  chartPWM.update(\'none\');
  document.getElementById(\'stat-rpm\').textContent    = d.rpm.toFixed(1);
  document.getElementById(\'stat-target\').textContent = d.target.toFixed(1);
  document.getElementById(\'stat-pwm\').textContent    = d.pwm;
  document.getElementById(\'stat-corr\').textContent   = d.corr;
};

const s = __STATE__;
[\'kp\',\'ki\',\'kd\'].forEach(k => {
  document.getElementById(k).value = s[k];
  document.getElementById(k+\'-val\').textContent = s[k].toFixed(k===\'kd\'?3:2);
});
document.getElementById(\'target\').value = s.target;
document.getElementById(\'target-val\').textContent = s.target;

const timers = {};
function updateGain(g) {
  const val = parseFloat(document.getElementById(g).value);
  document.getElementById(g+\'-val\').textContent = val.toFixed(g===\'kd\'?3:2);
  clearTimeout(timers[g]);
  timers[g] = setTimeout(() => post({[g]: val}), 150);
}

function updateTarget() {
  const val = parseFloat(document.getElementById(\'target\').value);
  document.getElementById(\'target-val\').textContent = val;
  clearTimeout(timers[\'target\']);
  timers[\'target\'] = setTimeout(() => post({target: val}), 150);
}

function post(body) {
  fetch(\'/set\', { method: \'POST\', headers: {\'Content-Type\':\'application/json\'}, body: JSON.stringify(body) });
}
</script>
</body>
</html>'''

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5053, threaded=True)

Next Steps

Now I’m confident I can control the motors from the Pi, I guess the next step is to design the prototype chassis and build the thing.