Ankel2000
Published © CC BY-NC

Simple Rally/Racing Dashboard

An attempt to achieve: 1. Shifter LEDs with RPM display 2. Manual gearbox sensing and display 3. CAN bus values forwarding to RaceChrono.

IntermediateWork in progress12,709
Simple Rally/Racing Dashboard

Things used in this project

Hardware components

Arduino UNO
Arduino UNO
×1
Bluetooth Low Energy (BLE) Module (Generic)
×1
CAN-BUS MCP2515 Module TJA1050 Receiver SPI Module
×1
NeoPixel Ring: WS2812 5050 RGB LED
Adafruit NeoPixel Ring: WS2812 5050 RGB LED
×1
MAXREFDES99# MAX7219 Display Driver Shield
Maxim Integrated MAXREFDES99# MAX7219 Display Driver Shield
×1
DFRobot 16x2 1602 LCD Keypad Shield For Arduino
×1

Software apps and online services

Arduino IDE
Arduino IDE
RaceChrono

Story

Read more

Custom parts and enclosures

RPM+Gear enclosure

RPM+Gear enclosure with components

RPM+Gear enclosure with components

Enclosure 3D model

Schematics

Schematics

Proteus simulation

Code

Prototype

Arduino
#define DEBUG
#define noDEMO

#include "debug.h"
#include "vars.h"
#include "Config.h"
#include <Adafruit_NeoPixel.h>
#include <LiquidCrystal.h>
#include <menu.h>
#include <menuIO/serialOut.h>
#include <menuIO/liquidCrystalOut.h>
#include <SoftwareSerial.h>
//#include <LedControl.h>
#include <LEDMatrixDriver.hpp>
#include <mcp_can.h>
#include <SPI.h>

/*
 * GLOBAL VARIABLES
 */

#define RPM_MIN RPM_TRIGGER[0]
#define CONFIG configuration.data

// GEARS 8x8 LED Matrix
//LedControl        gears_lcd (PIN_GEARS_data,PIN_GEARS_clock,PIN_GEARS_select,PIN_GEARS_devices);
LEDMatrixDriver   gears_lcd(1, PIN_GEARS_select, LEDMatrixDriver::INVERT_Y);
// Multipurpose 16x2 LCD
LiquidCrystal     lcd (PIN_LCD_RS, PIN_LCD_ENABLE, PIN_LCD_D4, PIN_LCD_D5, PIN_LCD_D6, PIN_LCD_D7);
// Bluetooth Serial console
SoftwareSerial    BTserial (PIN_BT_RX, PIN_BT_TX);
// Neopixel Ring for RPM
Adafruit_NeoPixel neoring (NEORING_LEDS, PIN_NEORING, NEO_GRB + NEO_KHZ800);
// Configuration in EEPROM
// necessary to pass object inside via pointer to being able to interact and apply() configuration changes
Configuration configuration(gears_lcd,neoring);

/*
 * GLOBAL MENU
 */
using namespace Menu;
bool lcd_menu_active=false;
#define MENU_MAX_DEPTH 3

// TODO: performance hit when using Configuration class members ? at least in VIRTUAL:
Menu::result menu_rpm_brightness(eventMask e,navNode& nav,prompt& item) {
  //neoring.setBrightness(map(set_rpm_brightness,0,100,0,255));
  configuration.apply(C_RPM);
  return proceed;
}

Menu::result menu_gear_brightness(eventMask e,navNode& nav,prompt& item) {
  //gears_lcd.setIntensity(0,map(set_gear_brightness,0,100,0,15));
  configuration.apply(C_GEAR);
  return proceed;
}

Menu::result menu_save_config() {
  configuration.save();
  return quit;
}

#define MENU_PROCESSING \
  lcd.clear();\
  lcd.setCursor(0,0);\
  lcd.print(F(">> PROCESSING <<"));

Menu::result menu_default_config() {
  MENU_PROCESSING;
  configuration.loadDefaults();
  configuration.save();
  configuration.apply();
  return quit;
}

Menu::result menu_back_action(){
  return quit;
}

Menu::result menu_rpm_change (eventMask e,navNode& nav,prompt& item) {
  MENU_PROCESSING;
  configuration.apply(C_COLOR);
}

Menu::result menu_rpm_color_change (eventMask e,navNode& nav,prompt& item) {
  // nav.sel has the index of the menu that is currently selected and manipulated
  neoring.fill(myColorHSV(CONFIG.RPM_COLOR[nav.sel/2+1],CONFIG.RPM_COLOR_LIGHTNESS[nav.sel/2+1]));
  neoring.show();
}

Menu::result menu_rpm_color_display (eventMask e,navNode& nav,prompt& item) {
  switch (e)
  {
    case enterEvent:
      DBG(F("ENTER CLR MENU"));
      neoring_active=false;
      break;
    case exitEvent:
      DBG(F("EXIT CLR MENU"));
      menu_rpm_change(e,nav,item);
      neoring_active=true;
      break;
  }
  return proceed;
}

MENU(configMenu_RPM_limits,"Set RPM limits",doNothing,noEvent,wrapStyle
  ,FIELD(CONFIG.RPM_TRIGGER[0],"RPM min","",0,11000,100,50, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_MAX,"RPM max","",0,11000,100,50, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_TRIGGER[1],"Stage1","",0,11000,100,50, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_NUMPIXELS[1],"Stage1 LEDs","",0,NEORING_LEDS,1,0, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_TRIGGER[2],"Stage2","",0,11000,100,50, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_NUMPIXELS[2],"Stage2 LEDs","",0,NEORING_LEDS,1,0, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_TRIGGER[3],"Stage3","",0,11000,100,50, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_NUMPIXELS[3],"Stage3 LEDs","",0,NEORING_LEDS,1,0, menu_rpm_change, exitEvent, noStyle)
  ,FIELD(CONFIG.RPM_TRIGGER[4],"StageFLSH","",0,11000,100,50, menu_rpm_change, exitEvent, noStyle)
);

MENU(configMenu_RPM_colors,"Set RPM colors",menu_rpm_color_display, (eventMask)(enterEvent | exitEvent),wrapStyle
  ,FIELD(CONFIG.RPM_COLOR[1],"Stage1","",0,1529,50,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR_LIGHTNESS[1],"Stage1Light","",0,255,20,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR[2],"Stage2","",0,1529,50,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR_LIGHTNESS[2],"Stage2Light","",0,255,20,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR[3],"Stage3","",0,1529,50,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR_LIGHTNESS[3],"Stage3Light","",0,255,20,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR[4],"StageFLSH","",0,1529,50,1, menu_rpm_color_change, enterEvent, noStyle)
  ,FIELD(CONFIG.RPM_COLOR_LIGHTNESS[4],"StageFLLght","",0,255,20,1, menu_rpm_color_change, enterEvent, noStyle)
);

MENU(configMenu_SAVE,"Save config?",doNothing,noEvent,wrapStyle
  ,OP("Yes",menu_save_config,enterEvent)
  ,OP("No",menu_back_action,enterEvent)
);

MENU(configMenu_DEFAULT,"Reset Defaults?",doNothing,noEvent,wrapStyle
  ,OP("Yes",menu_default_config,enterEvent)
  ,OP("No",menu_back_action,enterEvent)
);

MENU (configMenu,"Configuration",doNothing,noEvent,wrapStyle
  ,SUBMENU(configMenu_SAVE)
  ,SUBMENU(configMenu_DEFAULT)
);

MENU(mainMenu, "Settings", doNothing, noEvent, wrapStyle
  ,FIELD(CONFIG.rpm_brightness,"RPM LED","%",0,100,5,1, menu_rpm_brightness, enterEvent, noStyle)
  ,FIELD(CONFIG.gear_brightness,"Gear LED","%",0,100,5,1, menu_gear_brightness, enterEvent, noStyle)
  ,SUBMENU(configMenu_RPM_limits)
  ,SUBMENU(configMenu_RPM_colors)
  ,SUBMENU(configMenu)
  ,EXIT("<Exit menu")
);

Menu::noInput noinput;
//stringIn<0> menu_strIn;
//serialIn serial(Serial);
//MENU_INPUTS(in,&serial);

MENU_OUTPUTS(out,MENU_MAX_DEPTH
  ,LIQUIDCRYSTAL_OUT(lcd,{0,0,16,2})
  ,NONE//must have 2 items at least
);

NAVROOT(nav,mainMenu,MENU_MAX_DEPTH,noinput,out);

/*
 * SETUP()
 */

void setup() {
  // Serial comms init
  Serial.begin(9600);
  BTserial.begin(9600);

  // Neoring init
  neoring.begin();
  // VIRTUAL: do not use low brightness in SIM as it's not visible. Keep brightness low for real NeoRing HW.
  //neoring.setBrightness(8);

  // 8x8 LED Matrix init
  gears_lcd.setEnabled(true);

  // Apply EEPROM or Default config for Neoring, Gears matrix and RPM limits
  configuration.load();
  configuration.apply();

  // 16x2 LCD init
  lcd.begin(16,2);
  lcd.clear();

  // set menu visibility on startup as "idle"
  // instead use our own Monitor screen and handle Menu callback in lcd_monitor_screen
  nav.idleTask=lcd_monitor_screen;
  nav.idleOn();
  lcd_monitor_screen(out[0],Menu::idling);

  // play BMW logo rotation animation on startup
/*
  gears_display(&GEARS_GLYPH[10]);delay(1500);
  for (byte anim=0;anim<4;anim++)
  {
    gears_display(&GEARS_GLYPH[10]);delay(100);
    for (byte i=11;i<=13;i++)
    {
    gears_display(&GEARS_GLYPH[i]);delay(100);
    }
  }
  gears_display(&GEARS_GLYPH[10]);
 */
  // fill the rpm_scale_val and rpm_scale_col arrays with boundaries for each neopixel
  //rpm_scale_compute();

  noInterrupts();
  // 10Hz interrupt on TIMER1 for Racechrono BT LE output
  TCCR1A = 0;// set entire TCCR1A register to 0
  TCCR1B = 0;// same for TCCR1B
  TCNT1  = 0;//initialize counter value to 0
  // set compare match register for 1hz increments
  OCR1A = 1562; // = (16*10^6) / (10*1024) - 1 (must be <65536)
  // turn on CTC mode
  TCCR1B |= (1 << WGM12);
  // Set CS10 and CS12 bits for 1024 prescaler
  TCCR1B |= (1 << CS12) | (1 << CS10);  
  // enable timer compare interrupt
  TIMSK1 |= (1 << OCIE1A);
  interrupts();
}

void loop() {
  
#ifdef DEMO
unsigned short r;
for (byte g=1;g<7;g++)
{
  gears_display(&GEARS_GLYPH[g]);
  for (r=CONFIG.RPM_MIN;r<5700;r+=10)
  {
    rpm_fill(r);
    lcd.setCursor(0,1);
    lcd.print("     ");
    lcd.setCursor(0,1);
    lcd.print(r);
  }
}
#endif

  int lcd_button=analogRead(PIN_LCD_INPUT);

  if ((millis()-last_debounce_time) > debounce_delay)
  {
    for (byte i=1;i<=4;i++)
    {
      if (lcd_button>=lcd_button_range[i][1] && lcd_button<=lcd_button_range[i][2]) nav.doNav((Menu::navCmds) lcd_button_range[i][0]);
    }

    if (lcd_button<lcd_button_range[0][1])
    {
      DBG(F("Refresh menu"));
      DBG(lcd_button);
      lcd_menu_active=true;
      last_debounce_time=millis();
      nav.doOutput();
    }
  }

  // TODO: Integrate CANbus readings - currently only temporary PIN_RPM analog value used instead of CANBus
  // map analog PIN_RPM to values 0-xxxx(RPM_MAX)
  rpm = map(analogRead(PIN_RPM), 0,1023, 0,CONFIG.RPM_MAX);
  //rpm = int(RPM_MAX/float(1023)*analogRead(PIN_RPM));    // read the input pin
  // display the Neoring RPM with that value
  rpm_fill(rpm);

  // read the DAC convertor value
  gear_dac=analogRead(PIN_GEARS_INPUT);
  // and select gear based on DAC convertor lookup table. The lookup KEY is dynamically calculated so it is a direct access to the final gear to be displayed. No min/max Analogread comparisons.
  // 1024/16= 64 = full scale analogRead divided by 16 possible bits, and shifted by 32 (half of the "ranges") to both sides to make the AnalogRead boundaries.
  //gear=pgm_read_byte(&(gears_dac_lookup[(gear_dac+32)/(1024/16)][1]));
  gear=pgm_read_byte(&gears_dac_lookup[(gear_dac+32)/(1024/16)][1]);

  // read GEARs from the serial console if available
  /*
  if (Serial.available())
  {
    String console=Serial.readStringUntil('\n');
    gear=(byte) console.toInt();
  }
  */

  // TODO: performance - move to Interrupt section ? Make a millis() for refresh?
  if( millis()-last_gear_refreshtime>1000)
  {
    gears_display(&GEARS_GLYPH[gear]);
    last_gear_refreshtime=millis();
  }

  if (last_rpm!=rpm && !lcd_menu_active)
  {
   lcd.setCursor(0,1);
   lcd.print("     ");
   lcd.setCursor(0,1);
   lcd.print(rpm);  // Write a character to display
   last_rpm=rpm;
  }
}

// Racechrono BT output interrupt each 100ms aka 10Hz
ISR(TIMER1_COMPA_vect)
{
  char output[33];
  sprintf_P(output,PSTR("$RC2,,%u,,,,%d,%d,,,,,,,,*"),RC_counter,rpm,gear);
  byte checksum = 0;
  char checksum_format[]="00";
  // to verify, check https://nmeachecksum.eqth.net/ for simple NMEA-CRC online calculator
  // calulate CRC only for the message "body" between $ and *. These are excluded from the CRC.
  for (int i = 1; i < strlen(output)-1; i++)
  { 
    checksum = checksum ^ (unsigned byte)output[i];
  }
  sprintf_P(checksum_format,PSTR("%02X"),checksum);
  strcat(output,checksum_format);
  BTserial.println(output);
  RC_counter++;
  // as RC_counter is unsigned it roll over automatically 65535+1= back to 0
  // if (RC_counter==65535) RC_counter=0;
}


void gears_display(const void *image_pointer)
{
  uint64_t image;
  memcpy_P(&image,image_pointer,sizeof(uint64_t));
  for (int i = 0; i < 8; i++)
  {
    byte row = (image >> i * 8);
    for (int j = 0; j < 8; j++)
    {
      gears_lcd.setPixel(i, j, bitRead(row, j));
    }
  }
  gears_lcd.display();
}


// Used to render Neoring with RPM value
void rpm_fill(int rpm)
{
  if (!neoring_active) return;

  neoring.clear();

  // if out of range, just clear the neoring and exit
  if (rpm <= CONFIG.RPM_MIN || rpm > CONFIG.RPM_MAX)
  {
    if (neoring.canShow()) neoring.show();
    return;
  }
  
  // Flashing all
  if (rpm >= CONFIG.RPM_TRIGGER[RPM_FLASH])
  {
    neoring.fill(RPM_COLOR[RPM_FLASH]);neoring.show();
    delay(50);
    neoring.fill(0);neoring.show();
    delay(50);
    neoring.fill(RPM_COLOR[RPM_FLASH]);neoring.show();
    return;
  }
  
  // Normal operation, fill the LEDs according to RPMs
  for (byte position=0;position < NEORING_LEDS;position++)
  {
    if ( rpm > rpm_scale_val[position]) neoring.setPixelColor(NEORING_LEDS-1-position,*rpm_scale_col[position]);
    else neoring.setPixelColor(NEORING_LEDS-1-position,0);
  }

  if (neoring.canShow()) neoring.show();
}

void rpm_scale_compute()
{
  byte position=0;
  //for all G,Y,R before FLASH calculate and fill the internal array of RPM values
  RPM_COLOR[RPM_FLASH]=myColorHSV(CONFIG.RPM_COLOR[RPM_FLASH],CONFIG.RPM_COLOR_LIGHTNESS[RPM_FLASH]);
  for (byte stage=1;stage < RPM_FLASH;stage++)
  {
    RPM_COLOR[stage]=myColorHSV(CONFIG.RPM_COLOR[stage],CONFIG.RPM_COLOR_LIGHTNESS[stage]);
    position=position+CONFIG.RPM_NUMPIXELS[stage-1];
    if (position+CONFIG.RPM_NUMPIXELS[stage] <= NEORING_LEDS)
    {
      for (byte i=0;i<CONFIG.RPM_NUMPIXELS[stage];i++)
      {
      rpm_scale_val[position+i]=((CONFIG.RPM_TRIGGER[stage]-CONFIG.RPM_TRIGGER[stage-1])/CONFIG.RPM_NUMPIXELS[stage]*(i+0))+CONFIG.RPM_TRIGGER[stage-1];
      rpm_scale_col[position+i]=&RPM_COLOR[stage];
      DBG(position+i);
      DBG(rpm_scale_val[position+i]);
      }
    }
  }
}

Menu::result lcd_monitor_screen(menuOut& out,idleEvent e)
{
  // idleStart - fired when entering idle state, but last menurefresh is still executed
  // idling - fired once when enering menu idle mode, and after all menu refresh/clear is done
  // idleEnd - fired when leaving idle state, but before any menu init is done

  // so rely on idling state and prepare the lcd_monitor_screen to take over 
  if (e==Menu::idling)
  {
    out.clear();
    out.setCursor(0,0);
    out.print("RPM   WATER  OIL");
    // used for decision if menu must be polled/refreshed to save resources in loop()
    lcd_menu_active=false;
  }
}

uint32_t myColorHSV(uint16_t hue, uint8_t val) {
  // Remap 0-65535 to 0-1529. Pure red is CENTERED on the 64K rollover;
  // 0 is not the start of pure red, but the midpoint...a few values above
  // zero and a few below 65536 all yield pure red (similarly, 32768 is the
  // midpoint, not start, of pure cyan). The 8-bit RGB hexcone (256 values
  // each for red, green, blue) really only allows for 1530 distinct hues
  // (not 1536, more on that below), but the full unsigned 16-bit type was
  // chosen for hue so that one's code can easily handle a contiguous color
  // wheel by allowing hue to roll over in either direction.
/////////  hue = (hue * 1530L + 32768) / 65536;
  // Because red is centered on the rollover point (the +32768 above,
  // essentially a fixed-point +0.5), the above actually yields 0 to 1530,
  // where 0 and 1530 would yield the same thing. Rather than apply a
  // costly modulo operator, 1530 is handled as a special case below.

  uint8_t r, g, b, sat;

  if (val<128) {val=map(val,0,127,0,255);sat=255;}
  else if (val>=128) {sat=map(val,128,255,255,0);val=255;}
  
  // Convert hue to R,G,B (nested ifs faster than divide+mod+switch):
  if(hue < 510) {         // Red to Green-1
    b = 0;
    if(hue < 255) {       //   Red to Yellow-1
      r = 255;
      g = hue;            //     g = 0 to 254
    } else {              //   Yellow to Green-1
      r = 510 - hue;      //     r = 255 to 1
      g = 255;
    }
  } else if(hue < 1020) { // Green to Blue-1
    r = 0;
    if(hue <  765) {      //   Green to Cyan-1
      g = 255;
      b = hue - 510;      //     b = 0 to 254
    } else {              //   Cyan to Blue-1
      g = 1020 - hue;     //     g = 255 to 1
      b = 255;
    }
  } else if(hue < 1530) { // Blue to Red-1
    g = 0;
    if(hue < 1275) {      //   Blue to Magenta-1
      r = hue - 1020;     //     r = 0 to 254
      b = 255;
    } else {              //   Magenta to Red-1
      r = 255;
      b = 1530 - hue;     //     b = 255 to 1
    }
  } else {                // Last 0.5 Red (quicker than % operator)
    r = 255;
    g = b = 0;
  }

  // Apply saturation and value to R,G,B, pack into 32-bit result:
  uint32_t v1 =   1 + val; // 1 to 256; allows >>8 instead of /255
  uint16_t s1 =   1 + sat; // 1 to 256; same reason
  uint8_t  s2 = 255 - sat; // 255 to 0
  return ((((((r * s1) >> 8) + s2) * v1) & 0xff00) << 8) |
          (((((g * s1) >> 8) + s2) * v1) & 0xff00)       |
         ( ((((b * s1) >> 8) + s2) * v1)           >> 8);
}

Credits

Ankel2000

Ankel2000

0 projects β€’ 7 followers

Comments