An attempt to achieve: 1. Shifter LEDs with RPM display 2. Manual gearbox sensing and display 3. CAN bus values forwarding to RaceChrono.
Things used in this project
Story
Introduction
When I start to physically modify my BMW E46 328i Coupe to be more suitable for my occasional race track days or amateur rallying I quickly realize I need also some electronics to support my driving experience.
It starts with a simple target: To have the data logging in RaceChrono application in real-time. With my old BMW car, the refresh rate of OBDII values for RPM and throttle position is only about 2-3Hz at maximum - which is good to have an rough view, but for exporting the video with overlay gauges it is really too slow. The downshifts or RPM/throttle peaks are not even stored. It does not matter which OBD Bluetooth module I use (ELM327 China, or a $50 recommended module) I never get better performance. My goal was to feed RaceChrono with data at least each 100ms aka 10 Hz.
Preparations
I start to research how the instrument cluster/dashboard works in my car, how to hack in and what HW do I need to achieve the RaceChrono connection. Quite soon I end up with first proposal including an Arduino board with a Bluetooth and CAN modules to hook up directly to my car CANbus to read the raw values from ECU/DME and possibly also from other control units.
With such a smart Arduino device there were now opportunities to use it also for other stuff and the project evolve in a minimalistic race dashboard with:
RPM LED ring
LCD with basic engine data (RPM, water coolant temp, oil temp..)
Gear/shift indicator that reads the current gear from the shift lever on top of manual gearbox
During prototyping I use simulators of Arduino, because the progress in electrical scheme/setup was way faster than trial&error on a real hardware. I'm more an IT guy rather than an electrician.
AVR Simulator tools used
I started with Tinkercad for the very beginning, but realize there is no possibility to include 3rd party libraries, which I need at least for simulating the real hardware devices. I moved to VirtualBreadboard (the older 6.0.x version) which was way better and faster and also provide an integrated osciloscope. As it suddenly stop to work after some.NET update and I was unable to get it working again, I finally end up with Proteus. And I was astonished about its features. It is really a swiss army knife in microcontroller and circuit simulation and it provides also ton of debugging tools including I2C, SPI, oscilator, conditional code breakpoints and a lot more stuff including PCB designs and 3D models of the components used. Definitely recommended for "bigger" projects and pricing is also ok compared to how many features it has.
Finished project tasks
LCD with menu for configuring NeoPixel RPM colors and their ranges. Fully customizable and stored in EEPROM
8x8 LED matrix using standard SPI instead of software seen in LedControl.h library
DAC for gears -simulated. A simple 4bit Digital to Analog converter to read 4 switches that will be mounted on the gear shift lever and based on which bits are High/Low displays correct gear on 8x8 matrix
MCP2515 CANbus successfully connected to a car and values from ECU sniffed and translated to human readable values of RPM, throttle position and Coolant temp
RaceChrono Bluetooth connection to Arduino to receive live data. Currently only Bluetooth2 is working as RFCOMM device. For Bluetooth4LE a more low-level configurable chip is needed to meet RaceChrono requirements (eg. ESP32/8266). I was unable to get it working with HM-10 breakboard and its Serial configuration only.
Measured, create and 3D print the dashboard enclosure for Arduino components and displays. As I have no experience, this task was a challenging journey for me. The modeling was done in SketchUp and final print looks awesome to me. Everything align nicely. Big thanksto my former colleagues to utilize their own profi 3D printer. I already purchased my own Ender3 for future projects 😁.
Unfinished project tasks
Inspect CANbus messages for additional interesting data to process
Observe power consumptions and interferences when finally mounted in the car
Possible further ideas to implement
dynamically controlling the engine fan via PWM based on the coolant temps. As the fan control unit in my car is dead I have currently only simple switch/relay for 100% ON or OFF.
Oil temp is not available on CANbus for this DME, but can be read via service K-line. Not in focus currently, but some proof of concept solutions found based on sniffing the serial line while the original BMW service programs are running and displaying the Oil temp. UPDATE: a simpler option will be to read the raw temperature sensor coming to the ECU
Compensate the brightness of displays based on a light sensor in addition to a user manual configuration. Would be nice to have automatic dimming between day/night conditions.
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);
}