System integration

Assignment:

  • Design and document the system integration for your final project

Have you answered these questions?

  • implemented methods of packaging?
  • designed your final project to look like a finished product?
  • documented system integration of your final project?
  • linked to your system integration documentation from your final project page?

This is the model example of this week's documentation -- as shown in Neil's weekly notes.

BOM

Component Info Quantity Supplier Product code Price Total
Nema 17 --- --- --- --- --- ---
Servo motor --- --- --- --- --- ---
WTOO 624Z Bearings --- --- --- --- --- ---
4mm shaft --- --- --- --- --- ---
608ZZ bearing --- --- --- --- --- ---
8mm shaft --- --- --- --- --- ---
DRV8825 step stick --- --- --- --- --- ---
XIAO ESP32C3 MCU --- --- --- --- --- ---
100 uF capacitor --- --- --- --- --- ---
HW 411 buck converter --- --- --- --- --- ---
Wire (to servo) --- --- --- --- --- ---
Knorr Prandell bead thread --- --- --- --- --- ---
Aluminum extrusions --- --- --- --- --- ---
220 ohm resistor --- --- --- --- --- ---
SM-S2309S servo motor --- --- --- --- --- ---
button --- --- --- --- --- ---
LED --- --- --- --- --- ---
FR1 --- --- --- --- --- ---
Bolts --- --- --- --- --- ---
XXX --- --- --- --- --- ---
XXX --- --- --- --- --- ---
XXX --- --- --- --- --- ---
XXX --- --- --- --- --- ---
XXX --- --- --- --- --- ---

Mechanical

This week I focused on finalizing how the systems of my project would fit together. Henk and I practiced with Lego to help visualize how pulleys can help make the lifting easier.

Frame

The mechanical design utilizes a frame made of aluminum extrusions that is 250mm tall, 8mm deep, and 200mm wide. The only constraint being that there is often minimal space behind a toilet seat. I measured a few of the recurring toilets in my life and 8mm seemed safe / conservative.

stepper

Stepper motor

Connected to the frame will be the NEMA 17 stepper motor. The stepper motor turns a spur gear that rotates another, larger, spur gear, that is connected to a 4mm D-shaft. Along the D-shaft is a spool. The spool is connected to a cable. As the motor turns, it will wind up the cable around the spool.

stepper

stepper

spool

Clamp

On the other end of the cable is a clamp. The clamp holds onto the toilet lid. On top of the clamp is a servo motor that rotates and latches onto the toilet seat below.

clamp 2

The screw that holds the clamp in place will also be attached to the cable that is being pulled by the servo motor.

servo

Cover

The machine will all be encased with a cover. But more on that later, in wildcard week!

cover

Retainer

On Friday Saco came in. He sat with me and helped me make a few adjustments to the design of my project. He inspired a few new things too!

One of the big questions I was trying to solve was how I was going to get the gears to rotate. On the stepper motor, it has a "D-shaft". Which is basically a normal rod, except that it has a flat edge.

Cutting a flat edge into a metal rod is not easily do-able in our lab. Which is where the retainer comes in.

timber

With this design feature, I can tighten components to the shaft / rod.

Electronics system diagram

In Global Open Time Rico had drafted this for me. It's a rough sketch of my electronics system diagram.

rico

Shout out to Rico.

Here's my version:

diagram

Wiring and assembly

Servo (to XIAO): 1x GPIO, 1x 5V / VIN, 1x GND

Stepper (to XIAO): 1x 3V - 5V logic power supply, 1x GND, 2x GPIO

NB. RST and SLP pins?

Stepper (to motor power supply): VMOT, GND, 100uF decoupling capacitor, 4x phase pins

Button to XIAO: 220Ω resistor , 1x logic power supply connection, 1x GND, 1x GPIO

wiring

Still to consider...

Battery vs Wall power?

I asked AI about 24W portable power supplies and this is one of the options:

You can make your own portable 12V 24W supply by wiring together ten AA NiMH batteries in series (1.2V x 10 = 12V) or using a small sealed lead-acid or Li-ion battery with a suitable voltage and capacity.

Otherwise, here's a 12V 3A wall socket power supply.

dc jack

Limit switch to XIAO: 1x VCC (3V3 or 5V), 1x GND, 10k resistor

Buck converter

In order to power the XIAO and stepper motor using the same power supply, a buck converter is needed. The buck converter will convert the power supply to 5V for the XIAO.

Here's a bit about how buck converters work.

This is the buck converter that I'll use for my project:

buck converter

Here's a decent blog post about the HW-411.

Button read / stepper change

I spent a fair bit of time on Friday, Saturday and Sunday setting up the buck converter AND getting a button to change the direction of the stepper motor.

I had shied away from coding at most opportunities during the course. So, to start, I looked up push button tutorials. Here's another one from the same website that was very helpful.

It took a long time to figure out how to get the code to do exactly what I wanted, but in the end, when it did finally worked, it was extremely satisfying.

Below, in the code, I figured out how to use a bool lastButtonState and int counter to toggle the stepper motor between moving forwards and backwards.

#include <AccelStepper.h>

const int buttonPIN = D6;

int counter = 0;
bool lastButtonState = LOW;

AccelStepper stepper(AccelStepper::DRIVER, D0, D1);

void setup() {
  Serial.begin(9600);

  pinMode(buttonPIN, INPUT_PULLUP);

  stepper.setMaxSpeed(1000);
  stepper.setSpeed(500);
}

void loop() {
  int buttonState = digitalRead(buttonPIN);

  if (buttonState != lastButtonState) {
    lastButtonState = buttonState;

    if (buttonState == HIGH) { // Button pressed
      counter++;
      if (counter >= 2) counter = 0; // Reset after 2 presses

      // Toggle speed/direction
      if (counter == 1) {
        stepper.setSpeed(-500); // Reverse
      } else {
        stepper.setSpeed(500); // Forward (original speed)
      }
    }
  }
stepper.runSpeed(); // Always run the stepper
}

Digital files

This is the code for the: Button push, change servo direction .ino.

External controller

After some role playing, I realized that my original idea of having a foot activated toilet would actually be kind of annoying. So, I decided to shift to a wall mounter controller.

The controller will have:

  • 3x buttons
  • 1x neopixel
  • 1x XIAO ESP32C3
  • 1x TP4056 battery charger
  • 1x 3.7V rechargeable battery

Modes

The three buttons will correspond to three modes that the toilet lifter will operate under:

  • One (Piss)
  • Two (Lid)
  • Henk (EMERGENCY)

Option One will make the servo go to 90. Which will lift the toilet seat and the lid.

Option Two will make the servo go to 65. And will only lift the toilet lid.

Option Henk will make the servo go to 90 AND it will make the toilet seat lift a lot faster.

Stepper movement

In all modes the stepper must turn in the direction that winds the cable in until it hits the limit switch.

I'm still not sure by how much exactly the stepper should wind the other way, when it should start doing that, and what activates that happening.

ESP-NOW

Here is the first code that I got working that sends the bool value of the limitSwitch from one ESP32 to the ESP32 other.

Master
#include <esp_now.h>
#include <WiFi.h>

uint8_t GrannySmith[] = {0x64, 0xe8, 0x33, 0x00, 0x9c, 0xfc};

const int limitSwitchPin = D0;

typedef struct test_struct {
  bool switchState;
} test_struct;

test_struct test;

esp_now_peer_info_t peerInfo;

// callback when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  Serial.print("Packet to: ");
  // Copies the sender mac address to a string
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}

void setup() {
  Serial.begin(115200);
  pinMode(limitSwitchPin, INPUT);
  WiFi.mode(WIFI_STA);

  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }

  esp_now_register_send_cb(OnDataSent);

  // register peer
  peerInfo.channel = 0;  
  peerInfo.encrypt = false;

  // register first peer  
  memcpy(peerInfo.peer_addr, GrannySmith, 6);
  if (esp_now_add_peer(&peerInfo) != ESP_OK){
    Serial.println("Failed to add peer");
    return;
  }
}

void loop() {
  test.switchState = digitalRead(limitSwitchPin);
  Serial.println(digitalRead(limitSwitchPin));

  esp_err_t result1 = esp_now_send(GrannySmith, (uint8_t *) &test, sizeof(test_struct));

  if (result1 == ESP_OK) {
    Serial.println("Sent with success");
  }
  else {
    Serial.println("Error sending the data");
  }
  delay(2000);
}
Secondary
#include <esp_now.h>
#include <WiFi.h>

typedef struct test_struct {
  bool switchState;
} test_struct;

//Create a struct_message called myData
test_struct myData;

//callback function that will be executed when data is received
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
  memcpy(&myData, incomingData, sizeof(myData));
  Serial.print("Bytes received: ");
  Serial.println(len);
  Serial.print("Switch value: ");
  Serial.println(myData.switchState);
  Serial.println();
}

void setup() {
  //Initialize Serial Monitor
  Serial.begin(115200);

  //Set device as a Wi-Fi Station
  WiFi.mode(WIFI_STA);

  //Init ESP-NOW
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }

  // Once ESPNow is successfully Init, we will register for recv CB to
  // get recv packer info
  esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
}

void loop() {

}

Button reading

For the controller, I need the MCU to read three different buttons.

I found this guy's code on an Arduino forum when I was looking up callback functions
#include <MsTimer2.h>

const byte buttonPin = 5;

void setup()
{
  Serial.begin(9600);
  pinMode(13, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);
}

void loop()
{
  if(buttonFivePressed())
  {
   flashLedThirteen(5000);
  }
}

bool buttonFivePressed(void)
{
  static unsigned long lastMillis = 0;
  static byte lastPress = HIGH;
  byte currentPress = digitalRead(buttonPin);
  if(currentPress != lastPress)
  {
    if(millis() - lastMillis < 200) return false;
    lastPress = currentPress;
    if(currentPress == LOW)
    {
      Serial.println(" button press detected!");
      lastMillis = millis();
      return true;
    }
  }
  return false;
}

void flashLedThirteen(int ledTime)
{
  digitalWrite(13, HIGH);
  Serial.println("LED ON");
  // sets a new timer callback function
  MsTimer2::set(ledTime, [] {  // the square brackets define the start of the anonymous callback function which is executed after 5000 milliseconds in this example
    digitalWrite(13, LOW);
    Serial.println("Timer expired...");
    Serial.println("LED OFF");
    MsTimer2::stop();
  }); // the curly brace defines the end of the anonymous callback function
  MsTimer2::start();
}

source

I wrote this code based on that
const byte buttonPin = 5;
const byte buttonPin2 = 6;
const byte buttonPin3 = 7;

void setup()
{
  Serial.begin(9600);
  pinMode(13, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(buttonPin2, INPUT_PULLUP);
  pinMode(buttonPin3, INPUT_PULLUP);
}

void loop()
{
  if(buttonFivePressed())
  {
   digitalWrite(13, HIGH);
  }
  if(buttonSIXPressed())
  {
   digitalWrite(13, HIGH);
  }
  if(buttonSEVENPressed())
  {
   digitalWrite(13, HIGH);
  }
}

bool buttonFivePressed(void)
{
  static unsigned long lastMillis = 0;
  static byte lastPress = HIGH;
  byte currentPress = digitalRead(buttonPin);
  if(currentPress != lastPress)
  {
    if(millis() - lastMillis < 200) return false;
    lastPress = currentPress;
    if(currentPress == LOW)
    {
      Serial.println(" button press detected!");
      lastMillis = millis();
      return true;
    }
  }
  return false;
}
bool buttonSIXPressed(void)
{
  static unsigned long lastMillis = 0;
  static byte lastPress = HIGH;
  byte currentPress = digitalRead(buttonPin2);
  if(currentPress != lastPress)
  {
    if(millis() - lastMillis < 200) return false;
    lastPress = currentPress;
    if(currentPress == LOW)
    {
      Serial.println(" button press detected!");
      lastMillis = millis();
      return true;
    }
  }
  return false;
}
bool buttonSEVENPressed(void)
{
  static unsigned long lastMillis = 0;
  static byte lastPress = HIGH;
  byte currentPress = digitalRead(buttonPin3);
  if(currentPress != lastPress)
  {
    if(millis() - lastMillis < 200) return false;
    lastPress = currentPress;
    if(currentPress == LOW)
    {
      Serial.println(" button press detected!");
      lastMillis = millis();
      return true;
    }
  }
  return false;
}
Then I asked AI to make my code more efficient
const byte buttonPins[] = {5, 6, 7};  // All your button pins in one array
const int numButtons = 3;

// Arrays to track each button's state
unsigned long lastMillis[numButtons] = {0};
byte lastPress[numButtons] = {HIGH, HIGH, HIGH};

void setup()
{
  Serial.begin(9600);
  pinMode(13, OUTPUT);

  // Set up all buttons with a simple loop
  for(int i = 0; i < numButtons; i++)
  {
    pinMode(buttonPins[i], INPUT_PULLUP);
  }
}

void loop()
{
  // Check all buttons with one loop
  for(int i = 0; i < numButtons; i++)
  {
    if(buttonPressed(i))
    {
      digitalWrite(13, HIGH);
    }
  }
}

bool buttonPressed(int buttonIndex)
{
  byte currentPress = digitalRead(buttonPins[buttonIndex]);

  if(currentPress != lastPress[buttonIndex])
  {
    if(millis() - lastMillis[buttonIndex] < 200) return false;
    lastPress[buttonIndex] = currentPress;

    if(currentPress == LOW)
    {
      Serial.print("Button ");
      Serial.print(buttonIndex + 1);  // Print 1, 2, 3 instead of 0, 1, 2
      Serial.println(" pressed!");
      lastMillis[buttonIndex] = millis();
      return true;
    }
  }
  return false;
}
Made a few minor tweaks to get that working with my XIAO board
const byte buttonPins[] = {D5, D6, D7};  // Changed this to the XIAO button format
const int numButtons = 3;

unsigned long lastMillis[numButtons] = {0};
byte lastPress[numButtons] = {HIGH, HIGH, HIGH};

void setup()
{
  Serial.begin(9600);

  for(int i = 0; i < numButtons; i++)
  {
    pinMode(buttonPins[i], INPUT_PULLUP);
  }
}

void loop()
{
  for(int i = 0; i < numButtons; i++)
  {
    if(buttonPressed(i))
    {
      Serial.println(digitalRead(i)); // Checking on Serial if the MCU was reading the correct button pushes
    }
  }
}

bool buttonPressed(int buttonIndex)
{
  byte currentPress = digitalRead(buttonPins[buttonIndex]);

  if(currentPress != lastPress[buttonIndex])
  {
    if(millis() - lastMillis[buttonIndex] < 200) return false;
    lastPress[buttonIndex] = currentPress;

    if(currentPress == LOW)
    {
      Serial.print("Button ");
      Serial.print(buttonIndex + 1);  
      Serial.println(" pressed!");
      lastMillis[buttonIndex] = millis();
      return true;
    }
  }
  return false;
}
Then I inputted the code for the different button pushes into my earlier ESPNOW code

Master:

#include <esp_now.h>
#include <WiFi.h>

uint8_t GrannySmith[] = {0x64, 0xe8, 0x33, 0x00, 0x9c, 0xfc};

const byte buttonPins[] = {D5, D6, D7};
const int numButtons = 3;

unsigned long lastMillis[numButtons] = {0};
byte lastPress[numButtons] = {HIGH, HIGH, HIGH};

bool buttonPressed(int buttonIndex)
{
  byte currentPress = digitalRead(buttonPins[buttonIndex]);

  if(currentPress != lastPress[buttonIndex])
  {
    if(millis() - lastMillis[buttonIndex] < 200) return false;
    lastPress[buttonIndex] = currentPress;

    if(currentPress == LOW)
    {
      Serial.print("Button ");
      Serial.print(buttonIndex + 1);  // Print 1, 2, 3 instead of 0, 1, 2
      Serial.println(" pressed!");
      lastMillis[buttonIndex] = millis();
      return true;
    }
  }
  return false;
}

typedef struct test_struct {
  int buttonState;
} test_struct;

test_struct test;

esp_now_peer_info_t peerInfo;

// callback when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  Serial.print("Packet to: ");
  // Copies the sender mac address to a string
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}

void setup() {
  Serial.begin(115200);
  for(int i = 0; i < numButtons; i++)
  {
    pinMode(buttonPins[i], INPUT_PULLUP);
  }

  WiFi.mode(WIFI_STA);
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  esp_now_register_send_cb(OnDataSent);
  peerInfo.channel = 0;  
  peerInfo.encrypt = false; 
  memcpy(peerInfo.peer_addr, GrannySmith, 6);
  if (esp_now_add_peer(&peerInfo) != ESP_OK){
    Serial.println("Failed to add peer");
    return;
  }
}

void loop() {
  bool buttonWasPressed = false;  // Flag to track if any button was pressed

  for(int i = 0; i < numButtons; i++)
  {
    if(buttonPressed(i))
    {
      test.buttonState = i + 1;  // Store button number (1, 2, 3) not index (0, 1, 2)
      Serial.print("Sending button state: ");
      Serial.println(test.buttonState);
      buttonWasPressed = true;
      break;  // Exit loop after first button press to avoid conflicts
    }
  }

  // Only send ESP-NOW message if a button was actually pressed
  if(buttonWasPressed) {
    esp_err_t result1 = esp_now_send(GrannySmith, (uint8_t *) &test, sizeof(test_struct));

    if (result1 == ESP_OK) {
      Serial.println("Sent with success");
    }
    else {
      Serial.println("Error sending the data");
    }
  }

  delay(100);  // Reduced delay for better responsiveness
}

Secondary

#include <esp_now.h>
#include <WiFi.h>
#include <AccelStepper.h>

AccelStepper stepper(AccelStepper::DRIVER, D0, D1);

typedef struct test_struct {
  int buttonState;
} test_struct;

//Create a struct_message called myData
test_struct myData;

//callback function that will be executed when data is received
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
  memcpy(&myData, incomingData, sizeof(myData));
  Serial.print("Bytes received: ");
  Serial.println(len);
  Serial.print("Button number: ");
  Serial.println(myData.buttonState);
  Serial.println();
}

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

  WiFi.mode(WIFI_STA);
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));

  stepper.setMaxSpeed(1000);
  stepper.setSpeed(500);
}

void loop() {
  if (myData.buttonState == 1) {
  stepper.setSpeed(-500);
  }
  if (myData.buttonState == 2) {
    stepper.setSpeed(500);
  }
  if (myData.buttonState == 3) {
    stepper.setSpeed(1000);
  }
  stepper.runSpeed(); 
}

Non blocking timer

This code adds the stepper
#include <ESP32Servo.h>
#include <esp_now.h>
#include <WiFi.h>
#include <AccelStepper.h>

AccelStepper stepper(AccelStepper::DRIVER, D0, D1);

Servo myservo1;

unsigned long servoMoveTime = 0;
bool servoMoving = false;
bool stepperShouldStart = false;
int pendingStepperSpeed = 0;
const unsigned long SERVO_DELAY = 1000;

typedef struct test_struct {
  int buttonState;
} test_struct;

//Create a struct_message called myData
test_struct myData;

//callback function that will be executed when data is received
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
  memcpy(&myData, incomingData, sizeof(myData));
  Serial.print("Bytes received: ");
  Serial.println(len);
  Serial.print("Button number: ");
  Serial.println(myData.buttonState);
  Serial.println();
}

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

  myservo1.attach(2);

  WiFi.mode(WIFI_STA);
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));

  stepper.setMaxSpeed(1000);
  stepper.setSpeed(0);
}

void loop() {
  if (myData.buttonState == 1) {
  stepper.setSpeed(-500);
  myservo1.write(65);
  }
  else if (myData.buttonState == 2) {
    stepper.setSpeed(-1000);
    myservo1.write(90);
  }
  else if (myData.buttonState == 3) {
    stepper.setSpeed(1000);
    myservo1.write(0);
  }
  else if (myData.buttonState == 4) {
    stepper.setSpeed(0);
    myservo1.write(65);
  }
  else {
    stepper.setSpeed(0);
    myservo1.write(65);
  }
  stepper.runSpeed(); 
}
I asked AI to help write my code above in a way that would make the servo move first and then the stepper motor after, here's the working code for that
#include <ESP32Servo.h>
#include <esp_now.h>
#include <WiFi.h>
#include <AccelStepper.h>

AccelStepper stepper(AccelStepper::DRIVER, D0, D1);
Servo myservo1;

unsigned long servoMoveTime = 0;
bool servoMoving = false;
bool stepperShouldStart = false;
int pendingStepperSpeed = 0;
int pendingServoPosition = 65;
int lastButtonState = 0;
const unsigned long SERVO_DELAY = 500; // Adjust based on your servo speed

typedef struct test_struct {
  int buttonState;
} test_struct;

test_struct myData;

void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
  memcpy(&myData, incomingData, sizeof(myData));
  Serial.print("Bytes received: ");
  Serial.println(len);
  Serial.print("Button number: ");
  Serial.println(myData.buttonState);
  Serial.println();
}

void setup() {
  Serial.begin(115200);
  myservo1.attach(2);
  myservo1.write(65); // Initial position

  WiFi.mode(WIFI_STA);
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));

  stepper.setMaxSpeed(1000);
  stepper.setSpeed(0);
}

void loop() {
  // Check if button state has changed
  if (myData.buttonState != lastButtonState) {
    lastButtonState = myData.buttonState;

    // Determine servo position and stepper speed based on button state
    switch (myData.buttonState) {
      case 1:
        pendingServoPosition = 65;
        pendingStepperSpeed = -500;
        break;
      case 2:
        pendingServoPosition = 90;
        pendingStepperSpeed = -1000;
        break;
      case 3:
        pendingServoPosition = 0;
        pendingStepperSpeed = 1000;
        break;
      case 4:
        pendingServoPosition = 65;
        pendingStepperSpeed = 0;
        break;
      default:
        pendingServoPosition = 65;
        pendingStepperSpeed = 0;
        break;
    }

    // Start servo movement first
    myservo1.write(pendingServoPosition);
    servoMoveTime = millis();
    servoMoving = true;
    stepperShouldStart = false;

    // Stop stepper immediately when new command comes
    stepper.setSpeed(0);
  }

  // Check if servo has finished moving and start stepper
  if (servoMoving && (millis() - servoMoveTime >= SERVO_DELAY)) {
    servoMoving = false;
    stepperShouldStart = true;
  }

  // Start stepper after servo delay
  if (stepperShouldStart) {
    stepper.setSpeed(pendingStepperSpeed);
    stepperShouldStart = false;
  }

  // Always run stepper (non-blocking)
  stepper.runSpeed(); 
}