Chase_Bluestamp_Portfolio

Bluestamp Engineering 2026 Project Portfolio


Project maintained by ch-a-ze Hosted on GitHub Pages — Theme by mattgraham

Vincent Van Roh -bot

This is a drawing robot that uses a gyroscope to steer itself as it moves, so it can trace shapes and drawings onto paper. It runs on an Arduino Nano ESP32 and brings together a few different systems: motors with encoders, an IMU to track direction, a servo to lift the pen, and a Wi-Fi website to send it commands.

Engineer School Area of Interest Grade
Chase L Mtn View Electrical Engineering Incoming Junior

PXL_20260727_180837554

Final Milestone

My final milestone was the finished drawing robot. It is able to interpret g-code and draw based on its instructions. The changes for this milestone were done mostly on the software side, with a few tweaks made to the robot itself. The most notable of these tweaks were finding a perfectly-sized screw to mount into the motor holders and to stop the motor from shifting around, and switching out the normal HB Ticonderoga pencil for a specialized 8B pencil, making it show up as a much bolder line that reduces friction. The rest was pretty much software and a ton of calibration, adjusting variables such as COUNTS_PER_MM to track distance and Backlash for adjusting the degrees for each individual motor accounting for power difference and resistance and for the fact that the robot is not perfectly aligned.

Besides calibration, the robot also needed to be able to draw drawings that anyone can find online. To do this, I am using the app Inkscape to trace the outline of an image that I upload, which can then be downloaded as an .svg (scalable vector graphic).

This is a catalog website of all the different g-code presets already uploaded so the entire process below doesn’t have to be done every single time. If you want the link, it can be found here: https://vincentvanro-bot.netlify.app/

Here is a screen recording of the entire process on how to get the svg file from Inkscape and trace the outline:

For reference, the Inkscape App can be found at this link: inkscape.org. This can then be converted into g-code for my robot to interpret using this python script:

"""
svg_to_gcode.py - convert an Inkscape SVG outline into the gyro_robot_plotter
firmware's g-code dialect.

  python3 svg_to_gcode.py cat.svg                     # prints g-code, writes cat.preview.svg
  python3 svg_to_gcode.py cat.svg --size 120          # fit within 120 mm (default 80)
  python3 svg_to_gcode.py cat.svg --min-turn 20       # merge corners shallower than 20 deg
  python3 svg_to_gcode.py cat.svg --close-x -2 --close-y -15   # compass-rule closure fix

Emits ONLY what the firmware understands:
  G0 X.. Y..   travel with pen UP   (firmware lifts the pen on G00)
  G1 X.. Y..   draw with pen DOWN   (firmware drops the pen on G01)
No Z, no M3/M5 - the pen is controlled purely by move type.
Curves are flattened to short straight segments (the firmware handles those fine).
"""
import math, re, sys

# ---- tiny self-contained SVG path parser (M L H V C S Q T Z, abs+rel) --------
def tokenize_path(d):
    return re.findall(r'[MmLlHhVvCcSsQqTtAaZz]|-?\d*\.?\d+(?:[eE][-+]?\d+)?', d)

def cubic(p0, p1, p2, p3, n=10):
    pts = []
    for i in range(1, n + 1):
        t = i / n; mt = 1 - t
        x = mt*mt*mt*p0[0] + 3*mt*mt*t*p1[0] + 3*mt*t*t*p2[0] + t*t*t*p3[0]
        y = mt*mt*mt*p0[1] + 3*mt*mt*t*p1[1] + 3*mt*t*t*p2[1] + t*t*t*p3[1]
        pts.append((x, y))
    return pts

def quad(p0, p1, p2, n=10):
    pts = []
    for i in range(1, n + 1):
        t = i / n; mt = 1 - t
        x = mt*mt*p0[0] + 2*mt*t*p1[0] + t*t*p2[0]
        y = mt*mt*p0[1] + 2*mt*t*p1[1] + t*t*p2[1]
        pts.append((x, y))
    return pts

def parse_path(d):
    """Return a list of subpaths; each subpath is a list of (x,y) points."""
    toks = tokenize_path(d)
    i = 0
    subpaths, cur = [], []
    x = y = 0.0
    start = (0.0, 0.0)
    prev_ctrl = None
    cmd = None
    def num():
        nonlocal i
        v = float(toks[i]); i += 1
        return v
    while i < len(toks):
        t = toks[i]
        if re.match(r'[A-Za-z]', t):
            cmd = t; i += 1
        rel = cmd.islower()
        C = cmd.upper()
        if C == 'M':
            nx, ny = num(), num()
            if rel: nx += x; ny += y
            if cur: subpaths.append(cur)
            cur = [(nx, ny)]
            x, y = nx, ny; start = (x, y); prev_ctrl = None
            cmd = 'l' if rel else 'L'
        elif C == 'L':
            nx, ny = num(), num()
            if rel: nx += x; ny += y
            cur.append((nx, ny)); x, y = nx, ny; prev_ctrl = None
        elif C == 'H':
            nx = num()
            if rel: nx += x
            cur.append((nx, y)); x = nx; prev_ctrl = None
        elif C == 'V':
            ny = num()
            if rel: ny += y
            cur.append((x, ny)); y = ny; prev_ctrl = None
        elif C == 'C':
            x1, y1, x2, y2, nx, ny = (num() for _ in range(6))
            if rel: x1+=x; y1+=y; x2+=x; y2+=y; nx+=x; ny+=y
            cur += cubic((x,y),(x1,y1),(x2,y2),(nx,ny))
            prev_ctrl = (x2, y2); x, y = nx, ny
        elif C == 'S':
            x2, y2, nx, ny = (num() for _ in range(4))
            if rel: x2+=x; y2+=y; nx+=x; ny+=y
            x1, y1 = (2*x - prev_ctrl[0], 2*y - prev_ctrl[1]) if prev_ctrl else (x, y)
            cur += cubic((x,y),(x1,y1),(x2,y2),(nx,ny))
            prev_ctrl = (x2, y2); x, y = nx, ny
        elif C == 'Q':
            x1, y1, nx, ny = (num() for _ in range(4))
            if rel: x1+=x; y1+=y; nx+=x; ny+=y
            cur += quad((x,y),(x1,y1),(nx,ny))
            prev_ctrl = (x1, y1); x, y = nx, ny
        elif C == 'T':
            nx, ny = num(), num()
            if rel: nx+=x; ny+=y
            x1, y1 = (2*x - prev_ctrl[0], 2*y - prev_ctrl[1]) if prev_ctrl else (x, y)
            cur += quad((x,y),(x1,y1),(nx,ny))
            prev_ctrl = (x1, y1); x, y = nx, ny
        elif C == 'A':
            _rx,_ry,_rot,_laf,_sf,nx,ny = (num() for _ in range(7))
            if rel: nx+=x; ny+=y
            cur.append((nx, ny)); x, y = nx, ny; prev_ctrl = None
        elif C == 'Z':
            if cur:
                cur.append(start)
                subpaths.append(cur); cur = []
            x, y = start; prev_ctrl = None
        else:
            i += 1
    if cur: subpaths.append(cur)
    return subpaths

def get_paths(svg_text):
    return re.findall(r'<path[^>]*\bd="([^"]+)"', svg_text)

def _turn_at(P, i):
    b1 = math.degrees(math.atan2(P[i][0]-P[i-1][0], P[i][1]-P[i-1][1]))
    b2 = math.degrees(math.atan2(P[i+1][0]-P[i][0], P[i+1][1]-P[i][1]))
    return abs((b2 - b1 + 180) % 360 - 180)

def merge_shallow(P, thresh):
    """Greedily drop the vertex with the shallowest corner until every remaining
    corner turns at least `thresh` degrees - leaves only corners the robot pivots
    cleanly, straightening shallow bends the pivot can't hit."""
    P = P[:]
    while len(P) > 6:
        worst_i, worst = None, 999.0
        for i in range(1, len(P)-1):
            t = _turn_at(P, i)
            if t < worst: worst, worst_i = t, i
        if worst >= thresh: break
        P.pop(worst_i)
    return P

def close_gap(P, gx, gy):
    """Compass-rule closure: the robot ends a repeatable (gx,gy) mm from where it
    started, so pre-shift each point by that error scaled by how far along the path
    it sits - start unmoved, end moved the full -gap. When the robot re-adds its
    consistent error, the drawn end lands back on the start."""
    n = len(P)
    d = [0.0]*n
    for i in range(1, n):
        d[i] = d[i-1] + math.hypot(P[i][0]-P[i-1][0], P[i][1]-P[i-1][1])
    total = d[-1] or 1.0
    return [(P[i][0] - gx*d[i]/total, P[i][1] - gy*d[i]/total) for i in range(n)]

def main():
    if len(sys.argv) < 2:
        print(__doc__); sys.exit(1)
    infile = sys.argv[1]
    target_mm = 80.0
    if '--size' in sys.argv:
        target_mm = float(sys.argv[sys.argv.index('--size') + 1])
    min_turn = 20.0
    if '--min-turn' in sys.argv:
        min_turn = float(sys.argv[sys.argv.index('--min-turn') + 1])
    close_x = close_y = 0.0
    if '--close-x' in sys.argv: close_x = float(sys.argv[sys.argv.index('--close-x')+1])
    if '--close-y' in sys.argv: close_y = float(sys.argv[sys.argv.index('--close-y')+1])
    scale_x = scale_y = 1.0
    if '--scale-x' in sys.argv: scale_x = float(sys.argv[sys.argv.index('--scale-x')+1])
    if '--scale-y' in sys.argv: scale_y = float(sys.argv[sys.argv.index('--scale-y')+1])
    keep = None   # keep only the Nth <path> element (0-based); None = all
    if '--keep' in sys.argv: keep = int(sys.argv[sys.argv.index('--keep')+1])
    min_seg = 1.5

    svg = open(infile).read()
    paths = get_paths(svg)
    if keep is not None:
        paths = [paths[keep]]
    subs = []
    for d in paths:
        subs += parse_path(d)
    if not subs:
        print("No <path> data found.", file=sys.stderr); sys.exit(1)

    pts = [p for s in subs for p in s]
    xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
    minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
    w, h = maxx - minx, maxy - miny
    scale = target_mm / max(w, h)

    def tx(p):   # to mm, recentre, flip Y (SVG y-down -> plotter y-up), per-axis scale
        return ((p[0] - minx) * scale * scale_x, (maxy - p[1]) * scale * scale_y)

    out = [[tx(p) for p in s] for s in subs]
    def plen(s):
        return sum(math.hypot(s[i+1][0]-s[i][0], s[i+1][1]-s[i][1]) for i in range(len(s)-1))
    out = [s for s in out if plen(s) >= min_seg]
    if min_turn > 0:
        out = [merge_shallow(s, min_turn) for s in out]
    if close_x or close_y:
        main_i = max(range(len(out)), key=lambda i: len(out[i]))
        out[main_i] = close_gap(out[main_i], close_x, close_y)

    g = []
    for s in out:
        g.append(f"G0 X{s[0][0]:.3f} Y{s[0][1]:.3f}")
        for p in s[1:]:
            g.append(f"G1 X{p[0]:.3f} Y{p[1]:.3f}")
    g.append("G0 X0 Y0")
    print("\n".join(g))

    try:
        allp = [p for s in out for p in s]
        bx = [p[0] for p in allp]; by = [p[1] for p in allp]
        mnx, mxx, mny, mxy = min(bx), max(bx), min(by), max(by)
        pad = 6; W = mxx-mnx+2*pad; H = mxy-mny+2*pad
        svg_out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W*4}" height="{H*4}" '
                   f'viewBox="0 0 {W} {H}"><rect width="{W}" height="{H}" fill="white"/>']
        for s in out:
            pl = " ".join(f"{p[0]-mnx+pad:.2f},{mxy-p[1]+pad:.2f}" for p in s)
            svg_out.append(f'<polyline points="{pl}" fill="none" stroke="black" stroke-width="0.4"/>')
        svg_out.append("</svg>")
        base = infile.rsplit('.', 1)[0]
        open(base + '.preview.svg', 'w').write("\n".join(svg_out))
        n = sum(len(s) for s in out)
        print(f"\n[{len(out)} strokes, {n} points, {mxx-mnx:.0f}x{mxy-mny:.0f} mm; "
              f"preview -> {base}.preview.svg]", file=sys.stderr)
    except Exception as e:
        print(f"[preview skipped: {e}]", file=sys.stderr)

if __name__ == '__main__':
    main()

Finally, the outputted g-code can be pasted directly into the dashboard for the robot to read. If you want to view the G-code yourself, you can use this website: ncviewer.com, paste it into the field on the left, and click plot. This is what the dashboard I am using to control the robot looks like:

DashboardScreenshot_new

The status bar at the top shows you what’s happening (driving, turning, drawing, etc.). The Drive panel gives you manual control, so you can use it like any remote-control vehicle — forward, backward, turn both directions, and a Stop. The Pen & Plot panel toggles the servo (lifts/lowers the pen), draws a single test square, changes the movement speed, resets the distance, and zeroes the heading. Below that, the G‑Code panel is where the drawing actually happens: paste in your g-code and hit Reset origin to set the start point, then Run Custom G‑Code. The bottom bar is telemetry — distance, heading, speed, each encoder’s count, and the gyro calibration (S, G, M). When the calibration reads all 3s and lights up green, it’s ready to run; you calibrate it by moving the robot around in the air so the gyro can figure out where it is. This website changed quite a bit from the previous version, with new colors and buttons for g-code.

This is the full code of the finished robot:

/*************************************************************************
  robot plotter V7 - ESP32 web-controlled, structured after lingib's sketch
  lingib https://www.instructables.com/Gyro-Controlled-Robot-Plotter/
  differences: ESP32 + L9110 + Wi-Fi dashboard, gyro straightness, g-code interpretor
**************************************************************************/

#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BNO055.h>
#include <Servo.h>

// --- Wi-Fi ---

const char* ssid     = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

WebServer server(80);

// --- BNO055 fusion sensor ---
Adafruit_BNO055 bno = Adafruit_BNO055(55);
float yaw;   //raw heading straight off the sensor

// --- Pen lift ---
Servo penServo;
const int servoPin   = 10;      //D10
const int SERVO_UP   = 25+90;   //pen up - straight-up
const int SERVO_DOWN = 25;      //pen down - sideways
bool servoDown = false;         //tracks pen position

// --- L9110 motor controller (2 pins per motor, speed+dir coupled) ---
const int A1A = 6;
const int A1B = A0;
const int B1A = A1;
const int B1B = 11;

// --- Wheel encoders ---
const int enca[] = { 2, 3 };    //interrupt channel, one per motor
const int encb[] = { 4, 5 };    //second quadrature channel
volatile long encoderCount[2];  //signed counts, one per motor

// --- Initial wheel speeds ---
int baseSpeed = 140;            //driving speed (master)
const int MOTOR_TRIM = 20;      //+ boosts motor A to match B

// --- Heading control (gyro keeps lines straight) ---
float targetHeading = 0.0;
float startHeading  = 0.0;      //raw heading treated as zero (Zero Heading button)
const float Kp = 3.5;
const float Ki = 0.05;          //kills the residual drift
float headingIntegral = 0.0;    //running error sum - reset each drive
const float I_WINDUP = 1000.0;  //clamp so the sum can't run away

// --- Controlled stop ramp ---
int rampSpeed = 0;              //working speed while slowing
const int STOP_STEP = 8;        //how fast the ramp bleeds off
const int STOP_FLOOR = 40;      //brake once below this
int motorState = 0;             //0=stop 1=fwd 2=left 3=right 4=back 5,6=ramp

// --- Distance ---
const float COUNTS_PER_MM = 3.48;   //encoder counts per mm

// --- Turns ---
const float EPSILON_DEG  = 4.0;   //stop the turn within this many degrees
const int   PWM_TURN_MIN = 60;    //slowest PWM that still rotates
const int   PWM_TURN_MAX = 120;   //starting turn speed
const float PWM_DECAY    = 0.65;  //shrink turn speed after each overshoot
const unsigned long TURN_TIMEOUT_MS = 4000;   //never let a turn run longer
//Turns at or above this PIVOT; smaller course changes get steered while rolling.
//Steering needs travel distance to work (~30 mm to bend 20 deg), so on the short
//segments of a traced drawing it barely turns and flattens the corners. Pivoting
//is reliable down to the ~16 deg coast floor, so keep this just above that and let
//the g-code converter merge away any corner shallower than it - then every corner
//pivots (the primitive the calibration squares prove) and nothing is steered.
const float PIVOT_MIN_DEG = 18.0;

// --- Backlash compensation when turning ---
//A turn quits EPSILON_DEG short of where it aimed and then coasts past it, so it
//has to aim short by the difference. That difference does NOT scale with turn
//size: the taper bottoms out at the same PWM (~72) as the error closes on
//EPSILON_DEG, so a 90 corner and a 25 one let go at the same speed and coast the
//same ~16 deg (only ~6 counter-clockwise - this drivetrain is lopsided, which is
//why the two values differ so much). Hence a flat offset.
//The catch is a flat offset can't be applied to a turn smaller than itself - that
//would aim backwards - which is the dead zone moveTowards() steers around rather
//than pivots through. Tune these with the Draw Test Square button.
float BACKLASH_CW  = -12.3;  //degrees added to a clockwise turn
float BACKLASH_CCW = 2.2;    //degrees added to a counter-clockwise turn

// --- Square test-plot geometry ---
const float SQUARE_MM  = 40.0;   //side length of the Draw Test Square check

// --- G-code parameters ---
float X = 0.0;                  //XY drawing coordinates
float Y = 0.0;
float I = 0.0;                  //I,J circle offsets
float J = 0.0;
float scaleFactor = 1.0;        //scales g-code dimensions
bool continuousMotion = false;  //suppresses stop() during arc segments
String gcodeBuffer = "";        //holds custom g-code from web dashboard

// --- Housekeeping for cartesian tracking ---
//Two different positions, and the difference matters:
//  currentX/Y - where the G-CODE thinks the pen is. Ideal, exact, never measured.
//               Arc geometry is worked out in these coordinates.
//  actualX/Y  - where the robot ACTUALLY is, integrated from the encoders and the
//               gyro as it drives. Steering onto a bearing curves the path, so a
//               move lands a little short of the point it aimed at, and those
//               shortfalls used to compound silently over a long path (the whole
//               drawing would drift out from under the g-code). Aiming every move
//               from the measured position instead stops that from accumulating.
float currentX = 0.0;           //current X coordinate in mm (ideal)
float currentY = 0.0;           //current Y coordinate in mm (ideal)
float actualX  = 0.0;           //odometry - measured X in mm
float actualY  = 0.0;           //odometry - measured Y in mm
const float maxAngleStep = PI / 9.0; //step angle for drawing smooth arcs

// --- Routine scheduler (0 = idle) ---
volatile int routine = 0;

// --- Forward declarations for the encoder ISRs ---
void IRAM_ATTR encoder0ISR();
void IRAM_ATTR encoder1ISR();

//===========
//  setup()
//===========
void setup() {
  Serial.begin(115200);

  setupServo();   //home the pen
  setupMotors();  //motor pins + encoder interrupts

  // --- BNO055 fusion sensor ---
  if (!bno.begin()) {
    Serial.println("No BNO055 detected! Check I2C wiring.");
    while (1) { delay(10); }   //halt, but keep the watchdog fed
  }
  delay(500);
  bno.setExtCrystalUse(true);

  // --- Wi-Fi ---
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\n Wi-Fi Connected!");
  Serial.print("Robot IP Address: http://");
  Serial.println(WiFi.localIP());

  // --- Web routes (this is our command interpreter) ---
  server.on("/", handleRoot);
  server.on("/forward", handleForward);
  server.on("/backward", handleBackward);
  server.on("/left", handleLeft);
  server.on("/right", handleRight);
  server.on("/stop", handleStop);
  server.on("/telemetry", handleTelemetry);
  server.on("/reset", handleReset);
  server.on("/faster", handleFaster);
  server.on("/slower", handleSlower);
  server.on("/servo", handleServo);
  server.on("/zero_heading", handleZeroHeading);
  server.on("/cal_cw", handleCalCW);
  server.on("/reset_origin", handleResetOrigin);
  server.on("/custom_gcode", HTTP_POST, handleCustomGCode);

  server.begin();
}

//===========
//  loop()
//===========
void loop() {
  server.handleClient();

  // --- run any queued blocking routine here, not in a web handler ---
  if (routine == 1) { motorState = 0; drawSquareCW();  routine = 0; stop(); return; }
  if (routine == 8) { motorState = 0; runCustomGCode(); routine = 0; stop(); return; }

  // --- otherwise run the manual drive/turn state machine ---
  if (motorState == 1) {          //DRIVE STRAIGHT (gyro loop)
    float error = headingError(targetHeading);
    headingIntegral += error;     //accumulate leftover error, clamped
    headingIntegral = constrain(headingIntegral, -I_WINDUP, I_WINDUP);
    int correction = error * Kp + headingIntegral * Ki;
    setMotorA(baseSpeed - correction + MOTOR_TRIM, true);
    setMotorB(baseSpeed + correction, true);
  }
  else if (motorState == 2) {     //TURN LEFT (jog)
    setMotorA(baseSpeed, true);
    setMotorB(baseSpeed, false);
  }
  else if (motorState == 3) {     //TURN RIGHT (jog)
    setMotorA(baseSpeed, false);
    setMotorB(baseSpeed, true);
  }
  else if (motorState == 4) {     //DRIVE BACKWARD (gyro loop)
    float error = headingError(targetHeading);
    int correction = error * Kp;
    setMotorA(baseSpeed + correction, false);
    setMotorB(baseSpeed - correction, false);
  }
  else if (motorState == 5) {     //CONTROLLED STOP - forward
    float error = headingError(targetHeading);
    //keep the drive's integral so the ramp doesn't kick at the finish
    int correction = (error * Kp + headingIntegral * Ki) * (rampSpeed / (float)baseSpeed);
    setMotorA(rampSpeed - correction + MOTOR_TRIM, true);
    setMotorB(rampSpeed + correction, true);
    rampSpeed -= STOP_STEP;
    if (rampSpeed <= STOP_FLOOR) { motorState = 0; stop(); }
  }
  else if (motorState == 6) {     //CONTROLLED STOP - backward
    float error = headingError(targetHeading);
    int correction = error * Kp * (rampSpeed / (float)baseSpeed);
    setMotorA(rampSpeed + correction, false);   
    //mirror of motorState 4
    setMotorB(rampSpeed - correction, false);
    rampSpeed -= STOP_STEP;
    if (rampSpeed <= STOP_FLOOR) { motorState = 0; stop(); }
  }
  delay(20);
}

//===============
//  turn()
//===============
//rotate by the given angle - POSITIVE = clockwise, closes on the gyro only
void turn(float angle) {
  if (angle == 0) return;

  //aim short by however far it will coast past the stopping point - a flat
  //offset, since the taper always lets go at the same speed (see the constants)
  float backlash = (angle > 0) ? BACKLASH_CW : BACKLASH_CCW;
  float target   = normalize360(gyroHeading() + angle + backlash);

  int   pwmMax    = PWM_TURN_MAX;
  float lastError = 0.0;
  bool  first     = true;
  unsigned long tStart = millis();   //timeout safety net

  while (true) {
    server.handleClient();
    if (routine == 0) { stop(); return; }                    //aborted from the UI
    if (millis() - tStart > TURN_TIMEOUT_MS) { stop(); return; }  //won't settle

    float error = headingError(target);
    if (fabs(error) <= EPSILON_DEG) break;                   //close enough

    //overshoot: error changed sign since last pass, so we crossed the target
    if (!first && (error * lastError < 0)) {
      stop();
      delay(20);       //let inertia die
      pwmMax = max((int)(pwmMax * PWM_DECAY), PWM_TURN_MIN);
    }
    first = false;

    int cmd = map((long)fabs(error), 0, 20, PWM_TURN_MIN, pwmMax);   //taper near target
    cmd = constrain(cmd, PWM_TURN_MIN, pwmMax);

    //to make heading INCREASE (clockwise), motor A runs false and B runs true
    if (error > 0) { setMotorA(cmd, false); setMotorB(cmd, true); }
    else           { setMotorA(cmd, true);  setMotorB(cmd, false); }

    lastError = error;
    delay(5);
  }

  stop();
  delay(120);
}

//===============
//  move() / moveHeading()
//===============
//drive a straight line of the given length in mm - negative reverses
void move(float mm) {
  moveHeading(mm, gyroHeading());   //hold whatever heading we're already on
}

//drive the given length while steering onto an absolute heading. Aiming at a
//commanded bearing rather than the one we happen to be sitting on lets the drive
//pull out a pivot that finished a couple of degrees off, and lets a small course
//change be steered instead of pivoted at all - see moveTowards().
void moveHeading(float mm, float heading) {
  bool goForward    = (mm >= 0);
  long targetCounts = (long)(fabs(mm) * COUNTS_PER_MM);

  resetEncoders();
  targetHeading = heading;   //steer onto this the whole way, don't just hold
  long lastCounts = 0;

  while (true) {
    server.handleClient();
    if (routine == 0) { stop(); return; }   //aborted from the UI

    long  counts = avgCounts();
    float h      = gyroHeading();   //one gyro read per pass, reused below

    //odometry: add however far we rolled since the last pass, along the heading
    //we actually rolled it at. Done before the exit check so the last chunk counts
    float ds = (counts - lastCounts) / COUNTS_PER_MM;
    lastCounts = counts;
    if (!goForward) ds = -ds;
    actualX += ds * sin(radians(h));
    actualY += ds * cos(radians(h));

    if (counts >= targetCounts) break;

    float error      = normalize180(targetHeading - h);   //headingError(), reusing h
    int   correction = error * Kp;
    if (goForward) {
      setMotorA(baseSpeed - correction + MOTOR_TRIM, true);
      setMotorB(baseSpeed + correction, true);
    } else {
      setMotorA(baseSpeed + correction, false);
      setMotorB(baseSpeed - correction, false);
    }
    delay(5);   //yields to Wi-Fi and feeds the watchdog
  }

  //ignore stop() when plotting arc segments
  if (!continuousMotion) {
    stop();
    delay(120); //let rotor inertia die before the next
  }

  //Capture the braking coast into odometry. The loop above stops integrating the
  //moment counts pass the target, but the robot rolls a few mm further while it
  //brakes - and the next move's resetEncoders() would discard those counts. Left
  //unrecorded, that few-mm overshoot repeats every segment, always forward, and
  //sums around a closed path into a fixed closure gap. Fold it in here so odometry
  //ends where the robot actually ended.
  long  endCounts = avgCounts();
  float dsCoast   = (endCounts - lastCounts) / COUNTS_PER_MM;
  if (!goForward) dsCoast = -dsCoast;
  float he = gyroHeading();
  actualX += dsCoast * sin(radians(he));
  actualY += dsCoast * cos(radians(he));
}

//================
//  setupServo()
//================
void setupServo() {
  penServo.attach(servoPin, 1000, 2000);
  penServo.write(SERVO_UP);   //home straight-up
  delay(400);
  penServo.detach();          //cut pulses so it goes quiet
}

//=====================
//  penUp() / penDown()
//=====================
void penUp() {
  penServo.attach(servoPin, 1000, 2000);
  penServo.write(SERVO_UP);
  delay(400);
  penServo.detach();
  servoDown = false;
}

void penDown() {
  penServo.attach(servoPin, 1000, 2000);
  penServo.write(SERVO_DOWN);
  delay(400);
  penServo.detach();
  servoDown = true;
}

//=============================
//  setMotorA() / setMotorB()
//=============================
//L9110 phase control - speed and direction share the two pins
void setMotorA(int speed, bool forward) {
  speed = constrain(speed, 0, 255);
  if (forward) { digitalWrite(A1B, HIGH); analogWrite(A1A, 255 - speed); }
  else         { digitalWrite(A1B, LOW);  analogWrite(A1A, speed); }
}

void setMotorB(int speed, bool forward) {
  speed = constrain(speed, 0, 255);
  if (forward) { digitalWrite(B1A, LOW);  analogWrite(B1B, speed); }
  else         { digitalWrite(B1A, HIGH); analogWrite(B1B, 255 - speed); }
}

//===========================
//  stop() ... BRAKE mode
//===========================
//both inputs HIGH on a channel = brake; B's polarity is opposite A's
void stop() {
  setMotorA(0, true);
  setMotorB(0, false);
}

//=============================
//  setupMotors()
//=============================
void setupMotors() {
  pinMode(A1A, OUTPUT); pinMode(A1B, OUTPUT);
  pinMode(B1A, OUTPUT); pinMode(B1B, OUTPUT);

  pinMode(enca[0], INPUT_PULLUP); pinMode(encb[0], INPUT_PULLUP);
  pinMode(enca[1], INPUT_PULLUP); pinMode(encb[1], INPUT_PULLUP);

  attachInterrupt(digitalPinToInterrupt(enca[0]), encoder0ISR, CHANGE);
  attachInterrupt(digitalPinToInterrupt(enca[1]), encoder1ISR, CHANGE);
}

//==========================
//  encoder ISRs (quadrature)
//==========================
//IRAM_ATTR keeps the ISR in RAM so a tick during flash access can't crash it
void IRAM_ATTR encoder0ISR() {
  if (digitalRead(enca[0]) == digitalRead(encb[0])) encoderCount[0]++;
  else                                              encoderCount[0]--;
}

void IRAM_ATTR encoder1ISR() {
  if (digitalRead(enca[1]) == digitalRead(encb[1])) encoderCount[1]--;
  else                                              encoderCount[1]++;
}

//===============
//  resetEncoders() / avgCounts()
//===============
void resetEncoders() {
  noInterrupts();
  encoderCount[0] = 0;
  encoderCount[1] = 0;
  interrupts();
}

//average absolute travel of both wheels, in counts (abs because reverse decrements)
long avgCounts() {
  noInterrupts();
  long a = encoderCount[0];
  long b = encoderCount[1];
  interrupts();
  return (abs(a) + abs(b)) / 2;
}

///////////////// Gyro Functions ////////////////

//======================
//  readGyro()
//======================
float readGyro() {
  sensors_event_t event;
  bno.getEvent(&event);
  return yaw = event.orientation.x;   //yaw (heading) in degrees
}

//======================
//  gyroHeading()
//======================
//heading in 0..360, shifted by startHeading so it can be zeroed
float gyroHeading() {
  return normalize360(readGyro() - startHeading);
}

//======================
//  headingError()
//======================
//signed shortest error from where we are to the target
float headingError(float target) {
  return normalize180(target - gyroHeading());
}

//=========================
//  normalize360() / normalize180()
//=========================
float normalize360(float angle) {
  angle = fmod(angle, 360.0f);
  if (angle < 0) angle += 360.0f;
  return angle;
}

float normalize180(float angle) {
  angle = fmod(angle, 360.0f);
  if (angle > 180.0f)   angle -= 360.0f;
  if (angle <= -180.0f) angle += 360.0f;
  return angle;
}

/////////////// Test Plots ////////////////

//===============
//  drawSquareCW()
//===============
//one 40mm square; all four turns exercise the matching BACKLASH value. Wired to
//the dashboard's "Draw Test Square" button as a quick calibration check.
void drawSquareCW() {
  penDown();
  for (int side = 0; side < 4; side++) {
    if (routine == 0) { penUp(); return; }   //aborted
    move(SQUARE_MM);
    turn(90);        //clockwise
  }
  penUp();
}

///////////////// G-Code Interpreter ////////////////

//==========================
//  getValueFromMessage()
//==========================
//extracts numeric parameter after a command key like X or Y
float getValueFromMessage(const String &cmd, char code, float defaultValue) {
  int start = cmd.indexOf(code);
  if (start == -1) return defaultValue;
  int pos = start + 1;
  while (pos < cmd.length() && cmd[pos] == ' ') pos++;
  int end = pos;
  while (end < cmd.length() && (isDigit(cmd[end]) || cmd[end] == '.' || cmd[end] == '-' || cmd[end] == '+')) {
    end++;
  }
  return cmd.substring(pos, end).toFloat() * scaleFactor;
}

//========================
//  moveTowards()
//========================
//turns and drives to target cartesian coordinate
void moveTowards(float x, float y) {
  //aim from where we MEASURED ourselves to be, at the ideal target. Using the
  //g-code's own idea of where we are would assume every previous move landed
  //perfectly, and quietly bake in every millimetre it didn't.
  float dx = x - actualX;
  float dy = y - actualY;
  float targetAngle = atan2(dx, dy) * 180.0 / PI; //bearing from +Y (north), CW+
  float deltaAngle  = normalize180(targetAngle - gyroHeading());
  float dist        = sqrt(dx * dx + dy * dy);

  //Only pivot for a real course change. A stop-and-turn has a dead zone it can't
  //physically hit: it quits EPSILON_DEG short and then coasts ~12-16 deg past, so
  //asking for less than that either doesn't move at all or overshoots wildly.
  //That's fine for a 90 corner but ruins an arc, where every step is a few
  //degrees - so anything under PIVOT_MIN_DEG gets steered out while rolling
  //instead, which has no such floor and doesn't stop the wheels to do it.
  if (fabs(deltaAngle) > PIVOT_MIN_DEG) turn(deltaAngle);

  moveHeading(dist, targetAngle);   //steers onto the bearing either way
  currentX = x;
  currentY = y;
}

//============================
//  drawArc()
//============================
//approximates circular curves using short straight line segments
void drawArc(float targetX, float targetY, float offsetI, float offsetJ, bool cw) {
  continuousMotion = true;
  float centerX = currentX + offsetI;
  float centerY = currentY + offsetJ;
  float radius  = sqrt(offsetI * offsetI + offsetJ * offsetJ);
  float startAngle = atan2(currentY - centerY, currentX - centerX);
  float endAngle   = atan2(targetY - centerY, targetX - centerX);

  if (startAngle < 0) startAngle += 2 * PI;
  if (endAngle < 0)   endAngle   += 2 * PI;

  float arcAngle;
  if (cw) {
    arcAngle = fmod((startAngle - endAngle + 2 * PI), 2 * PI);
  } else {
    arcAngle = fmod((endAngle - startAngle + 2 * PI), 2 * PI);
  }

  int segments = max(1, (int)(arcAngle / maxAngleStep));
  for (int i = 1; i <= segments; i++) {
    if (routine == 0) break;    //aborted from the UI
    float theta;
    if (cw) {
      theta = startAngle - (arcAngle * i / segments);
    } else {
      theta = startAngle + (arcAngle * i / segments);
    }
    float px = centerX + radius * cos(theta);
    float py = centerY + radius * sin(theta);
    moveTowards(px, py);
  }
  continuousMotion = false;
  stop();
  delay(120);                   //let rotor inertia die
  currentX = targetX;
  currentY = targetY;
}

//======================
//  processCommand()
//======================
//parses standard g-code syntax and executes corresponding movements
void processCommand(String cmd) {
  cmd.trim();
  cmd.toUpperCase();
  if (cmd.length() == 0 || cmd.startsWith(";")) return; //skip empty lines and comments

  if (cmd.startsWith("G00") || cmd.startsWith("G0 ")) {
    X = getValueFromMessage(cmd, 'X', currentX);
    Y = getValueFromMessage(cmd, 'Y', currentY);
    penUp();
    moveTowards(X, Y);
  }
  else if (cmd.startsWith("G01") || cmd.startsWith("G1 ")) {
    X = getValueFromMessage(cmd, 'X', currentX);
    Y = getValueFromMessage(cmd, 'Y', currentY);
    penDown();
    moveTowards(X, Y);
  }
  else if (cmd.startsWith("G02") || cmd.startsWith("G2 ")) {
    X = getValueFromMessage(cmd, 'X', currentX);
    Y = getValueFromMessage(cmd, 'Y', currentY);
    I = getValueFromMessage(cmd, 'I', 0);
    J = getValueFromMessage(cmd, 'J', 0);
    penDown();
    drawArc(X, Y, I, J, true);  //clockwise arc
  }
  else if (cmd.startsWith("G03") || cmd.startsWith("G3 ")) {
    X = getValueFromMessage(cmd, 'X', currentX);
    Y = getValueFromMessage(cmd, 'Y', currentY);
    I = getValueFromMessage(cmd, 'I', 0);
    J = getValueFromMessage(cmd, 'J', 0);
    penDown();
    drawArc(X, Y, I, J, false); //counter-clockwise arc
  }
  else if (cmd.startsWith("PENUP")) { penUp(); }
  else if (cmd.startsWith("PENDOWN")) { penDown(); }
  else if (cmd.startsWith("TURN")) { turn(cmd.substring(4).toFloat()); }
  else if (cmd.startsWith("MOVE")) { move(cmd.substring(4).toFloat()); }
  else if (cmd.startsWith("STOP")) { stop(); }
  else if (cmd.startsWith("SCALE")) {
    int sp = cmd.indexOf(' ');
    if (sp != -1) scaleFactor = cmd.substring(sp + 1).toFloat();
  }
}

//======================
//  runCustomGCode()
//======================
//executes line-by-line commands pasted from the dashboard
void runCustomGCode() {
  while (gcodeBuffer.length() > 0 && routine == 8) {
    int newLine = gcodeBuffer.indexOf('\n');
    String line;
    if (newLine != -1) {
      line = gcodeBuffer.substring(0, newLine);
      gcodeBuffer = gcodeBuffer.substring(newLine + 1);
    } else {
      line = gcodeBuffer;
      gcodeBuffer = "";
    }
    processCommand(line);
  }
  penUp();
}

///////////////// Web UI ////////////////

//==========
//  handleRoot() - the dashboard
//==========
void handleRoot() {
  String html = "<!DOCTYPE html><html><head>";
  html += "<meta charset='UTF-8'>";
  html += "<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'>";
  html += "<title>Robot Control</title>";
  html += "<style>";
  html += "*{box-sizing:border-box;margin:0;padding:0}";
  html += "html,body{width:100%;min-height:100%;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}";
  html += "body{display:flex;justify-content:center;min-height:100vh;padding:22px 16px 40px;color:#e6edf3}";
  html += ".dashboard{width:100%;max-width:600px}";
  html += ".eyebrow{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px;margin-bottom:14px}";
  html += ".eyebrow .dot{width:8px;height:8px;border-radius:50%;background:#e85c40}";
  html += ".status-bar{width:100%;padding:11px;margin-bottom:16px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600;font-size:13px;letter-spacing:.5px;text-align:center;border:1px solid #30363d;color:#8b949e;background:#161b22;transition:all .2s ease}";
  html += ".status-bar.active{border-color:#e85c40;color:#e85c40;background:rgba(232,92,64,0.10)}";
  html += ".groups{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}";
  html += ".group{border:1px solid #30363d;border-radius:6px;background:#161b22;padding:13px;display:flex;flex-direction:column;gap:8px}";
  html += ".group-label{font-size:11px;letter-spacing:.6px;text-transform:uppercase;color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}";
  html += ".row2{display:grid;grid-template-columns:1fr 1fr;gap:8px}";
  html += ".btn{display:block;width:100%;padding:9px 12px;font-size:14px;font-weight:600;font-family:inherit;border-radius:6px;border:1px solid #30363d;background:#21262d;color:#e6edf3;cursor:pointer;user-select:none;-webkit-user-select:none;transition:background .15s ease,border-color .15s ease}";
  html += ".btn:hover{background:#30363d}";
  html += ".btn:active{transform:translateY(1px)}";
  html += ".btn-primary{background:#e85c40;color:#0d1117;border-color:transparent}";
  html += ".btn-primary:hover{background:#f2734f}";
  html += ".btn-stop{background:#e85c40;color:#0d1117;border-color:transparent}";
  html += ".btn-stop:hover{background:#f2734f}";
  html += ".telemetry-bar{display:grid;grid-template-columns:repeat(auto-fit,minmax(92px,1fr));gap:10px;margin-top:12px;padding:13px;border:1px solid #30363d;border-radius:6px;background:#161b22}";
  html += ".tele{text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}";
  html += ".tele-label{font-size:10px;letter-spacing:.6px;color:#8b949e;text-transform:uppercase}";
  html += ".tele-val{font-size:19px;font-weight:600;margin-top:3px;color:#e6edf3;font-variant-numeric:tabular-nums}";
  html += "textarea{width:100%;min-height:150px;background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;resize:vertical}";
  html += "textarea:focus{outline:none;border-color:#e85c40}";
  html += "</style></head><body>";

  html += "<div class='dashboard'>";
  html += "<div class='eyebrow'><span class='dot'></span>Gyro Robot Plotter</div>";
  html += "<div id='statusText' class='status-bar'>STATUS: STOPPED</div>";
  html += "<div class='groups'>";

  html += "<div class='group'>";
  html += "<div class='group-label'>Drive</div>";
  html += "<button onclick='sendCommand(\"/forward\",\"STATUS: DRIVING\")' class='btn'>Drive</button>";
  html += "<div class='row2'>";
  html += "<button onclick='sendCommand(\"/left\",\"STATUS: TURNING LEFT\")' class='btn'>Left</button>";
  html += "<button onclick='sendCommand(\"/right\",\"STATUS: TURNING RIGHT\")' class='btn'>Right</button>";
  html += "</div>";
  html += "<button onclick='sendCommand(\"/backward\",\"STATUS: REVERSING\")' class='btn'>Backward</button>";
  html += "<button onclick='sendCommand(\"/stop\",\"STATUS: STOPPED\")' class='btn btn-stop'>Stop</button>";
  html += "</div>";

  html += "<div class='group'>";
  html += "<div class='group-label'>Pen &amp; Plot</div>";
  html += "<button onclick='sendCommand(\"/servo\",\"\")' class='btn'>Toggle Pen</button>";
  html += "<button onclick='sendCommand(\"/cal_cw\",\"STATUS: DRAWING SQUARE\")' class='btn'>Draw Test Square</button>";
  html += "<div class='row2'>";
  html += "<button onclick='sendCommand(\"/slower\",\"\")' class='btn'>Slower</button>";
  html += "<button onclick='sendCommand(\"/faster\",\"\")' class='btn'>Faster</button>";
  html += "</div>";
  html += "<button onclick='sendCommand(\"/zero_heading\",\"\")' class='btn'>Zero Heading</button>";
  html += "<button onclick='sendCommand(\"/reset\",\"STATUS: STOPPED\")' class='btn'>Reset Distance</button>";
  html += "</div>";

  html += "</div>";   //end groups

  html += "<div class='group' style='margin-top:12px'>";
  html += "<div class='group-label'>G-Code</div>";
  html += "<textarea id='gcodeBox' placeholder='Paste G-code here, or from the catalog...'></textarea>";
  html += "<button onclick='uploadGCode()' class='btn btn-primary'>Run Custom G-Code</button>";
  html += "<button onclick='sendCommand(\"/reset_origin\",\"\")' class='btn'>Reset Origin</button>";
  html += "</div>";

  html += "<div class='telemetry-bar'>";
  html += "<div class='tele'><div class='tele-label'>Distance</div><div id='distanceText' class='tele-val'>0.0 cm</div></div>";
  html += "<div class='tele'><div class='tele-label'>Heading</div><div id='headingText' class='tele-val'>0.0&deg;</div></div>";
  html += "<div class='tele'><div class='tele-label'>Speed</div><div id='speedText' class='tele-val'>0</div></div>";
  html += "<div class='tele'><div class='tele-label'>Encoders</div><div id='telemetryText' class='tele-val' style='font-size:14px'>A 0 | B 0</div></div>";
  html += "<div class='tele'><div class='tele-label'>Gyro Cal</div><div id='calText' class='tele-val' style='font-size:14px;color:#8b949e'>S0 G0 M0</div></div>";
  html += "</div>";

  html += "</div>";   //end dashboard

  html += "<script>";
  html += "function sendCommand(route, labelText) {";
  html += "  fetch(route);";
  html += "  if(labelText === '') return;";
  html += "  const sBox = document.getElementById('statusText');";
  html += "  sBox.innerText = labelText;";
  html += "  if(labelText.includes('STOPPED')) sBox.classList.remove('active'); else sBox.classList.add('active');";
  html += "}";

  html += "function uploadGCode(){";
  html += " const txt=document.getElementById('gcodeBox').value;";
  html += " fetch('/custom_gcode',{";
  html += "   method:'POST',";
  html += "   headers:{'Content-Type':'text/plain'},";
  html += "   body:txt";
  html += " }).then(()=>{";
  html += "   const s=document.getElementById('statusText');";
  html += "   s.innerText='STATUS: RUNNING CUSTOM GCODE';";
  html += "   s.classList.add('active');";
  html += " });";
  html += "}";

  html += "setInterval(() => {";
  html += "  fetch('/telemetry').then(res => res.json()).then(data => {";
  html += "    let cmAvg = (data.dist/10).toFixed(1);";
  html += "    document.getElementById('distanceText').innerText = `${cmAvg} cm`;";
  html += "    document.getElementById('headingText').innerText = `${data.h}°`;";
  html += "    document.getElementById('speedText').innerText = `${data.spd}`;";
  html += "    document.getElementById('telemetryText').innerText = `A ${data.a} | B ${data.b}`;";
  html += "    const cal = document.getElementById('calText');";
  html += "    cal.innerText = `S${data.cs} G${data.cg} M${data.cm}`;";
  html += "    cal.style.color = (data.cs >= 3 && data.cg >= 3) ? '#3fb950' : '#d29922';";
  html += "    const sBox = document.getElementById('statusText');";
  html += "    if(data.r == 1) { sBox.innerText = 'STATUS: DRAWING SQUARE'; sBox.classList.add('active'); }";
  html += "    else if(data.r == 8) { sBox.innerText = 'STATUS: RUNNING CUSTOM GCODE'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 0) { sBox.innerText = 'STATUS: STOPPED'; sBox.classList.remove('active'); }";
  html += "    else if(data.s == 1) { sBox.innerText = 'STATUS: DRIVING'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 2) { sBox.innerText = 'STATUS: TURNING LEFT'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 3) { sBox.innerText = 'STATUS: TURNING RIGHT'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 4) { sBox.innerText = 'STATUS: REVERSING'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 5 || data.s == 6) { sBox.innerText = 'STATUS: SLOWING'; sBox.classList.add('active'); }";
  html += "  });";
  html += "}, 300);";
  html += "</script></body></html>";

  server.send(200, "text/html", html);
}

///////////////// Web Handlers ////////////////

void handleForward() {
  targetHeading = gyroHeading();
  headingIntegral = 0.0;   //fresh start
  motorState = 1;
  server.send(200, "text/plain", "OK");
}

void handleBackward() {
  targetHeading = gyroHeading();
  motorState = 4;
  server.send(200, "text/plain", "OK");
}

void handleLeft()  { motorState = 2; server.send(200, "text/plain", "OK"); }
void handleRight() { motorState = 3; server.send(200, "text/plain", "OK"); }

//pick the right controlled stop based on how we were moving
void handleStop() {
  routine = 0;   //kill any running routine first
  if (motorState == 1)      { targetHeading = gyroHeading(); rampSpeed = baseSpeed; motorState = 5; }
  else if (motorState == 4) { targetHeading = gyroHeading(); rampSpeed = baseSpeed; motorState = 6; }
  else                      { motorState = 0; stop(); }
  server.send(200, "text/plain", "OK");
}

//queue the blocking routines - return instantly, they run in loop()
void handleCalCW()      { routine = 1; server.send(200, "text/plain", "OK"); }  //Draw Test Square
//zeroes cartesian origin - both the ideal position and the odometry, or the two
//would disagree from the first move and every aim would be off by the difference
void handleResetOrigin(){
  currentX = 0.0; currentY = 0.0;
  actualX  = 0.0; actualY  = 0.0;
  server.send(200, "text/plain", "OK");
}

void handleCustomGCode() {                                                      //receives custom gcode block
  if (server.hasArg("plain")) {
    gcodeBuffer = server.arg("plain");
    routine = 8;
    server.send(200, "text/plain", "OK");
  } else {
    server.send(400, "text/plain", "NO DATA");
  }
}

//pen toggle from the dashboard
void handleServo() {
  if (servoDown) penUp(); else penDown();
  server.send(200, "text/plain", "OK");
}

void handleTelemetry() {
  float distA   = encoderCount[0] / COUNTS_PER_MM;
  float distB   = encoderCount[1] / COUNTS_PER_MM;
  float distAvg = (distA + distB) / 2.0;

  //BNO055 calibration state, 0 (uncalibrated) to 3 (fully calibrated) per subsystem.
  //Yaw only holds steady once sys and gyro read 3; drawing before then is what puts
  //the loop's finish off from its start. mag drifts near the running motors' magnets.
  uint8_t cSys, cGyro, cAccel, cMag;
  bno.getCalibration(&cSys, &cGyro, &cAccel, &cMag);

  String json = "{\"a\":" + String(encoderCount[0]) +
                ",\"b\":" + String(encoderCount[1]) +
                ",\"dist\":" + String(distAvg, 1) +
                ",\"spd\":" + String(baseSpeed) +
                ",\"h\":" + String(gyroHeading(), 1) +
                ",\"r\":" + String(routine) +
                ",\"cs\":" + String(cSys) +
                ",\"cg\":" + String(cGyro) +
                ",\"cm\":" + String(cMag) +
                ",\"s\":" + String(motorState) + "}";
  server.send(200, "application/json", json);
}

void handleReset() {
  resetEncoders();
  server.send(200, "text/plain", "OK");
}

//zero the heading reference
void handleZeroHeading() {
  startHeading = readGyro();
  server.send(200, "text/plain", "OK");
}

//speed controls
void handleFaster() { baseSpeed = constrain(baseSpeed + 20, 60, 255); server.send(200, "text/plain", "OK"); }
void handleSlower() { baseSpeed = constrain(baseSpeed - 20, 60, 255); server.send(200, "text/plain", "OK"); }

There were quite a few challenges at BSE that I encountered. The first of these was getting started with my project in the first place. It seemed like a lot going in, especially to someone who had limited robotics experience. Seeing the crazy network of soldered wires the example image showed for their project didn’t help much either. However, with a bit of help and lots of thinking/planning, I was finally able to start putting pieces together on a breadboard and got the circuit working by the end of the week. Another major challenge that I had to overcome was the accuracy of the robot. Pretty much by the end of the third week, all the hardware was completely finished, but the robot just couldn’t draw with consistent lines and turns. This led to two full weeks of calibration and figuring out what to change on the software side, but eventually did produce a finished project that I am extremely proud of.

The key topics I covered were coding with Arduino and Adafruit components such as the BNO055 sensor and the servo, which all have their own functions in C++. I also got familiar with html coding to assist with creating the website. Troubleshooting was another key topic that I had to get pretty proficient at throughout the duration of the camp. I had to use many different resources to figure out how things worked and to get them to work. In the future, I hope to continue with robotics and engineering and create more projects, learning more code, hardware, and other software.

Second Milestone

My second milestone was a fully functional, wired robot that had all the major components in place, such as the pencil lift, the breadboards, and the DC Motors. This was a big change from the first milestone where I just had a breadboard with a bunch of wires providing connection, but there was no actual robot that could function. Now, the website my Arduino shares allows me to send it commands to move forward/backward, turn both directions, lift/lower the pencil using the servo motor, and track distance traveled using the encoders. Here is what my website looks like at the second milestone:

Jul9WebsiteScreenshot

Here is my code at the second milestone:

Code at Milestone II

#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BNO055.h>
#include <Servo.h>

//Wi-Fi Configuration
const char* ssid     = "WIFI_NETWORK";
const char* password = "WIFI_PASSWORD";

WebServer server(80); 

//Hardware Pin Assignments (Nano ESP32 Layout)
const int A1A = 6;  
const int A1B = A0; 
const int B1A = A1; 
const int B1B = 11;

const int encA1 = 2; 
const int encA2 = 4; 
const int encB1 = 3; 
const int encB2 = 5; 

volatile long encACount = 0;
volatile long encBCount = 0;

//Distance tracking - needs to be calibrated
const float COUNTS_PER_MM = 3.7;   //encoder counts per 1 mm of travel

//Sensor & Control Variables
Adafruit_BNO055 bno = Adafruit_BNO055(55);
float targetHeading = 0.0;
const float Kp = 3.5;       
int baseSpeed = 100;
const int MOTOR_TRIM = 0;   //+ boosts motor A to match B - tune until it drives straight open-loop
int rampSpeed = 0;              //working speed during a controlled stop
const int STOP_STEP = 8;        //how fast the ramp bleeds off - lower = gentler
const int STOP_FLOOR = 40;      //brake once it is below motor deadband is 
int motorState = 0; //0 = Stop, 1 = Drive Straight, 2 = Left, 3 = Right

//Servo (pen-lift) setup
Servo penServo;
const int SERVO_PIN  = 10;   //D10  
const int SERVO_UP   = 25+90;   //pen up - straight-up position
const int SERVO_DOWN = 25;    //pen down - 90 deg to sideways
bool servoDown = false;      //tracks which position the servo is in

void setup() {
  Serial.begin(115200);
  
  pinMode(A1A, OUTPUT); pinMode(A1B, OUTPUT);
  pinMode(B1A, OUTPUT); pinMode(B1B, OUTPUT);
  
  pinMode(encA1, INPUT_PULLUP); pinMode(encA2, INPUT_PULLUP);
  pinMode(encB1, INPUT_PULLUP); pinMode(encB2, INPUT_PULLUP);
  
  attachInterrupt(digitalPinToInterrupt(encA1), ISR_A, CHANGE);
  attachInterrupt(digitalPinToInterrupt(encB1), ISR_B, CHANGE);

  //home the servo straight-up, then detach so it stops buzzing/drawing current
  penServo.attach(SERVO_PIN, 1000, 2000);
  penServo.write(SERVO_UP);
  delay(400);              //let it actually reach the position
  penServo.detach();       //cut the pulses - servo goes quiet and limp

  if(!bno.begin()) {
    Serial.println("No BNO055 detected! Check I2C wiring.");
    while(1);
  }
  delay(500);
  bno.setExtCrystalUse(true);

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\n Wi-Fi Connected!");
  Serial.print("Robot IP Address: http://");
  Serial.println(WiFi.localIP());

  //Web Server Route Bindings
  server.on("/", handleRoot);
  server.on("/forward", handleForward);
  server.on("/backward", handleBackward);
  server.on("/left", handleLeft);
  server.on("/right", handleRight);
  server.on("/stop", handleStop);
  server.on("/telemetry", handleTelemetry); 
  server.on("/reset", handleReset);
  server.on("/faster", handleFaster);
  server.on("/slower", handleSlower);
  server.on("/servo", handleServo);


  
  server.begin(); 
}

void loop() {
  server.handleClient(); 

  if (motorState == 1) { //DRIVE STRAIGHT (Using Gyro Loop)
    sensors_event_t event;
    bno.getEvent(&event);
    float currentHeading = event.orientation.x;
    
    float error = targetHeading - currentHeading;
    if (error > 180)  error -= 360;
    if (error < -180) error += 360;

    int correction = error * Kp;
    setMotorA(baseSpeed - correction + MOTOR_TRIM, true);
    setMotorB(baseSpeed + correction, true);

  } 
  else if (motorState == 2) { //TURN LEFT
    setMotorA(baseSpeed, true); 
    setMotorB(baseSpeed, false);
  }
  else if (motorState == 3) { //TURN RIGHT
    setMotorA(baseSpeed, false); 
    setMotorB(baseSpeed, true);
  }
  else if (motorState == 4) { //DRIVE BACKWARD (Gyro Loop)
    sensors_event_t event;
    bno.getEvent(&event);
    float currentHeading = event.orientation.x;

    float error = targetHeading - currentHeading;
    if (error > 180)  error -= 360;
    if (error < -180) error += 360;

    int correction = error * Kp;
    setMotorA(baseSpeed + correction, false);
    setMotorB(baseSpeed - correction, false);
  }
  else if (motorState == 5) { //CONTROLLED STOP - forward (hold heading while slowing)
    sensors_event_t event;
    bno.getEvent(&event);
    float currentHeading = event.orientation.x;

    float error = targetHeading - currentHeading;
    if (error > 180)  error -= 360;
    if (error < -180) error += 360;

    int correction = error * Kp * (rampSpeed / (float)baseSpeed);   //scale with speed so it can't dominate as we slow
    setMotorA(rampSpeed - correction + MOTOR_TRIM, true);
    setMotorB(rampSpeed + correction, true);

    rampSpeed -= STOP_STEP;
    if (rampSpeed <= STOP_FLOOR) { motorState = 0; setMotorA(0, true); setMotorB(0, true); }
  }
  else if (motorState == 6) { //CONTROLLED STOP - backward (ramp down in reverse)
    sensors_event_t event;
    bno.getEvent(&event);
    float currentHeading = event.orientation.x;

    float error = targetHeading - currentHeading;
    if (error > 180)  error -= 360;
    if (error < -180) error += 360;

    int correction = error * Kp * (rampSpeed / (float)baseSpeed);   //scale with speed so it can't dominate as we slow
    setMotorA(rampSpeed - correction + MOTOR_TRIM, true);
    setMotorB(rampSpeed + correction, true);

    rampSpeed -= STOP_STEP;
    if (rampSpeed <= STOP_FLOOR) { motorState = 0; setMotorA(0, true); setMotorB(0, true); }
  }
  delay(20);
}

//UI coding
void handleRoot() {
  String html = "<!DOCTYPE html><html><head>";
  //setup
  html += "<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'>";
  html += "<title>ROBOT CONTROL</title>";
  html += "<style>";
  html += "* { box-sizing: border-box; margin: 0; padding: 0; }";
  html += "html, body { width: 100%; height: 100%; background-color: #010307; font-family: 'Segoe UI', Arial, sans-serif; overflow: hidden; display: flex; align-items: center; justify-content: center; }";
  html += "#bgCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }";
  html += ".dashboard { position: relative; z-index: 2; width: 92%; max-width: 500px; padding: 35px 25px; background: rgba(2, 5, 12, 0.92); border: 2px solid #00aaff; box-shadow: 0 0 35px rgba(0, 170, 255, 0.4); border-radius: 14px; backdrop-filter: blur(8px); text-align: center; }";
  
  //red status bar at top
  html += ".status-bar { width: 100%; padding: 14px; margin-bottom: 25px; border-radius: 8px; font-weight: bold; font-size: 15px; letter-spacing: 2px; text-transform: uppercase; border: 1px solid #f44336; color: #ff5252; background: rgba(244, 67, 54, 0.15); transition: all 0.3s ease; }";
  html += ".status-bar.active { border: 1px solid #00ffdd; color: #00ffdd; background: rgba(0, 255, 221, 0.15); box-shadow: 0 0 15px rgba(0,255,221,0.2); }";
  
  //button configurations
  html += ".control-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; }";
  html += ".drive-row, .stop-row { grid-column: span 2; }";
  html += ".btn { display: block; width: 100%; padding: 20px; font-size: 18px; font-weight: 800; text-transform: uppercase; letter-spacing: 2px; text-decoration: none; border-radius: 8px; border: 2px solid transparent; transition: all 0.2s ease; cursor: pointer; user-select: none; -webkit-user-select: none; }";
  html += ".btn-drive { color: #02050d; background-color: #00ffdd; box-shadow: 0 0 20px rgba(0, 255, 221, 0.4); }";
  html += ".btn-turn { color: #ffffff; background-color: rgba(0, 136, 255, 0.25); border-color: #0088ff; box-shadow: 0 0 15px rgba(0, 136, 255, 0.2); }";
  html += ".btn-stop { color: #ffffff; background-color: #e63946; box-shadow: 0 0 20px rgba(230, 57, 70, 0.4); }";
  html += ".btn:hover { transform: translateY(-2px); filter: brightness(1.2); }";
  html += ".btn:active { transform: translateY(1px); }";
  html += ".telemetry { margin-top: 20px; font-size: 13px; color: #526d82; letter-spacing: 1px; font-family: monospace; }";
  html += "</style></head><body>";
  
  html += "<canvas id='bgCanvas'></canvas>";
  
  //updates status bar when buttons clicked
  html += "<div class='dashboard'>";
  html += "<div id='statusText' class='status-bar'>STATUS: STOPPED</div>";
  
  html += "<div class='control-grid'>";
  html += "<div class='drive-row'><button onclick='sendCommand(\"/forward\",\"STATUS: DRIVING\")' class='btn btn-drive'>Drive</button></div>";
  html += "<div class='drive-row'><button onclick='sendCommand(\"/backward\",\"STATUS: REVERSING\")' class='btn btn-turn'>Backward</button></div>";
  html += "<div><button onclick='sendCommand(\"/left\",\"STATUS: TURNING LEFT\")' class='btn btn-turn'>Turn Left</button></div>";
  html += "<div><button onclick='sendCommand(\"/right\",\"STATUS: TURNING RIGHT\")' class='btn btn-turn'>Turn Right</button></div>";
  html += "<div class='stop-row'><button onclick='sendCommand(\"/stop\",\"STATUS: STOPPED\")' class='btn btn-stop'>Stop</button></div>";
  html += "<div class='stop-row'><button onclick='sendCommand(\"/reset\",\"STATUS: STOPPED\")' class='btn btn-turn'>Reset Distance</button></div>";
  //toggles pen between up and sideways - empty label so status bar is untouched
  html += "<div class='stop-row'><button onclick='sendCommand(\"/servo\",\"\")' class='btn btn-turn'>Toggle Pen</button></div>";
  html += "<div><button onclick='sendCommand(\"/slower\",\"\")' class='btn btn-turn'>Slower</button></div>";
  html += "<div><button onclick='sendCommand(\"/faster\",\"\")' class='btn btn-turn'>Faster</button></div>";
  html += "</div>";
  

  html += "<div id='distanceText' class='telemetry' style='font-size:16px; color:#00ffdd; margin-bottom:6px;'>DISTANCE: 0.0 cm</div>";
  html += "<div id='telemetryText' class='telemetry'>ENC_A: 0 | ENC_B: 0</div>";
  html += "</div>";
  
  //moving dot network
  html += "<script>";
  html += "const canvas = document.getElementById('bgCanvas'); const ctx = canvas.getContext('2d');";
  html += "let points = []; ";
  html += "const numPoints = 120; "; 
  html += "const maxDist = 300; "; 
  
  html += "function init() { ";
  html += "  canvas.width = window.innerWidth; canvas.height = window.innerHeight; points = []; ";
  html += "  for(let i=0; i<numPoints; i++) { ";
  html += "    let isRedNet = (i > numPoints * 0.52); "; 
  html += "    points.push({ ";
  html += "      x: Math.random()*canvas.width, y: Math.random()*canvas.height, ";
  //micro-adjustment to speed to keep vectors crisp
  html += "      vx: (Math.random()-0.5)*0.5, vy: (Math.random()-0.5)*0.5, ";
  html += "      type: isRedNet ? 'red' : 'cyan' ";
  html += "    }); ";
  html += "  } ";
  html += "}";
  
  html += "function draw() { ";
  html += "  ctx.clearRect(0, 0, canvas.width, canvas.height);";
  html += "  for(let i=0; i<numPoints; i++) { ";
  html += "    let p = points[i]; p.x += p.vx; p.y += p.vy; ";
  html += "    if(p.x<0||p.x>canvas.width) p.vx*=-1; if(p.y<0||p.y>canvas.height) p.vy*=-1;";
  
  html += "    ctx.fillStyle = (p.type === 'red') ? '#ff2233' : '#00ffdd'; ";
  html += "    ctx.beginPath(); ctx.arc(p.x, p.y, p.type === 'red' ? 2.5 : 2.0, 0, Math.PI*2); ctx.fill();";
  
  html += "    for(let j=i+1; j<numPoints; j++) { ";
  html += "      let p2 = points[j]; ";
  html += "      if(p.type === p2.type) { "; 
  html += "        let dist = Math.hypot(p.x-p2.x, p.y-p2.y);";
  html += "        if(dist < maxDist) { ";
  //opacity formula for the dots
  html += "          let alpha = (1 - dist/maxDist) * 0.65; "; 
  html += "          ctx.strokeStyle = (p.type === 'red') ? `rgba(255, 45, 15, ${alpha})` : `rgba(0, 160, 255, ${alpha})`; ";
  html += "          ctx.lineWidth = 1.5; "; // Thickened from 1 to 1.5 for presence
  html += "          ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); ";
  html += "        } ";
  html += "      } ";
  html += "    } ";
  html += "  } ";
  html += "  requestAnimationFrame(draw); ";
  html += "}";
  
  html += "window.addEventListener('resize', init); init(); draw();";
  
  html += "function sendCommand(route, labelText) {";
  html += "  fetch(route);";
  html += "  if(labelText === '') return;";        // speed buttons: fire request, don't touch status
  html += "  const sBox = document.getElementById('statusText');";
  html += "  sBox.innerText = labelText;";
  html += "  if(labelText.includes('STOPPED')) sBox.classList.remove('active'); else sBox.classList.add('active');";
  html += "}";

  
  html += "setInterval(() => {";
  html += "  fetch('/telemetry').then(res => res.json()).then(data => {";
  html += "    let cmA = (data.da/10).toFixed(1); let cmB = (data.db/10).toFixed(1); let cmAvg = (data.dist/10).toFixed(1);";
  html += "    document.getElementById('distanceText').innerText = `DISTANCE: ${cmAvg} cm  (A ${cmA} | B ${cmB})`;";
  html += "    document.getElementById('telemetryText').innerText = `ENC_A: ${data.a} | ENC_B: ${data.b}`;";
  html += "    document.getElementById('telemetryText').innerText = `SPD: ${data.spd} | ENC_A: ${data.a} | ENC_B: ${data.b}`;";
  html += "    const sBox = document.getElementById('statusText');";
  html += "    if(data.s == 0) { sBox.innerText = 'STATUS: STOPPED'; sBox.classList.remove('active'); }";
  html += "    else if(data.s == 1) { sBox.innerText = 'STATUS: DRIVING'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 2) { sBox.innerText = 'STATUS: TURNING LEFT'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 3) { sBox.innerText = 'STATUS: TURNING RIGHT'; sBox.classList.add('active'); }";
  html += "    else if(data.s == 4) { sBox.innerText = 'STATUS: REVERSING'; sBox.classList.add('active'); }";
  html += "  });";
  html += "}, 300);"; 
  html += "</script></body></html>";
  
  server.send(200, "text/html", html);
}

//background directional controls
void handleForward() {
  sensors_event_t event;
  bno.getEvent(&event);
  targetHeading = event.orientation.x; 
  motorState = 1;
  server.send(200, "text/plain", "OK"); 
}

void handleBackward() {
  sensors_event_t event;
  bno.getEvent(&event);
  targetHeading = event.orientation.x;
  motorState = 4;
  server.send(200, "text/plain", "OK");
}

void handleLeft()  { motorState = 2; server.send(200, "text/plain", "OK"); }
void handleRight() { motorState = 3; server.send(200, "text/plain", "OK"); }

//stopping
//stopping - pick the right controlled stop based on how we were moving
void handleStop() {
  sensors_event_t event;
  bno.getEvent(&event);

  if (motorState == 1) {          //was driving forward
    targetHeading = event.orientation.x;
    rampSpeed = baseSpeed;
    motorState = 5;               //5 = controlled stop, forward
  }
  else if (motorState == 4) {     //was driving backward
    targetHeading = event.orientation.x;
    rampSpeed = baseSpeed;
    motorState = 6;               //6 = controlled stop, backward
  }
  else {                          //turning or already stopped - just brake
    motorState = 0;
    setMotorA(0, true);
    setMotorB(0, true);
  }
  server.send(200, "text/plain", "OK");
}


//servo toggle - flips the pen between up and sideways
void handleServo() {
  servoDown = !servoDown;
  penServo.attach(SERVO_PIN, 1000, 2000);   //re-attach for the move
  if (servoDown) {
    penServo.write(SERVO_DOWN);   //rotate down to sideways
  } else {
    penServo.write(SERVO_UP);     //back to straight-up
  }
  delay(400);                     //give it time to actually get there
  penServo.detach();              //stop pulsing so it stops buzzing/drawing
  server.send(200, "text/plain", "OK");
}


void handleTelemetry() {
  float distA   = encACount / COUNTS_PER_MM;      // mm
  float distB   = encBCount / COUNTS_PER_MM;      // mm
  float distAvg = (distA + distB) / 2.0;          // mm (forward travel)

  String json = "{\"a\":" + String(encACount) +
                ",\"b\":" + String(encBCount) +
                ",\"da\":" + String(distA, 1) +
                ",\"db\":" + String(distB, 1) +
                ",\"dist\":" + String(distAvg, 1) +
                ",\"spd\":" + String(baseSpeed) +
                ",\"s\":" + String(motorState) + "}";
  server.send(200, "application/json", json);
}

void handleReset() {
  noInterrupts();
  encACount = 0;
  encBCount = 0;
  interrupts();
  server.send(200, "text/plain", "OK");
}

//Core Motor Phase Controls
void setMotorA(int speed, bool forward) {
  speed = constrain(speed, 0, 255);
  if (forward) {
    digitalWrite(A1B, HIGH);
    analogWrite(A1A, 255 - speed);
  } else {
    digitalWrite(A1B, LOW);
    analogWrite(A1A, speed);
  }
}

void setMotorB(int speed, bool forward) {
  speed = constrain(speed, 0, 255);
  if (forward) {
    digitalWrite(B1A, LOW);
    analogWrite(B1B, speed);
  } else {
    digitalWrite(B1A, HIGH);
    analogWrite(B1B, 255 - speed);
  }
}

//speed controls
void handleFaster() {
  baseSpeed = constrain(baseSpeed + 20, 60, 255);
  server.send(200, "text/plain", "OK");
}
void handleSlower() {
  baseSpeed = constrain(baseSpeed - 20, 60, 255);
  server.send(200, "text/plain", "OK");
}

//Interrupt Service Routines (monitors sensor pins for the main loop)
void ISR_A() {
  if (digitalRead(encA1) == digitalRead(encA2)) {
    encACount++;
  } else {
    encACount--;
  }
}

void ISR_B() {
  if (digitalRead(encB1) == digitalRead(encB2)) {
    encBCount--;
  } else {
    encBCount++;
  }
}

A huge part of this milestone was attaching the components to the base of the robot. I’m using an acrylic base, and initially I was going to go put the wheels on the longer sides of the robot, but due to poor measurements, I had to reformat so the robot drives straight. It’s now much wider than it is long, but it has the pen lift directly in the center so it can spin around the tip as a pivot point. One other thing that I did was change it from one long breadboard to two mini breadboards, which involved rewiring every single connection.

Another challenge was that the wheels kept getting stuck. This was because the initial motor mounts that I had didn’t stop the wheels from rubbing against the base, creating enough friction to stop them from turning altogether. I had to go back and re-cad the mounts and get them reprinted in order to keep the robot running smoothly.

Original motor mount, wheels rubbing the base Re-CADded motor mount with clearance Before

For the next milestone, I’m going to have to dive into the software a lot, and work on converting drawings into shapes. I’m also going to have to calibrate pretty much every part of the robot to ensure it is as accurate as possible in making drawings.

First Milestone

My first milestone was planning out the full build and getting all the electronics figured out before putting the robot together.

The robot has a few main parts that all have to work with each other. The Arduino Nano is the brain that controls everything. Two N20 motors with encoders drive the wheels and keep track of how far the robot has moved. A BNO055 gyro measures which way the robot is facing so it can turn accurately. An SG90 servo raises and lowers the pencil/drawing utensil, a L9110 motor driver sits between the Arduino and the motors and controls their speed and direction.

The biggest challenge at this stage was getting started. Initially, the wiring diagrams looked way too complicated to understand, so it took a few days to really start to get it, and I didn’t start wiring with the breadboard until about a week in. However, it wasn’t just the wires. I also had to figure out what all the parts I mentioned above actually did. We were also initially going to use an Arduino UNO, TB6612FNG motor driver, and an HC-05 for bluetooth, but we switched all of those things out for the ESP32 nano & L9110 driver, so I had to get a deeper understanding of how to wire them. Eventually, I did get it and now I have a fully functioning circuit:

Completed circuit, side view
Completed circuit, side view
Completed circuit, top view
Completed circuit, top view


For my next milestones I plan to attach everything to the base (involves transferring the entire circuit to two smaller breadboards), get the motors and gyro working together so the robot can drive straight, and then attach the pen-lift so it can actually draw.

Circuit Diagram

Full circuit diagram, completed version

Bill of Materials

Part Note Price Link
Arduino NANO ESP32 Main microcontroller that runs the plotter and the g-code interpreter $20 Link
BNO055 Sensor Fusion Module 9-axis IMU/gyro used for accurate heading and turns $35 Link
N20 6V 60 RPM DC Motor with Encoder (x2) Drive motors for the two wheels; encoders track distance $18 Link
L9110 Motor Driver Controls speed/direction of the two N20 motors $2 Link
SG90 Micro Servo Pen-lift mechanism (raises and lowers the pen) $2 Link
7806 6V Voltage Regulator Regulated 6V rail for the motors and servo $8 Link
9V Battery Powers the Arduino and the 6V regulator $2 Link
1N4007 Diode (x2) Reverse-polarity protection on the power input $1 Link
Toggle Switch Main on/off power switch $3 Link
Screw Terminal Block Connection point for the 9V power input wires $1 Link
Jumper / Hookup Wires Wiring between all components $7 Link
Acrylic Sheet (base) Flat platform that all components bolt onto $2 Link
Wheels (x2) Driven by the N20 motors to move the plotter $5 Link
Misc. Hardware (screws, bolts, nuts) Mounts servo bracket, motors, and other parts $5 Link
3D Printed Parts (pen-lift assembly, battery holder, 2 glides) Printed from the included STL files; cost is filament only $2 STL files
Pencil / Fibre-tip Pen The drawing instrument held by the pen-lift tube $1 Link

Estimated total: ~$114 at the prices above. The original project lists an estimated cost of under $100 excluding shipping, so the total will depend on which clone/branded parts you buy.

Starter Project

My Starter Project was the LED Slider that was pretty much just soldering practice. The end goal was intended to be three different sliders that increase/decrease resistance for red, green, and blue parts of an LED, changing the color.

However, when I soldered everything on, it wouldn’t work. I de-soldered and re-soldered pretty much every connection but it would never work. I even tried flipping the orientation of the light bulb, yet it still wouldn’t turn on. In the end, I had to unfortunately give up to begin working on my intensive project, the Drawing Robot. However, my soldering skills greatly improved throughout the process because although there were initially less than 30 connections, I ended up working on around 100 different solders. I also used these skills in the main project, so it was a very good introduction, even if I didn’t get the end product.

Other Resources & Examples

Obviously, this is not the only drawing robot that exists. I took inspiration for this project from lingib’s gyro-controlled drawing robot. This is what I used for the circuit diagrams, 3d printed parts, and many of the materials, although I did make a good amount of changes to key materials/pieces and the circuits as well:

Other Resources & Examples

Obviously, this is not the only drawing robot that exists. I took inspiration for this project from lingib’s gyro-controlled drawing robot. This is what I used for the circuit diagrams, 3d printed parts, and many of the materials, although I did make a good amount of changes to key materials/pieces and the circuits as well:

Other examples of drawing robots can be found below:

Thank You Bluestamp Engineering! Bluestamp Engineering Logo