Team 21: Valtteri Turkki

Project plan

The plan is to create a device that records data from the suspension of a mountain bike and then presents the data in a form that allows the rider to judge whether the adjustments made were good or not. This way the mountain biker should be able to find the correct suspension tune faster, which means that there would be more time for riding the bike. The data recorded from the suspension and the bike will be the linear position of the damper shafts, acceleration of the frame, braking points (brakes on or off) and GPS data. These will be logged to a memory card as a .csv file that is easy to analyze afterwards in Excel or Matlab.  In addition the device might have a wireless connection that allows quick access to the recorded data. The figure below shows the connections between different components of the device.

There were five requirements set for the project and it was split into three stages so that the device was functional after stage 1 and stages 2 and 3 would improve the features of the device. The five requirements are:

  1. The measurements have to be reasonably accurate and the acquired data should be meaningful in a way that it can be used for suspension tuning and generally experimenting with suspension characteristics.
  2. The device must be suitable for mountain bike use, meaning that it will need to be portable, small and it should withstand the elements.
  3. The device is as universal as reasonably possible, meaning that it really measures the suspension travel, not air chamber pressure or something else, and can fit most bikes. One aspect is also that the device should be easily improvable in the future e.g. the whole thing doesn't need to be re-engineered if one wants to for instance put a Raspberry pi zero inside instead of Arduino Nano.
  4. Overview of the data should be accessible without connecting the device to PC.
  5. (Bonus) The project should be so cheap and functional that it would really become appealing for the average rider to build this by oneself.

The three stages are following: Stage one is to fulfill the first three requirements i.e., have a working device that produces a bunch of numbers. Second stage is to improve the usability of the device for instance with better user-interface and with more advanced features, such as automatic recording based on gpx-file track segments. The third stage is to implement a phone app that allows the user to see an overview of the recorded data on the fly without a laptop. The bonus goal about the cost will be estimated in the end and the final report might have alternative parts lists so that one can choose the suitable cost and feature level, if one decides to build this.

Components for the project

As the device needs to be small and portable, the components were selected so that the are power efficient and fit in a small enclosure. The component list is shown in the below table.

ComponentModelQuantityPriceSourced from
MicrocontrollerArduino Nano 33 BLE131,16Elfa Distrelec
GPS moduleArduino mkr GPS shield132,56Elfa Distrelec
MicroSD adapterAdafruit 25419,85Elfa Distrelec
LiPo chargerAdafruit 1944118,40Elfa Distrelec
Battery1350 mAh 3.7 V LiPo battery114,50Partco
OLED display32x128 I2C display with SSD1306 driver19,90Own storage
PotentiometersVishay 20 kohm 10 turn, 534B1203JBC214,40Partco
Micro switchesOmron NC ip67 miniature switch21,08Elfa Distrelec
Sensor connectorsSP13 ip68 connectors (4 and 5 pole models)2+25,95Partco
Push buttonsIp67 NO push button 0.4 A 32 V2~10Own storage
Electric components

Active buzzer, 2x diodes, 2x resistor(1K and 3K),

ON-OFF switch, DC21 jack and wires

-~5Own storage
Mechanical parts

Bunch of M3 nuts and bolts, 2x spiral springs,

piece of plexiglass and some O-rings

--Own storage / springs from old key chains
3D printer filamentPrusament PETG 1.75 mm orange 1 kg spool129,99Prusa research a.s.

The microcontroller unit for the project was chosen to be Arduino nano 33 BLE. The reasons for choosing that board are that it features integrated Bluetooth and IMU, so it helps to keep the size down. It also features 12-bit analog to digital converter and more processor power and memory than a standard nano. These might come handy as a bit similar project in 2017 mentioned that uno didn't provide enough computing power for reasonable sampling rates. An interesting feature of the nano 33 BLE is also that it can use Mbed RTOS features. The Arduino GPS module was chosen because it was quite cheap and the idea was that the Arduino library should work flawlessly with the Nano (though it didn't). The filament was chosen to be PETG (polyethylene terephthalate modifed with glycol) rather than PLA (polylactic acid) since PETG is a bit more flexible, so if the print bends it won't crack immediately. All 3D printing for the project was done with Prusa Mini FDM printer and slicing was done with PrusaSlicer software. The 3D models were made using Solidworks CAD software.

Project progress

Detailed description of the progress

The project began with ordering the components and designing the mechanical parts. The components arrived on the second week of February and as they arrived they were tested to work individually. This already brought the first problems as the Arduino_MKRGPS library is not compatible with the Mbed architecture of the Nano 33 BLE, so after trying different libraries the TinyGPS++ library by Mikal Hart proved to be working quite well. Second library problem was found when testing the on board IMU, as it has range of ±16g, but the Arduino_LSM9DS1 library does not allow you to change that from the default range of ±4g. Fortunately there was a version 2.0 from that library in github written by Femme Verbeek. This version not only allowed changing the measuring range, but also calibrating, filtering and changing the sample rate of the IMU.

Now when the library issues were tackled, the work focused on the mechanical parts. These include the sensor design for the potentiometers and micro switches and designing a compact enclosure for the electronics. The key sensors of this device are the linear position sensors that measure the suspension travel. A weatherproof linear potentiometer would have been the best choice, but those are so expensive that they weren't really an option. Instead, the sensors were made using a multi-turn potentiometer combined with a spiral spring and string. These were used to make a DIY string potentiometer that works basically like an electric ruler. After a couple of iterations the most frictionless design was obtained and the finalized versions were printed and assembled. The figure below shows the linear sensor internal design and also the sensor mounted to a suspension fork.


The diameter of the reel is 8 mm, which gives the sensor an overall range of around 250 mm. This should be enough for all suspension forks and more than enough for rear shocks. The ball at the end of the string is meant to work as a safety feature: If the string gets caught on a branch or stick when riding, the ball joint should  break free and prevent the sensor from breaking. The ball joint also makes it easy to mount the sensor on to different shape and size damper bodies as only a 3D printed adapter needs to be used.

The brake sensor was a lot simpler to design than the potentiometer as it is only an on-off sensor. It was designed so that the lever of the micro switch is inserted against the brake lever so that it contacts the reach adjusting bolt in front of the lever body and when the rider pulls the lever the switch opens. Because the sensor is attached using cable ties, the position can be adjusted so that the sensor reading matches the bite point of the brakes. The micro switch was printed in place so that the sensor is watertight without any further sealing. The figure below shows the brake sensor attached on to its place.

The only possible issue with this design is that, if the lever is straight and doesn't have a reach adjusting bolt, it might be hard to find contact point for the micro switch lever. Fortunately all modern hydraulic mountain bike disc brake levers tend to have a reach adjusting bolt so this issue is quite unlikely to occur.

Now as the external sensors were designed it was time to focus to the electronics side and after that design the enclosure for those. Plan was to test the device on breadboard before soldering everything together. This failed though, because there were so many jumper wires that the used breadboard leads didn't make contact to all of them. This meant that the circuit plan was checked once more and then it was time to solder. The decorated pcb is shown in the figure below.

                                                                    

The components in the left figure are from left to right: LiPo charger/boost converter module, power switch, GPS module, OLED screen (mounted vertically to the orange holder), microSD adapter, buzzer (with the white cover still on) and the microcontroller unit. The power switch that is connected to the charger module and also to upper-right corner of the board which makes it possible to power the device from external 5-21 V power supply. When powered through that connection, the regulator of the microcontroller is used to lower the voltage to 3.3 volts. Lastly there is a voltage divider behind the charger module that allows reading the battery level with the microcontroller analog input. Now everything could be tested by simply hooking up the battery and writing a short sketch that displays acceleration and location on the OLED screen. After making sure that everything worked, it was time for the enclosure design.

The pcb measures 142x55 mm and the screen is the highest component with the height of 16 mm. This means that the enclosure could be taller and longer to fit the connectors and buttons, but the width was quite close to maximum. After a bit of drawing and modelling, the enclosure design was obtained: The external sensor connectors are mounted to the microcontroller end and the two user interface buttons (navigate and select) were mounted on the side and there was a small cutout made to the upper-left corner of the pcb to fit the buttons without making the enclosure wider. The dimensions of the enclosure are 180x68x28 mm and it is designed to be mounted to the down tube of the bike, ideally using water bottle mount to secure it on place. The Solidworks model of the enclosure is shown in the below figure.

The enclosure consists of four parts: The main part, top cover, door and front plate. The main part and top cover form 5 sides of the enclosure and the door with a plexiglass window is attached to the side on which the OLED and power switch are located. This allows the enclosure to be watertight while you can still read the display and navigate with the buttons. The front plate covers the electronics behind the door. The reason for not printing the front plate directly to the main part is that this way the electronics can be upgraded in the future and the same main enclosure can still be used with just a new front plate. The figure below shows the enclosure and the final circuitry with all the buttons and connectors soldered on place. The LiPo battery is mounted underneath the pcb on to a 3D printed cage that is bolted to the bottom of the main part.

The watertightness of the enclosure was further enhanced with using some seals made out of O-rings to seal the door and the top cover to the main part. In the above figure there is an orange cap on the other sensor connector. The cap is used cover the rear shock sensor when measuring bikes that don't have rear suspension. Between the sensor connectors there is a male DC21 jack that can be used to connect regular bicycle light battery to the device.

Now when the mechanical design and electronics were ready it was time to start programming. The plan was to implement a simple user interface that works with the push buttons and displays menu on the OLED screen. The code structure follows object-orientated programming as it consists of two c++ classes, Menu and Measurement. The Menu class runs the user interface and uses a switch-case structure to determine which screen to display. From the menu the user can calibrate the device, scan memory card for saved trails, adjust bike specific settings and start measurements. The Measurement class is the one that interfaces with the sensors and runs the measuring events. One issue that came up when testing the components separately was that the GPS update rate is quite low and it would kill the sample rate of the device. The Mbed RTOS gives a potential solution to this problem as you can start multiple threads and specify their priorities. So by running sensor polling in separate thread and specifying its priority higher than the GPS thread, it should be possible to crank up the sample rate. The reason why GPS isn't so important is that it is anyway so inaccurate that it is enough to get location only once every second. 

The programming phase introduced many challenges as the threads and interrupts didn't play along very well. The things that didn't work were interrupt detaching and re-attaching and terminating and starting thread again. After modifying the code so that the ok-button measurement stopping interrupt didn't have to be detached, the device didn't jam, when starting second measurement after power up. The problem was still that the measurements after the first one recorded only constant values. This was caused by the fact that threads were declared as global variables and re-started for every measurement, which some how didn't execute the polling task. Changing the threads to local variables fixed this bug. The sample rate issue wasn't solved with thread priorities, but with sd card usage. The first version of the code flushed the data to the card between every sample and this was very slow as each flush takes about 180 ms. When the flush rate was altered, the sample rates were greatly improved and the finished device can measure up to 180 samples per second with GPS and over 350 samples without GPS. The higher number of writes between each flush means that the risk of losing data is higher, but testing hasn't yet shown that this would be significant as there has been no sign of lost data.

Final touches to the Arduino code were that the flush rate was implemented to be user configurable e.g. the user can in theory adjust the sampling rate through the flush rate as they have some what linear dependency: flush rate 1 → sample rate 4 Hz, flush rate 200 → sample rate 350 Hz. Another feature that was added was adjustable measurement filtering with running average, that is taken care of by Average class. This means that the user can specify the buffer length for the running average and this way either filter the data on board or record raw data and filter it in the analyzing phase. The data review without laptop was set to be one of the requirements and on the microcontroller end it is implemented using one BLE service with 10 characteristics to represent the data. The calculated values are average used travel f/r, maximum used travel f/r, highest compression and rebound speeds f/r, brake usage and elapsed time. The speeds are obtained through numerical differentiation and the brake usage is calculated by comparing the number of sample points to the number of points when at least one brake was being used. The phone app is causing a bit problems as the MIT App Inventor BLE extension seems to not be able to listen multiple characteristics simultaneously. The current status of the app is shown in the below figures: It receives data only trough one characteristic and it's meant to subscribe/unsubscribe the values, when timer ticks, but that isn't some how working as the recieved data isn't changing.

This means that at the moment the data review can be done with BLE terminal app or with Adafruit's Bluefruit connect app. The conclusion section will consider this topic more deeply, but it can be said that WiFi would have been better wireless communication option for the project in terms of usability from the end user's perspective. The source code can be downloaded from the bottom of the page and it is also included in the below macro.

/*---------------------------------------------------------------------------------------------------------------------------
 * Data logger skecth for mountain bike suspension data collection
 *--------------------------------------------------------------------------------------------------------------------------
*/

#include <TinyGPS++.h>
#include <Arduino_LSM9DS1.h>
#include <SPI.h>
#include <SD.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoBLE.h>
#include <mbed.h>
#include "math.h"

// Pin variables
const byte sdInserted = 2;
const byte battery = A3;
const byte okButton = 5;
const byte downButton = 4;
const byte frontBrake = 6;
const byte rearBrake = 7;
const byte rearSensor1 = A2; // Checks wheter the connector is connected
const byte buzzer = 9;
const byte chipSelect = 10;
const byte frontSensor = A0;
const byte rearSensor2 = A1;
// microSD in SPI
// OLED in I2C
// GPS in Serial1

// Other global variables and objects

#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 32 // OLED display height, in pixels

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1); // -1 for common reset with arduino

TinyGPSPlus gps;

// Arrays for gpx-file track segment start and end points
float startCoords[2][20];
float endCoords[2][20];
float coordAccuracy[2] = {0.0005, 0.001}; //These determine the comparing intervals

// Number of tracks found from path /trails/.. in the sd card
byte tracks;
// Defaults for the strokes of the dampers
float frontTravel = 160.0;
float rearTravel = 57.0;
float potTravel = 299.0; //Experimental parameter
bool rearConnected;
bool useGPS = true; //Measurements have GPS data by default
float potZeroLevel[2];
float IMUOffset[2] = {1, 0}; //No need for x-axis offset as the device can't be mounted that way 

// Measurement variables
File logFile;
float damperLenght[2];
float x, y, z;
float lat, lng, spd;
int ele;
byte brakeState[2];
volatile bool measureOn = false;
int flushRate = 200; //Default number of writes between flushes
byte n = 5; //Default number of samples taken to runnig average of the measurements

// BLE stuff
BLEService dataService("edc5aa04-c325-4fad-b02d-f4fd2c61dc60");

BLEUnsignedIntCharacteristic avg1Char("2A19", BLERead | BLENotify);

BLEUnsignedIntCharacteristic avg2Char("2A20", BLERead | BLENotify);

BLEUnsignedIntCharacteristic timeChar("2A21", BLERead | BLENotify);

BLEUnsignedIntCharacteristic max1Char("2A22", BLERead | BLENotify);

BLEUnsignedIntCharacteristic max2Char("2A23", BLERead | BLENotify);

BLEUnsignedIntCharacteristic ndiff1Char("2A24", BLERead | BLENotify);

BLEUnsignedIntCharacteristic ndiff2Char("2A25", BLERead | BLENotify);

BLEUnsignedIntCharacteristic pdiff1Char("2A26", BLERead | BLENotify);

BLEUnsignedIntCharacteristic pdiff2Char("2A27", BLERead | BLENotify);

BLEUnsignedIntCharacteristic brakeChar("2A28", BLERead | BLENotify);


// Function that gets correct timestamp fo each file
void dateTime(uint16_t* date, uint16_t* time){
  *date = FAT_DATE(gps.date.year(), gps.date.month(), gps.date.day());
  *time = FAT_TIME(gps.time.hour(), gps.time.minute(), gps.time.second());
}

//---------------------------------------------------------------------------------------------------------------------
// Class measurement: Handles measuring events
//--------------------------------------------------------------------------------------------------------------------

class Measurement{

  public:

  Measurement();

  //Starts gpx route based measurement -> When location correct -> Run
  void StartGPS(); 

  //Starts manual measurement instantly -> Run
  void StartManual(); 

  //Runs the measurement e.g. writes to sd card and wraps up the measurement when it's stopped
  void Run(); 

  //Takes average from potentiometers and prints it
  void MeasureSag(); 

  void Calibrate();

  void CalibrateIMU();

  void CalibratePots();

  void CalibrateBrakes();

  //Scan sd card for directory trails and tries to find gpx-files with track segments
  byte ScanGPS();


  private:

  bool ConfirmRecord();

  void PrepareRecord();

  float ParseFloat(File f);
  
};

//-------------------------------------------------------------------------------------------------------------------------------
// Classs average: Holds a matrix of data and gives the mean of the column
//-------------------------------------------------------------------------------------------------------------------------------
class Average{

  public:

  Average();

  void Initialize();

  void Add(float number, byte column);

  float GetAvg(byte column);

  private:
  // The stored values are everything else expect time, location and brakes
  float buff[20][7];
};

//--------------------------------------------------------------------------------------------------------------------------------
// Class menu: Handles the UI
//-------------------------------------------------------------------------------------------------------------------------------

class Menu{

  enum place {
    record_manual,
    record_gps,
    calibration,
    calibrate_all,
    calibrate_brakes,
    calibrate_IMU,
    calibrate_pots,
    settings,
    set_travelf,
    set_travelr,
    scan_rear,
    set_gps,
    set_flushrate,
    set_avglength,
    gpx_scan,
    measure_sag,
    BLE_connect,
    back_settings,
    back_calibrate
  };

  enum action{
    down,
    select,
    none
  };

  public:

  Menu();

  void Update();

  void Display() const;

  void ChangeTravelF();

  void ChangeTravelR();

  int GetBatteryLevel();

  private:

  void ScanRear();

  void SetFlushRate();

  void SetAvgInterval();

  place currentView = record_manual;
  int batLvl[100];
 
};

// Menu and measurement
Measurement measurement;
Menu menu;
Average average;

//------------------------------------------------------------------------------------------------------
// Setup and loop
//------------------------------------------------------------------------------------------------------

void setup() {

  bool bootUp = true;
  
   // Usb serial
  Serial.begin(9600);

  // GPS serial
  Serial1.begin(9600);
  while(!Serial1);

  // Display
  bootUp = display.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  if(!bootUp)
    while(true);
  else{
    display.clearDisplay();
    display.setTextSize(2);
    display.setTextColor(WHITE);
    display.setCursor(10, 2);
    display.print("WELCOME");
    display.setTextSize(1);
    display.setCursor(0, 20);
    display.print("Suspension wizard");
    display.display();
  }
  
  if(digitalRead(sdInserted) == LOW){
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(WHITE);
    display.setCursor(0, 10);
    display.print("microSD not inserted!\nInsert card");
    display.display();

    while(digitalRead(sdInserted) == LOW){
      
    }
    delay(400);
    display.clearDisplay();
    display.setCursor(0, 10);
    display.print("microSD found");
  }

  bootUp = bootUp && SD.begin(chipSelect);

  if(!bootUp){
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(WHITE);
    display.setCursor(0, 10);
    display.print("Failed to open microSD!");
    display.display();
    while(true);
  }
  
  // IMU unit
  bootUp = bootUp && IMU.begin();

  if(!bootUp){
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(WHITE);
    display.setCursor(0, 10);
    display.print("IMU not responding!");
    display.display();
    while(true);
  }

  IMU.setAccelFS(3); // Adjusting range: 0:±2g | 1:±24g | 2: ±4g | 3: ±8g  (default=2)
   IMU.setAccelODR(5); // ODR Output Data Rate   range 0:off | 1:10Hz | 2:50Hz | 3:119Hz | 4:238Hz | 5:476Hz, (default=3)(not working 6:952Hz)
  IMU.accelUnit =  GRAVITY;
  
  analogReadResolution(12); // Taking all the bits that the ADC has to offer

  attachInterrupt(digitalPinToInterrupt(okButton), stop, FALLING);


  // Initialize BLE
  bootUp = BLE.begin();
  if(!bootUp){
    display.clearDisplay();
    printStatusBar();
    display.setTextSize(1);
    display.setTextColor(WHITE);
    display.setCursor(0, 10);
    display.print("BLE not responding!\nPress ok to continue");
    display.display();
    while(digitalRead(okButton));
    delay(300);
  } else{
    BLE.setLocalName("Suspension wizard");
    BLE.setAdvertisedService(dataService);
    dataService.addCharacteristic(avg1Char);
    dataService.addCharacteristic(avg2Char);
    dataService.addCharacteristic(timeChar);
    dataService.addCharacteristic(max1Char);
    dataService.addCharacteristic(max2Char);
    dataService.addCharacteristic(ndiff1Char);
    dataService.addCharacteristic(ndiff2Char);
    dataService.addCharacteristic(pdiff1Char);
    dataService.addCharacteristic(pdiff2Char);
    dataService.addCharacteristic(brakeChar);
    BLE.addService(dataService);
  }
}

void loop() {
  
  menu.Update();

}

//----------------------------------------------------------------------------------------------------------
// Other functions
//---------------------------------------------------------------------------------------------------------


// This function handles driving the buzzer of the device
void beep(byte type){
  // Two short beeps for gps tracking indication
  if(type == 1){
    for(byte i = 0; i < 2; i++){
      analogWrite(buzzer, 150);
      delay(300);
      analogWrite(buzzer, 0);
      delay(300);
    }
  } else if(type == 2){
    // Start gate style beeps for manual recording
    for(byte i = 0; i < 2; i++){
      analogWrite(buzzer, 150);
      delay(300);
      analogWrite(buzzer, 0);
      delay(300);
    }
    analogWrite(buzzer, 150);
    delay(800);
    analogWrite(buzzer, 0);
  } else if(type == 3){
    // Short beep for notifying 'ready', two beeps for 'error'
    analogWrite(buzzer, 150);
    delay(300) ;
    analogWrite(buzzer, 0);
  }
  

}

// This prints basic information to upper right corner of the display
void printStatusBar(){

  display.setTextSize(1);
  display.setTextColor(WHITE, BLACK);
  display.setCursor(25, 0);
  display.print("GPS-");

  // Feeding NMEA sentences for the gps object so that the gps might found fix quicker
  if(Serial1.available() > 0){
    gps.encode(Serial1.read());
    if(gps.location.isValid())
      display.print("FIX (");
    else if(gps.time.isValid())
      display.print("TIME (");
    else
      display.print("NO FIX(");
    display.print(gps.satellites.value());
    display.print(")");
  } else {
    display.print("NO DATA");
  }

  display.print(" ");
  display.print(menu.GetBatteryLevel());
  display.print("%");
  
}

// This function is used to map the potentiometer values accurately
float mapFloat(int val, int val_min, int val_max, float new_min, float new_max){
  return (float)(val - val_min) * (new_max - new_min) / (float)(val_max - val_min) + new_min;
  
}

// Helper function for gpx-file reader debugging
void printCoords(){
  Serial.println("Trails in memory:");
  for(int i = 0; i < 20; i++){
    if(startCoords[0][i] != 0){
    Serial.print("Trail ");
    Serial.println(i+1);
    Serial.print("Start lon ");
    Serial.print(startCoords[0][i], 6);
    Serial.print(",lat ");
    Serial.println(startCoords[1][i], 6);
    Serial.print("End lon ");
    Serial.print(endCoords[0][i], 6);
    Serial.print(",lat ");
    Serial.println(endCoords[1][i], 6);
    }
  }
}

// The measurement functions for threads t1, t2, t3 and t4
void pollIMU(){
  //Serial.println("Got started");
  float x1, y1, z1;
  while(measureOn){
    if(IMU.accelAvailable()){
      IMU.readAccel(x1, y1, z1);
      z = IMUOffset[0]*z1 + IMUOffset[1]*y1;
      y = - IMUOffset[1]*z1 + IMUOffset[0]*y1;
      x = x1;
    }
  }
  //Serial.println("Got stopped");
}

void pollPots(){
  while(measureOn){
    damperLenght[0] = -(mapFloat(analogRead(frontSensor), 0, 4095, 0.0, potTravel) - potZeroLevel[0]);
    damperLenght[1] = -(mapFloat(analogRead(rearSensor2), 0, 4095, 0.0, potTravel) - potZeroLevel[1]);

    brakeState[0] = !digitalRead(frontBrake);
    brakeState[1] = !digitalRead(rearBrake);
  }
}

void pollPot(){ //If rear sensor is not connected
  while(measureOn){
    damperLenght[0] = -(mapFloat(analogRead(frontSensor), 0, 4095, 0.0, potTravel) - potZeroLevel[0]);

    brakeState[0] = !digitalRead(frontBrake);
    brakeState[1] = !digitalRead(rearBrake);
  }
}

void pollGPS(){
  while(measureOn){
    if(Serial1.available() > 0){
      gps.encode(Serial1.read());
      lat = gps.location.lat();
      lng = gps.location.lng();
      ele = gps.altitude.meters();
      spd = gps.speed.kmph();
    }
  }
}

void testGPS(){
  while(measureOn){
    for(int i = 0; i < 20; i++){
      // 0.00001 degree difference equals roughly 1.6 metre radius
      if(fabs(endCoords[0][i] - lat) < coordAccuracy[0]){
        if(fabs(endCoords[1][i] - lng) < coordAccuracy[1])
          measureOn = false;
      }
    }
}
}

void stop(){
  measureOn = false;
}

//---------------------------------------------------------------------------------------
// Menu class methods
//--------------------------------------------------------------------------------------


Menu::Menu(){
  pinMode(sdInserted, INPUT_PULLUP);
  pinMode(okButton, INPUT_PULLUP);
  pinMode(downButton, INPUT_PULLUP);
  pinMode(buzzer, OUTPUT);

  for(int i = 0; i < 100; i++){
    batLvl[i] = 100;
  }
   
}

void Menu::Update(){
  action a;

  if(!digitalRead(okButton)){
    a = select;
    delay(500);
  }else if(!digitalRead(downButton)){
    a = down;
    delay(500);
  }else{
    a = none;
  }

  if(a != none){
    switch (currentView) {
      case record_manual:
        if(a == select)
          measurement.StartManual();
        else if(a == down)
          currentView = record_gps;
        break;
      case record_gps:
        if(a == select)
          measurement.StartGPS();
        else if(a == down)
          currentView = calibration;
        break;
  
      case calibration:
        if(a == select)
          currentView = calibrate_all;
        else if(a == down)
          currentView = settings;
        break;
  
      case calibrate_all:
        if(a == select)
          measurement.Calibrate();
        else if(a == down)
          currentView = calibrate_brakes;
        break;
  
      case calibrate_brakes:
        if(a == select)
          measurement.CalibrateBrakes();
        else if(a == down)
          currentView = calibrate_IMU;
        break;
  
      case calibrate_IMU:
        if(a == select)
          measurement.CalibrateIMU();
        else if(a == down)
          currentView = calibrate_pots;
        break;
  
      case calibrate_pots:
        if(a == select)
          measurement.CalibratePots();
        else if(a == down)
          currentView = back_calibrate;
        break;
  
      case settings:
        if(a == select)
          currentView = set_travelf;
        else if(a == down)
          currentView = gpx_scan;
        break;
  
      case set_travelf:
        if(a == select)
          this->ChangeTravelF();
        else if(a == down)
          currentView = set_travelr;
        break;
  
      case set_travelr:
        if(a == select)
          this->ChangeTravelR();
        else if(a == down)
          currentView = scan_rear;
        break;

      case scan_rear:
        if(a == select)
          this->ScanRear();
        else if(a == down)
          currentView = set_gps;
        break;

      case set_gps:
        if(a == select)
          useGPS = !useGPS;
        else if(a == down)
          currentView = set_flushrate;
        break;
      
      case set_flushrate:
        if(a == select)
          this->SetFlushRate();
        else if(a == down)
          currentView = set_avglength;
        break;

      case set_avglength:
        if(a == select)
          this->SetAvgInterval();
        else if(a == down)
          currentView = back_settings;
        break;
  
      case gpx_scan:
        if(a == select)
          tracks = measurement.ScanGPS();
        else if(a == down)
          currentView = measure_sag;
        break;
          
      case measure_sag:
        if(a == select)
          measurement.MeasureSag();
        else if(a == down)
          currentView = BLE_connect;
        break;
  
      case BLE_connect:
        if(a == select)
          dataReview();
        else if(a == down)
          currentView = record_manual;
        break;
  
      case back_settings:
        if(a == select)
          currentView = settings;
        else if(a == down)
          currentView = set_travelf;
        break;

      case back_calibrate:
        if(a == select)
          currentView = calibration;
        else if(a == down)
          currentView = calibrate_all;
        break;
  
      default:
        break;
    }
  }
  this->Display();
  
}

void Menu::Display() const{
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);

  switch (currentView) {
    case record_manual:
      display.setTextColor(BLACK, WHITE);
      display.println("Record manually");
      display.setTextColor(WHITE, BLACK);
      display.println("Record saved trails");
      break;

    case record_gps:
      display.setTextColor(BLACK, WHITE);
      display.println("Record saved trails");
      display.setTextColor(WHITE, BLACK);
      display.println("Calibration");
      break;

    case calibration:
      display.setTextColor(BLACK, WHITE);
      display.println("Calibration");
      display.setTextColor(WHITE, BLACK);
      display.println("Settings");
      break;

    case calibrate_all:
      display.setTextColor(BLACK, WHITE);
      display.println("Calibrate all");
      display.setTextColor(WHITE, BLACK);
      display.println("Calibrate brakes");
      break;

    case calibrate_brakes:
      display.setTextColor(BLACK, WHITE);
      display.println("Calibrate brakes");
      display.setTextColor(WHITE, BLACK);
      display.println("Calibrate IMU");
      break;

    case calibrate_IMU:
      display.setTextColor(BLACK, WHITE);
      display.println("Calibrate IMU");
      display.setTextColor(WHITE, BLACK);
      display.println("Calibrate pots");
      break;

    case calibrate_pots:
      display.setTextColor(BLACK, WHITE);
      display.println("Calibrate pots");
      display.setTextColor(WHITE, BLACK);
      display.println("Return");
      break;

    case settings:
      display.setTextColor(BLACK, WHITE);
      display.println("Settings");
      display.setTextColor(WHITE, BLACK);
      display.println("Scan for trails");
      break;

    case set_travelf:
      display.setTextColor(BLACK, WHITE);
      display.println("Set fork travel");
      display.setTextColor(WHITE, BLACK);
      display.println("Set shock stroke");
      break;

    case set_travelr:
      display.setTextColor(BLACK, WHITE);
      display.println("Set shock stroke");
      display.setTextColor(WHITE, BLACK);
      display.println("Add rear sensor");
      break;

    case scan_rear:
      display.setTextColor(BLACK, WHITE);
      display.println("Add rear sensor");
      display.setTextColor(WHITE, BLACK);
      display.println("Use gps option");
      break;

    case set_gps:
      display.setTextColor(BLACK, WHITE);
      display.print("Use gps: ");
      if(useGPS)
        display.println("True");
      else
        display.println("False");
      display.setTextColor(WHITE, BLACK);
      display.println("Set sample rate");
      break;

    case set_flushrate:
      display.setTextColor(BLACK, WHITE);
      display.println("Set sample rate");
      display.setTextColor(WHITE, BLACK);
      display.println("Set average interval");
      break;

    case set_avglength:
      display.setTextColor(BLACK, WHITE);
      display.println("Set average interval");
      display.setTextColor(WHITE, BLACK);
      display.println("Return");
      break;

    case gpx_scan:
      display.setTextColor(BLACK, WHITE);
      display.println("Scan for trails");
      display.setTextColor(WHITE, BLACK);
      display.println("Measure sag");
      break;
        
    case measure_sag:
      display.setTextColor(BLACK, WHITE);
      display.println("Measure sag");
      display.setTextColor(WHITE, BLACK);
      display.println("Connect to app");
      break;

    case BLE_connect:
      display.setTextColor(WHITE, BLACK);
      display.println("Measure sag");
      display.setTextColor(BLACK, WHITE);
      display.println("Connect to app");
      break;

    case back_settings:
      display.setTextColor(WHITE, BLACK);
      display.println("Set average interval");
      display.setTextColor(BLACK, WHITE);
      display.println("Return");
      break;

    case back_calibrate:
      display.setTextColor(WHITE, BLACK);
      display.println("Calibrate pots");
      display.setTextColor(BLACK, WHITE);
      display.println("Return");
      break;

    default:
      break;
  }

  display.display();
  display.setTextColor(WHITE, BLACK);
}

int Menu::GetBatteryLevel(){
  //Experimental parametres, 3600 = full, ~3000 = 3.2V = empty
  //Calculated ones, 3260 = 3.7V = full, 2819 = 3.2V
  for(int i = 0; i < 99; i++){
    batLvl[i] = batLvl[i+1];
  }
  batLvl[99] = map(3600 - analogRead(battery), 600, 0, 0, 100);
  int avg = 0;
  
  for(int i = 0; i < 100; i++){
      avg += batLvl[i];
  }

  return (avg / 100);

}

void Menu::ChangeTravelF(){
  while(true){
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Front travel: ");
  display.println(frontTravel);
  display.println("Press ok when done");
  display.display();

  if(!digitalRead(downButton)){
    delay(400);
    if(frontTravel > 100.0)
      frontTravel = frontTravel - 10.0;
    else
      frontTravel = 200.0;
  }

  if(!digitalRead(okButton))
   break;

  }

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Front travel set");
  display.display();
  delay(1200);
  
}

void Menu::ChangeTravelR(){
  if(rearConnected){
    while(true){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Rear stroke: ");
    display.println(rearTravel);
    display.println("Press ok when done");
    display.display();

    if(!digitalRead(downButton)){
      delay(400);
      if(rearTravel > 40.0)
        rearTravel--;
      else
        rearTravel = 95.0;
    }

    if(!digitalRead(okButton))
    break;

    }

    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Rear stroke set");
    display.display();
    delay(1200);
  }else{
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Rear sensor is not connected!");
    display.display();
    delay(1200);
  }
}

void Menu::ScanRear(){
  rearConnected = !digitalRead(rearSensor1);
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  if(rearConnected)
    display.println("Rear sensor found");
  else
    display.println("Rear sensor not found");
  
  display.print("Press ok to exit");
  display.display();
  while(digitalRead(okButton));
  delay(300);
}

void Menu::SetFlushRate(){
  int options[6] = {1, 5, 10, 50, 100, 200};
  int i = 0;
  for(; i < 6;i++){
    if(options[i] == flushRate)
      break;
  }
  while(true){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 8);
    display.print("Sample rate: ");
    display.println(options[i]);
    display.println("Press down to adjust");
    display.println("Press ok when done");
    display.display();

    if(!digitalRead(downButton)){
      delay(300);
      i++;
      if(i > 5)
        i = 0;
    }

    if(!digitalRead(okButton)){
      flushRate = options[i];
      break;
    }
  }

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Sample rate set at\n");
  display.print(flushRate);
  display.display();
  delay(1200);
}

void Menu::SetAvgInterval(){
   
  while(true){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 8);
    display.print("Adjust number of\naveraged samples: ");
    display.println(n);
    display.println("Press ok when done");
    display.display();

    if(!digitalRead(downButton)){
      delay(300);
      n++;
      if(n > 20)
        n = 1;
    }

    if(!digitalRead(okButton))
      break;
  }

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Averaged samples set\n to ");
  display.print(n);
  display.display();
  delay(1200);
}

//-------------------------------------------------------------------------------------
// Measurement class methods
//-------------------------------------------------------------------------------------
Measurement::Measurement(){
  pinMode(rearSensor1, INPUT_PULLUP);
  pinMode(frontBrake, INPUT_PULLUP);
  pinMode(rearBrake, INPUT_PULLUP);

  rearConnected = !digitalRead(rearSensor1);
}

void Measurement::StartGPS(){

  if(!(this->ConfirmRecord())){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Recording cancelled");
    display.display();
    delay(1500);
    return;
  }

  if(!useGPS){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Error: Use gps option\nis set to False!");
    display.display();
    delay(1500);
    return;
  }

if(tracks == 0){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 8);
    display.print("Error: No trails\nScan for trails\nand try again!");
    display.display();
    delay(1500);
    return;
  }


  // Threads for measuring events
  rtos::Thread t1; // Analog sensors
  rtos::Thread t2; // IMU unit
  rtos::Thread t3; // GPS reading
  rtos::Thread t4; // GPS location comparing to endpoints

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Opening a file...");
  display.display();

  this->PrepareRecord();

  if(logFile.isDirectory()){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Error!");
    display.display();
    delay(1000);
    beep(3);
    beep(3);
    return;

  } else {
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("File opened:\n");
    display.print(logFile.name());
    display.display();
  }

  // SD is ready -> attach interrupt, start threads and begin measurement
  while(!gps.location.isValid()){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Waiting gps fix...");
    display.display();
  }
  measureOn = true;
  if(rearConnected)
    t1.start(pollPots);
  else
    t1.start(pollPot);
    
  t2.start(pollIMU);
  t3.start(pollGPS);
  // Here the measurement is stopped if trail endpoint is detected
  t4.start(testGPS);
  average.Initialize();

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("READY!\nScanning for start...");
  display.display();
  
  unsigned long timeUp = millis();
  bool startFound = false;
  // If no start is found in 10 minutes, abort record
  while((timeUp + 600000 - millis()) > 0 && measureOn && !startFound){
    for(int i = 0; i < 20; i++){
      if(fabs(startCoords[0][i] - gps.location.lat()) < coordAccuracy[0]){
        if(fabs(startCoords[1][i] - gps.location.lng()) < coordAccuracy[1])
          startFound = true;
      }
    }
  }

  if(startFound){
    beep(1);
    this->Run();

  } else {
    measureOn = false;
    String name = String(logFile.name());
    logFile.close();
    SD.remove(strcat("/runs/", name.c_str()));

    beep(3);
    beep(3);
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Timed out\n no start found");
    display.display();
    
    delay(3000);

  }
  
}

void Measurement::StartManual(){
  
  if(!(this->ConfirmRecord())){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Recording cancelled");
    display.display();
    delay(1500);
    return;
  }

  // Threads for measuring events
  rtos::Thread t1; // Analog sensors
  rtos::Thread t2; // IMU unit
  rtos::Thread t3; // GPS reading
  rtos::Thread t4; // GPS location comparing to endpoints

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Opening a file...");
  display.display();

  this->PrepareRecord();

  if(logFile.isDirectory()){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Error!");
    display.display();
    delay(1000);
    beep(3);
    beep(3);
    return;

  } else {
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("File opened:\n");
    display.print(logFile.name());
    display.display();
    delay(500);
  }

  // SD is ready -> attach interrupt, start threads and begin measurement
  while(!gps.location.isValid() && useGPS){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Waiting gps fix...");
    display.display();
  }
  delay(300);
  while(digitalRead(okButton)){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("READY!\nPress ok to start");
    display.display();
  }
  delay(500);
  
  measureOn = true;
  if(rearConnected)
    t1.start(pollPots);
  else
    t1.start(pollPot);

  t2.start(pollIMU);
  if(useGPS)
    t3.start(pollGPS);

  average.Initialize();
  delay(500);
  
  beep(2);
  this->Run();

}

void Measurement::Run(){
  
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Recording to file\n");
  display.print(logFile.name());
  display.display();
  
  unsigned long timer = millis();
  unsigned int counter = 0;

  if(rearConnected){
    logFile.print("Elapsed time,Front travel,Rear travel,Front brake,Rear brake,Acc x,Acc y,Acc z");
  } else {
    logFile.print("Elapsed time,Front travel,Front brake,Rear brake,Acc x,Acc y,Acc z");
  }
  if(useGPS){
    logFile.println(",Lat,Lon,Altitude,Speed");
  } else {
    logFile.println("");
  }
  logFile.flush();

  while(measureOn){
    if(rearConnected){
      logFile.print(((float)(millis()-timer))/1000.0, 3);
      logFile.print(",");
      average.Add(damperLenght[0]/frontTravel*100.0, 0);
      logFile.print(average.GetAvg(0), 1);
      logFile.print(",");
      average.Add(damperLenght[1]/rearTravel*100.0, 1);
      logFile.print(average.GetAvg(1), 1);
      logFile.print(",");
    } else{
      logFile.print(((float)(millis()-timer))/1000.0, 3);
      logFile.print(",");
      average.Add(damperLenght[0]/frontTravel*100.0, 0);
      logFile.print(average.GetAvg(0), 1);
      logFile.print(",");
    }

    logFile.print(brakeState[0]);
    logFile.print(",");
    logFile.print(brakeState[1]);
    logFile.print(",");
    average.Add(x, 2);
    logFile.print(average.GetAvg(2), 2);
    logFile.print(",");
    average.Add(y, 3);
    logFile.print(average.GetAvg(3), 2);
    logFile.print(",");
    if(useGPS){
      average.Add(z, 4);
      logFile.print(average.GetAvg(4), 2);
      logFile.print(",");
      logFile.print(lat, 6);
      logFile.print(",");
      logFile.print(lng, 6);
      logFile.print(",");
      average.Add((float)ele, 5);
      logFile.print(average.GetAvg(5), 1);
      logFile.print(",");
      average.Add(spd, 6);
      logFile.println(average.GetAvg(6), 1);
    } else {
      average.Add(z, 4);
      logFile.println(average.GetAvg(4), 2);
    }

    if(counter == flushRate){
      counter = 0;
      logFile.flush();
      if(menu.GetBatteryLevel() < 5){
        measureOn = false;
      }
    } else {
      counter++;
    }
  }

  beep(1);
  logFile.flush();
  logFile.close();
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 8);
  display.print("Recording done!\n");
  display.print("Recorded: ");
  display.print((millis()-timer)/1000);
  display.print(" s\nPress ok to exit");
  display.display();
  while(digitalRead(okButton));
  delay(300);
  

}

bool Measurement::ConfirmRecord(){
  bool ans = true;
  while(true){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 8);
    display.println("Sure you want to\nstart recording?");
    if(ans){
      display.setTextColor(BLACK, WHITE);
      display.print("YES");
      display.setTextColor(WHITE, BLACK);
      display.print("     NO");
    } else {
      display.setTextColor(WHITE, BLACK);
      display.print("YES     ");
      display.setTextColor(BLACK, WHITE);
      display.print("NO");
    }
    display.display();
    if(!digitalRead(downButton)){
      ans = !ans;
      delay(200);
    }
    if(!digitalRead(okButton)){
      return ans;
    }
  }
}

void Measurement::PrepareRecord(){
  
  if(!SD.exists("/runs/"))
    SD.mkdir("/runs/");

  unsigned int i = 1;
  for(;; i++){
    if(!SD.exists("/runs/run" + String(i) + ".csv")){
      SdFile::dateTimeCallback(dateTime);
      logFile = SD.open("/runs/run" + String(i) + ".csv", FILE_WRITE);
      return;
    } else if(i > 500){
      logFile = SD.open("/runs/");
      return;
    }
  }
  
}


void Measurement::MeasureSag(){
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Get on the bike to\nriding position");
  display.display();
  delay(6000);
  
  beep(3);
  float sag[2] = {0, 0};

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Measuring...");
  display.display();
  
  // Measure the average travel used during the 15s sampling period
  for(int i = 0; i < 30; i++){
    sag[0] = sag[0] + ((mapFloat(analogRead(frontSensor), 0, 4095, 0.0, potTravel) - potZeroLevel[0])/frontTravel);
    
    if(rearConnected)
      sag[1] = sag[1] + ((mapFloat(analogRead(rearSensor2), 0, 4095, 0.0, potTravel) - potZeroLevel[1])/rearTravel);
    
    delay(500);
  }

  sag[0] = -sag[0] / 30.0;
  sag[1] = -sag[1] / 30.0;
  beep(3);

  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 8);
  display.print("Front sag: ");
  display.println(sag[0]*100.0, 1);

  if(rearConnected){
    display.print("Rear sag: ");
    display.println(sag[1]*100.0, 1);
  }
  display.print("Press ok");
  display.display();

  while(digitalRead(okButton));
  delay(300);
  
}

byte Measurement::ScanGPS(){
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 10);
  display.print("Scanning for trails...");
  display.display();

  if(digitalRead(sdInserted) == LOW){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Error: SD not inserted");
    display.display();
    return -1;
  }

  File root = SD.open("/trails/");

  byte number = 0;
  bool scan_on = true;
  float start[2] = {0.0, 0.0};
  float finish[2] = {0.0, 0.0};

  while(scan_on) {
    File entry =  root.openNextFile();
      if (!entry || number >= 20) {
        scan_on = false;
        break;
      }
    
    // Check that entry is a gpx-file
    if(entry.find("<gpx")){
      if(entry.find("<trkseg>") && entry.find("<trkpt lat=")){
        entry.read(); //Skip the " before float
        start[0] = this->ParseFloat(entry);
        entry.find("lon=");
        entry.read(); //Skip the " before float
        start[1] = this->ParseFloat(entry);

        while(entry.available()){
          if(entry.find("<trkpt lat=")){
            entry.read(); //Skip the " before float
            finish[0] = this->ParseFloat(entry);
            entry.find("lon=");
            entry.read(); //Skip the " before float
            finish[1] = this->ParseFloat(entry);
          }
        }

        //Check wheter the found numbers are valid, 
        //since not assuming over 100 km trails, the difference must be under one degree
        if(start[0] - finish[0] < 1 && start[1] - finish[1] < 1){
          startCoords[0][number] = start[0];
          startCoords[1][number] = start[1];
          endCoords[0][number] = finish[0];
          endCoords[1][number] = finish[1];
          number++;
        }
      }
    }
    entry.close();
  }
  
  root.close();
  printCoords();
  display.clearDisplay();
  printStatusBar();
  display.setCursor(0, 8);
  display.print("Found ");
  display.print(number);
  display.println(" trail(s)");
  display.print("Press ok");
  display.display();
  while(digitalRead(okButton));
  delay(300);

  return number;

}

float Measurement::ParseFloat(File f){
  bool on = true;
  char coord[14];
  byte i = 0;
  while(on){
    coord[i] = f.read();
    i++;
    if(f.peek() == 34 || i > 12)
     on = false;
  }
  coord[13] = '\0';

  return atof(coord);
}

void Measurement::Calibrate(){
  this->CalibrateIMU();
  this->CalibratePots();
  this->CalibrateBrakes();
}

void Measurement::CalibrateIMU(){
  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Keep bike straight!");
  display.display();
  delay(1500);

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Calibrating IMU...");
  display.display();
  delay(1000);

  float x1, y1, z1;
  if(IMU.accelAvailable()){
      IMU.readAccel(x1, y1, z1);
  } else {
    display.clearDisplay();
    printStatusBar();
    display.setTextSize(1);
    display.setCursor(0, 10);
    display.print("Error!\nPress ok to exit");
    display.display();
    while(digitalRead(okButton));
    delay(300);
  }

  if(y1 > 0.05 || y1 < -0.05){
    IMUOffset[0] = cos(atan2(y1, z1)); // z-component of g
    IMUOffset[1] = sin(atan2(y1, z1)); // y-component of g
  } else {
    IMUOffset[0] = 1; // z-component of g
    IMUOffset[1] = 0; // y-component of g
  }
  // Calibrated y and z are:
  // z = IMUOffset[0]*z + IMUOffset[1]*y
  // y = - IMUOffset[1]*z + IMUOffset[0]*y (zero when at rest)

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 8);
  display.print("Calibrating done!\nDevice is at ");
  display.print(atan2(y1, z1)/3.141596*180, 0);
  display.print(" angle\nPress ok");
  display.display();
  while(digitalRead(okButton));
  delay(300);
}

void Measurement::CalibratePots(){
  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Keep bike unloaded!");
  display.display();
  delay(1500);

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Calibrating potentiometers...");
  display.display();
  delay(1000);

  potZeroLevel[0] = mapFloat(analogRead(frontSensor), 0, 4095, 0.0, potTravel);

  if(rearConnected)
    potZeroLevel[1] = mapFloat(analogRead(rearSensor2), 0, 4095, 0.0, potTravel);

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Calibrating done!\nPress ok");
  display.display();
  while(digitalRead(okButton));
  delay(300);

}

void Measurement::CalibrateBrakes(){
  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setTextColor(WHITE);
  display.setCursor(0, 8);
  display.print("Adjust brake switches, so that they readings are correct");
  display.display();
  delay(1500);

  while(true){
    byte f = digitalRead(frontBrake);
    byte r = digitalRead(rearBrake);
    display.clearDisplay();
    printStatusBar();
    display.setTextSize(1);
    display.setTextColor(WHITE);
    display.setCursor(0, 8);

    display.print("Front brake: ");
    if(!f)
      display.println("ON");
    else
      display.println("OFF");

    display.print("Rear brake: ");
    if(!r)
      display.println("ON");
    else
      display.println("OFF");

    display.print("Press ok when done");
    display.display();

    if(!digitalRead(okButton)){
      display.clearDisplay();
      printStatusBar();
      display.setTextSize(1);
      display.setCursor(0, 10);
      display.print("Calibrating done!\n");
      display.display();
      delay(1000);
      return;
    }
  }
  
}

//-----------------------------------------------------------------------------------------------------------
// Class average methods
//-----------------------------------------------------------------------------------------------------------

Average::Average(){

}

void Average::Initialize(){

  //Initializing the buffer to current values
  for(int j = 0; j < n; j++){
    buff[j][0] = damperLenght[0]/frontTravel*100.0;
  }
  for(int j = 0; j < n; j++){
    buff[j][1] = damperLenght[1]/rearTravel*100.0;
  }
  for(int j = 0; j < n; j++){
    buff[j][2] = x;
  }
  for(int j = 0; j < n; j++){
    buff[j][3] = y;
  }
  for(int j = 0; j < n; j++){
    buff[j][4] = z;
  }
  for(int j = 0; j < n; j++){
    buff[j][5] = (float)ele;
  }
  for(int j = 0; j < n; j++){
    buff[j][6] = spd;
  }

}

void Average::Add(float number, byte column){

  for(int i = 0; i < n-1; i++){
    buff[i][column] = buff[i+1][column];
  }
  buff[n-1][column] = number;
  //Serial.print("Adding: ");
  //Serial.println(number);
  return;
}

float Average::GetAvg(byte column){
  float ret = 0;
  for(int i = 0; i < n; i++){
    ret += buff[i][column];
    //Serial.print(buff[i][column]);
    //Serial.print(", ");
  }
  ret = ret/(float)n;
  //Serial.print("Returning: ");
  //Serial.println(ret);
  return ret;  
}


//---------------------------------------------------------------------------------------------------------
// Functions for the BLE data review
//---------------------------------------------------------------------------------------------------------

// Data review calculates and broadcasts the stats of the latest run
void dataReview(){

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Scanning for a file\nto read...");
  display.display();

  if(digitalRead(sdInserted) == LOW){
    display.clearDisplay();
    printStatusBar();
    display.setCursor(0, 10);
    display.print("Error: SD not inserted");
    display.display();
    delay(1000);
    return;
  }

  File dat;
    
  unsigned int i = 1;
  if(SD.exists("/runs/")){
    for(;; i++){
      if(!SD.exists("/runs/run" + String(i) + ".csv")){
        break;
      } else if(i > 500){
        i = 0;
        break;
      }
    }
  }

  if(i != 1 && i != 0)
    dat = SD.open("/runs/run" + String(i-1) + ".csv", FILE_READ);

  if(i==0 || i==1 || !dat){
    display.clearDisplay();
    printStatusBar();
    display.setTextSize(1);
    display.setCursor(0, 10);
    display.print("No valid files found!\nPress ok to exit");
    display.display();
    while(digitalRead(okButton));
    return;
  }

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("File: ");
  display.println(dat.name());
  display.print("Finding the values...");
  display.display();

  unsigned long samples = 0;
  char num[6];
  float max_[2] = {0.0, 0.0};
  float sum[2] = {0.0, 0.0};
  float nDiff[2] = {0.0, 0.0};
  float pDiff[2] = {0.0, 0.0};
  float prev[3] = {0.0, 0.0};
  unsigned long brakes = 0;
  float t = 0.0;
  bool hasRear = false;

  if(dat.find("Front travel,")){
  if(dat.read() == 'R')
    hasRear = true;

  while(dat.available()){
    if(dat.find('\n'))
      samples++;
    else
      break;
    
    for(int i = 0; i < 5; i++){
      num[i] = dat.read();
      if(dat.peek() == ',')
        break;
    }

    if(dat.available())
      t = atof(num);
    else
      break;
    
    dat.find(",");
    for(int i = 0; i < 5; i++){
      num[i] = dat.read();
      if(dat.peek() == ',')
        break;
    }
    sum[0] = sum[0] + atof(num);
    max_[0] = fmax(atof(num), max_[0]);
    if(samples > 1){
      float diff = (atof(num) - prev[0])/(t - prev[3]);
      pDiff[0] = fmax(pDiff[0], diff);
      nDiff[0] = fmin(nDiff[0], diff);
    }
    prev[0] = atof(num);
    
    if(hasRear){
      for(int i = 0; i < 5; i++){
      num[i] = dat.read();
      if(dat.peek() == ',')
        break;
      }
      sum[1] = sum[1] + atof(num);
      max_[1] = fmax(atof(num), max_[1]);
      if(samples > 1){
        float diff = (atof(num) - prev[1])/(t - prev[3]);
        pDiff[1] = fmax(pDiff[1], diff);
        nDiff[1] = fmin(nDiff[1], diff);
      }
      prev[1] = atof(num);
    }

    dat.read();
    if(dat.read() == '1')
      brakes++;
    else{
      dat.read();
      if(dat.read() == '1')
      brakes++;
    }

    prev[3] = t;
  }
  dat.close();
  sum[0] = sum[0]/(float)samples;
  sum[1] = sum[1]/(float)samples;

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Calculating done!");
  display.display();
  delay(500);

  Serial.print("values: ");
  Serial.print(sum[0]);
  Serial.print(", ");
  Serial.print(sum[1]);
  Serial.print(", ");
  Serial.print(t);
  Serial.print(", ");
  Serial.print(max_[0]);
  Serial.print(", ");
  Serial.println(max_[1]);

  int data_buff[10] = {(int)sum[0], (int)sum[1], (int)t, (int)max_[0], (int)max_[1], -(int)nDiff[0], -(int)nDiff[1], (int)pDiff[0], (int)pDiff[1], brakes/samples};
  sendData(data_buff);

  delay(300);
  return;

  }else{
    dat.close();
    display.clearDisplay();
    printStatusBar();
    display.setTextSize(1);
    display.setCursor(0, 10);
    display.print("File wasn't valid!\nPress ok to exit");
    display.display();
    while(digitalRead(okButton));
    delay(300);
    return;
  }


}

void sendData(int data[10]){

  BLE.advertise();

  display.clearDisplay();
  printStatusBar();
  display.setTextSize(1);
  display.setCursor(0, 10);
  display.print("Advertising data\nPress ok to stop");
  display.display();

  while(digitalRead(okButton)){
    BLEDevice central = BLE.central();

    if (central) {
      Serial.print("Connected to central: ");
      // print the central's BT address:
      Serial.println(central.address());

      while (central.connected() && digitalRead(okButton)) {
        //Send data
        avg1Char.writeValue(data[0]);
        avg2Char.writeValue(data[1]);
        timeChar.writeValue(data[2]);
        max1Char.writeValue(data[3]);
        max2Char.writeValue(data[4]);
        ndiff1Char.writeValue(data[5]);
        ndiff2Char.writeValue(data[6]);
        pdiff1Char.writeValue(data[7]);
        pdiff2Char.writeValue(data[8]);
        brakeChar.writeValue(data[9]);
      }
      
      Serial.print("Disconnected from central: ");
      Serial.println(central.address());
    }
  }
  
  BLE.stopAdvertise();
  return;
}

The figures below show all parts of the device: The left one has the external sensors and the mounting hardware. The small roller wheel on the down-left corner is meant for mounting flexibility: If there is not enough space or the mounting would be difficult, the rear sensor can be mounted to the frame and the string is guided through the roller. This should be useful on four-bar linkage design bikes as the rear sensor enclosure can't be attached to the rear shock or to the main link, so the sensor can be attached to the downtube and the roller to the lower shock mount. On the right there is the main unit and three different mounting solutions, which are Fidlock magnetic water bottle mount, 50 mm diameter round cable tie mounts and cable tie mounts for triangle-shaped frame tubes.

 Midway status of the project (15.03.2021)

As the previous section describes the mechanical and electronic parts of the project are now completed. There might still be some 3D modelling done to make the mounting hardware less cable tie consuming. The programming is in a phase were everything that belongs to stages one and two has been implemented, but the automatic recording from gpx-file is still in testing phase as required GPS accuracy needs some adjusting. The data review through BLE connection is still under work as there needs to be some data gathered before the reasonable indicator values of the data, that Nano can still compute in reasonable time, need to be determined. Initially plan was to calculate average used travel, filter maximum values and calculate the fastest compression and rebound speeds through numerical differentiation. The current sample rate with multi-threading using equal priority threads is 4 Hz which is far too low. So GPS thread priority decreasing needs to be tested. A glimpse of the the first successful recording session is shown in the below figures.

     

The left figure shows suspension travel and brake usage and the right one shows the IMU acceleration readings (z-axis reads +1g when at rest). The acceleration is calibrated to 'earth' coordinates so that the device can be mounted in different angles and the readings will still be comparable. What happens in the figures is that the bike is being pedaled from seated position until around time 17 second, when the rider stands up and continues pedaling. At time 23 seconds the rider stops and gets off from the bike. Although, the data is quite edgy due to low sample rate, it already shows the difference in suspension efficiency when pedaling from saddle.

What still needs to be done? 


  1. Improve the sample rate
  2. Test and adjust the automatic recording
  3. Do more testing and determine the indicator values of the data
  4. Design the phone app in MIT App Inventor and implement the BLE connection on the microcontroller end
  5. Try to make the buzzer louder

The result

Description of the device and its uses 

The meaning of the project was to make a cheap mountain bike suspension data logging system that could be used by average mountain bikers. The idea is that this way the mystery of good suspension setup could be examined and studied by more people than the world cup racers who have access to measuring devices that cost 10 times the budget of this project. One could think that this kind of device is only useful for racing purposes, where being faster is the only goal. This isn't the case though, as a rider can also use this device to learn about one's riding style and suspension usage and this way obtain a better suspension setup (or conclude that current one is perfect). This matters a lot as the riding experience can be totally different when the bike is working properly, as the riding will feel easier and offer more fun. Potentially even beginners who have problems figuring out the correct setup could use this kind of device to speed up the adjusting process. This case is actually quite relevant as the increasing popularity of mountain biking means that bikes are getting cheaper and nowadays even entry level bikes have so much adjusting options that one can easily get carried away with those.

What does the device give then? It gives damper shaft position data, braking data and accelerometer data that can be used to study the behavior of the suspension. For instance, the correlation coefficient between damper position and z-direction acceleration of the frame gives some hint about the damping performance (on a full suspension bike, on a hardtail the acceleration tells how big shocks the rear wheel has taken). The brake data can be used to study how the suspension works under braking: Most designs cause torque, which makes the suspension less sensitive under braking. Finally, to be able to backwards correlate all the data, the on board GPS records the location and speed. The below figures show recorded data from a winter trail in Espoo, the bike ridden was a hardtail meaning that it had only front suspension.

The left figure tells that on average the used suspension travel was around 25% and the maximum was 63% (when landing a jump). Based on those one could argue that there was too much air in the damper (air works as the spring in the used fork), but if there were thermometer data the result would have natural explanation as the damping oil meant for summer conditions is too viscous in -15°c temperatures and the system is over damped. The right figure shows the route that was ridden and by labeling that with different values, for instance speed, braking or suspension usage, one can easily find out the behavior of the bike on different sections of the trail. The Matlab script (test_analysis.m) for quick plotting can be found at the bottom of this page.

Presentation video

The video behind the link below goes trough some basic info about the device and shows how to record manually. The bike used in the video has same fork travel and rear shock stroke as the defaults of the device, so it doesn't go through changing those. Those can be changed from the settings menu, where the 'use gps option' is located.

Project video: https://vimeo.com/533635261

Quick user manual

This section describes how to use the device and what things should be taken into account (which parts aren't foolproof). First mounting the device:

Next up the user interface navigation for setup. The device doesn't have on board non-volatile memory for saving settings, which means that the initial setting and calibration procedure needs to be ran every time after starting the device. The procedure has following stages:

The recording is quite straightforward and the behavior of the different modes is described below:

In addition to above procedures the main menu also has 'Measure sag' and 'Connect to app' options. The 'Measure sag' option starts sag measuring procedure e.g. measures the average suspension travel used over a 15 second period starting 6 seconds after the button press. Sag means the amount of travel used, when the rider sits on the bike. The screen displays instructions for the user and after measurement is ready the values are also shown on it. The sag readings are correct if a) the bike specific stroke lengths are set to correct values b) the dampers were fully extended, when potetionmeter were calibrated.

The 'Connect to app' option calculates the indicator values from the latest saved measurement and advertises those through a BLE service with each value having its own BLE characteristic. The latest measurement is assumed to be the file before the first non-existing file with name "RUNx.csv", where is an integer x and the files are scanned starting from x = 1. This means that if there is empty slot because of deleted file, the found file might not be the latest. The advertised values can be accessed with for instance a BLE terminal app.

The figure below shows the main menu layout:

The status bar tells the status of the GPS and it can be no data (means no serial connection e.g. badly wrong), no fix (means that it hasn't connected to anything yet), time (at least one satellite found) and fix (at least 4 satellites found). The value in the parenthesis tells the number of satellites that the GPS is using and the last number tells the battery level of the device. The navigation works through two buttons and the item on which you are is highlighted with white background.

The specs of the device


PropertyValue
Measured variables

Front damper position, rear damper position,

frame acceleration, braking, location, speed,

elevation, elapsed time

Additional features

Sag measuring, quick data review with BLE*, automatic rear sensor detection,

gpx track segment based recording

*Proper phone app still missing, but values can be read

Linear sensor range0-300 mm
Linear sensor resolution0.07 mm
Accelerometer range±20g
Sample rateAdjustable: max 180 Hz (350 Hz GPS off)
Max SD card size32 GB (microSD card, type SD or SDHC, not compatible with SDXC or higher)
Battery lifeapprox. 8 h measuring
WaterproofSplash waterproof
Powering optionsInternal battery or external 7-12 V DC

Cable ties needed for

installation

10 - 15, depending on the number of integrated threads on the frame
Main unit dimensions180x68x28 mm
Weight285 g main unit + 210 g sensors
Final budget~200€

Conclusion

Did the project meet its requirements? Mainly yes, as the first three requirements of the project plan section are fulfilled and the latter two work to some point. The device ended up having more features than originally planned and the data review without PC didn't get enough attention. There is one fundamental problem in the design of the data review functionality, which reduces the usefulness of it: The BLE communication is designed for small amounts of data, so the idea of sending complete files to smartphone for plotting was realized to not work. The current data review with averages and maximum values gives some feedback, but if the trail has different sections, the user can't get any specific feedback from the different sections. From this point of view the data review should be done with WiFi and it could even be done so that the microcontroller runs a web server that has its own plotting code and the user just visits the page to review the data. This would of course need different main board, for instance an ESP32 or Raspberry pi zero.

The bonus requirement about the cost is a bit tricky, as the final budget of 200 euros isn't cheap, but it's still around 10% of the cost of a commercial solution. One could probably save up to 40 euros by finding the cheapest retailers and of course it is possible to simplify the device, for instance by leaving out the GPS, and save money that way. The alternative component choices in section 5.1 considers some other components, that are either cheaper or better. On the other end, one could argue that the project is quite cheap, when looking at the price of one weatherproof linear potentiometer that could be used in this project, as it costs 330 euros (versus the used DIY string pot cost under 20 euros).

Apart from the data review the project turned out to be working quite well and it definitely proofs that one can build a proper mountain bike suspension setup tool with basic electronics stuff. The external sensors work well and the main unit is compact enough to fit on to a bike without disturbing the rider. One small thing that is probably updated soon is that now the potentiometers are supported only by their bushings, so adding a bearing to the linear sensor would probably increase the life of the potentiometer. This update requires only a new reel for the string as the enclosure is designed to fit a 61901 bearing. Other design issue is that the ceramic patch antenna of the used GPS module is not especially fast when it comes to finding fix in urban areas, so it would have been good to choose a module with external antenna option. Of course there are a lot of small things that could be improved, for instance the efficiency of the program code and the powering solution. Now there is 5 V boost-converter and 3.3 V regulator between the board and the battery, but changing these to one 3.3 V buck/boost-converter would improve the power efficiency. These things are connected though, as the Nano can't take 3.3 V input unless you cut the solder pad of the regulator, but cutting the pad means that no new program code can be uploaded to the Nano. So this powering optimization would need a small switch on board that toggles between program mode and direct current mode.

The device has many features that offer easy upgrades in the future. One of these is the fact, that the rear sensor has two analog signal channels, one of which is now used for detecting the rear sensor. This means that it is possible to make a rear sensor that has two elongation strips and would allow one to study the behavior of different frame materials on a hardtail bike, by attaching the elongation strips to seatstay tubes of the rear triangle. This would give some quantitative results to the never ending debate whether steel and titanium offer a softer frame characteristics than aluminium. Another future upgrade would be to use all the data that the IMU offers, not just the acceleration, and combine it with GPS to make sensor fusion positioning. These upgardes are easily doable as there is still over half of the program memory left on the Nano, although the current code is already nearly 2000 lines long and uses 10 libraries.

How to build the project and alternative part lists

Alternative component choices

The components of the project from a quite well working package and it's worth noting that the used code only works on mbed architecture boards. Another fact is that the code currently takes about 0.4 MB of program memory, so going with smaller/cheaper microcontroller means that some features will need to be discarded. When looking for GPS module, one should choose a model that has a possibility to mount an external antenna, as it will make getting the GPS fix way faster. A small side note here: The Nano 33 BLE is actually the most powerful Arduino board in terms of clock speed and memory (64 MHz and 1 MB/256 kB), if we don't count the Portenta, which is designed for the industry. These in mind there are three alternative component lists below:

Light building instructions

The building process is quite simple if one has access to a 3D printer and soldering tools. If going with the same components, the component layout pictured in section 3.1 should work. In case using different components, one should first cut a 145x55 mm piece of pcb (or draw rectangle on paper and place the components on that) and test fit all the components. After making sure that the components fit, the parts need to be 3D printed according to notes described in section 5.3, note that the pause prints need some components to prepared, for instance the brake switches with wires soldered on place and the 3 mm plexigass cut to correct dimensions 147x23 mm.

After all the parts are printed the potentiometers can be assembled by first inserting the spring to the bottom part of the cover and attaching the string to the reel and the ball at the end of the string. It is also good to make sure that the tolerance between the potentiometer shaft and the reel is sufficient. In case it is too tight, one can try to use a 6.3 mm drill bit to take off some material. When everything fits together, install the reel to the spring (with the string nearly entirely wound on to the reel) and add a bit of preload to the spring and push the potentiometer in. After this tighten the potentiometer nut and solder some 3-pole wire with enough length (max diameter 6 mm) to the potentiometer. Plus goes to 1, GND goes to 3 and signal goes to solder pad no. 2 (numbers are valid for the potentiometer pictured in section 3.1). After this, route the wire through the top cover and screw the cover in place with 2x 20 mm M3 screws. Repeat same procedure for the other potentiometer.

To finish the external sensors, take some 5-pole wire and measure it so that it reaches from the handlebar to the water bottle mount. Then solder the brake sensors and the front potentiometer to the 5-pole wire according to the schematic found at the bottom of this section. Insulate the connections with hot glue and shrinking tube. The 3-pole wire of the rear sensor is attached directly to the rear sensor connector according to the schematic, but in addition you must connect the pin 4 of the connector to GND, in order for the automatic rear sensor detection to work.

The main electronics should be connected with quite small wire to save space underneath the pcb (see figure on section 3.1). After decorating the pcb according to the schematic, one should install the connectors and buttons to the main enclosure, screw the battery on its place (3x 10 mm M3). Then solder the connector and button wires to the pcb. After this carefully press the pcb in to the slot in the enclosure and use a 10 mm M3 screw to secure it to its place. Now just upload the code to the board (it takes some 15 minutes to compile and upload) and test the device. If everything works, screw the enclosure lid (4x 20 mm M3) and front plate (2x 7 mm M3 countersunk) on their places, if not check the connections. After this mount the enclosure door with 25 mm M3 screw and attach your preferred mounting solution to the bottom of the enclosure with 10 mm M3 screws. Now the device is ready, just plug in a memory card and start data logging.

The figure below shows the schematic of the device, note that the pins of the Nano 33 BLE are numbered according to their place not according to A0, A1, ..., D2, Rx.., the USB port is at the opposite end to the IC notch, e.g. between pins 15 and 16.

3D printing specifications

All the parts were printed with PrusaSlicer default settings for Prusa Mini in PETG 0.2 mm speed mode: Nozzle temperature 250°c, heat bed temperature 90°c, 0.2 mm layer height, 20% infill, rectangular infill pattern, skirt on, no brim and when using supports the detect bridging perimeters option was set on. Used nozzle was standard 0.4 mm one and the used spring steel sheet (removable build plate) was the textured one, which is recommended for PETG. Pause prints were made during slicing to prevent forgetting them. For the smaller and more detailed parts, for instance the front plate, a smaller 0.15 mm layer height might also be used. The amount of supports used is quite minimal and depending of the capabilities of the used printer the models might need more supports than mentioned in the below table. The below table shows the slicing notes for the 3D models.

ModelOrientationPause printsSupports
brake_sensorhorizontal1, the micro switch and its wires-
enclosure_50mm_round_adapterbolt holes horizontally0-
enclosure_battery_holderlargest flat face to build plate0-
enclosure_dc_in_captextured side to build plate0-
enclosure_doornon-grooved face to build plate1, insert the plexiglass (147x23x3 mm)support enforcer for the knob cutout
enclosure_fidlock_adapterlargest flat face to build plate1, insert M6 washers (1.5mm thick) to the cutouts-
enclosure_front_platetextured side to build plate0-
enclosure_lidhole side up1, 4 x M3 hex nuts-
enclosure_m4_knobcut to half and the uncut flat faces to build plate0the other half with hole needs supports from build plate
enclosure_main_parthorizontal5, insert M3 nuts (3 x square, 8 x hex)-
enclosure_sensor_captext side to build plate0-
enclosure_triangle_adapterbolt holes horizontally0-
enclosure_usb_lidtextured side to build plate0-
pcb_254_nutholes vertically0-
pcb_254_spacerholes vertically0-
pcb_1944_nutholes vertically0-
pcb_1944_spacerholes vertically0-
pcb_screen_holderholes horizontally0-
pot_adapter_m5horizontal0supports from build plate
pot_ball_holder_forkflat face to the build plate0-
pot_ball_holder_shockflat face to the build plate0-
pot_ball_jointflat face to the build plate0-
pot_cover_bottompotentiometer hole vertically, with the cut edges of the cylinder at the same level1, insert 2 x square M3 nuts-
pot_cover_topthe round flat end to the build plate0-
pot_fork_adaptervertically, flat half circle to the build plate0supports from build plate
pot_reelthe potentiometer axle hole to the build plate0-
pot_wheelflat face to the build plate0-
pot_wheel_armflat face to the build plate1, insert hex M3 nut-