Chemcool Electronics

Chemcool Electronics Blog

Heltec WiFi Kit Digital Speedometer with OLED Display

 

 

I recently assembled a digital speedometer using the Heltec WiFi Kit V3, equipped with an OLED screen, built on a proto board. The project integrates a LiPo battery, membrane switches, and a GPS module for comprehensive functionality. The speedometer display provides essential real-time data, including the current speed in kilometers per hour and the current average speed. Additionally, it features two trip meters (Trip A and Trip B), which users can reset as needed. For added convenience, there is a toggle screen to view the average speed over the selected trip distance, allowing easy comparison between Trip A and Trip B. To ensure reliable navigation and tracking, the display shows the number of satellites detected, complete with a satellite icon that appears when more than three satellites are connected. A battery icon is also present to indicate the current voltage level of the LiPo battery, ensuring users can monitor battery health during use. The speedometer also records and displays the maximum speed reached, providing a comprehensive overview of the performance data.

 

#define HELTEC_BOARD true
#define SLOW_CLK_TYPE 1

#include <HT_TinyGPS++.h>
#include <Wire.h>
#include "HT_SSD1306Wire.h"

// Function to control external power
void VextON() {
  pinMode(21, OUTPUT);
  digitalWrite(21, LOW);
}

void VextOFF() {
  pinMode(21, OUTPUT);
  digitalWrite(21, HIGH);
}

// Define pins
#define RX_PIN 3
#define TX_PIN 2
#define OLED_ADDR 0x3C
#define RESET_TRIP_A_PIN 5  // Changed to GPIO 12
#define RESET_TRIP_B_PIN 6  // Changed to GPIO 13
#define FREEZE_MODE_PIN 7   // Changed to GPIO 14

// Initialize OLED display
SSD1306Wire display(OLED_ADDR, 500000, SDA_OLED, SCL_OLED, GEOMETRY_128_64, RST_OLED);

// Initialize GPS module
HardwareSerial gpsSerial(2);
TinyGPSPlus gps;

// Variables for speed calculations
float maxSpeed = 0.0;
float recentSpeeds[5] = {0.0};  // Buffer to hold recent speeds
int speedIndex = 0;
float averageSpeed = 0.0;

// Variables for trip calculations
float tripDistanceA = 0.0;
float tripDistanceB = 0.0;
float tripAverageSpeedA = 0.0;
float tripAverageSpeedB = 0.0;
unsigned long tripTimeA = 0;
unsigned long tripTimeB = 0;
unsigned long lastTripUpdate = 0;

// Variables for dormant detection and reset
unsigned long dormantStartTime = 0;
const unsigned long dormantThreshold = 5000; // 5 seconds of dormancy
bool isDormant = false;

// Variables for battery monitoring
const float CALIBRATION_FACTOR = 0.0087;
bool batteryFlashState = false;
unsigned long lastFlashTime = 0;
const unsigned long flashInterval = 500;
bool freezeMode = false;

// Variables for button debouncing
unsigned long lastFreezeButtonPress = 0;
unsigned long lastTripAButtonPress = 0;
unsigned long lastTripBButtonPress = 0;
const unsigned long DEBOUNCE_DELAY = 75;  // 100ms debounce time

bool lastFreezeButtonState = HIGH;
bool lastTripAButtonState = HIGH;
bool lastTripBButtonState = HIGH;
bool currentFreezeButtonState = HIGH;
bool currentTripAButtonState = HIGH;
bool currentTripBButtonState = HIGH;

// Smoothing parameters for battery voltage
const int voltageBufferSize = 10;
float voltageBuffer[voltageBufferSize];
int voltageBufferIndex = 0;
bool bufferFilled = false;

// Function to initialize the voltage buffer
void initializeVoltageBuffer() {
  for (int i = 0; i < voltageBufferSize; i++) {
    voltageBuffer[i] = readBatteryVoltage();
  }
  bufferFilled = true;
}

// Function to read and average battery voltage
float getSmoothedBatteryVoltage() {
  voltageBuffer[voltageBufferIndex] = readBatteryVoltage();
  voltageBufferIndex = (voltageBufferIndex + 1) % voltageBufferSize;

  if (!bufferFilled && voltageBufferIndex == 0) {
    bufferFilled = true;
  }

  float sum = 0;
  int count = bufferFilled ? voltageBufferSize : voltageBufferIndex;

  for (int i = 0; i < count; i++) {
    sum += voltageBuffer[i];
  }

  return sum / count;
}

// Function to read battery voltage from GPIO 13
float readBatteryVoltage() {
  int raw = analogRead(13);
  return raw * CALIBRATION_FACTOR;
}

// Function to calculate trip average speeds
void calculateTripAverageSpeeds() {
    if (tripTimeA > 0) {
        tripAverageSpeedA = (tripDistanceA * 3600000.0) / tripTimeA;  // Convert to km/h
    }
    if (tripTimeB > 0) {
        tripAverageSpeedB = (tripDistanceB * 3600000.0) / tripTimeB;  // Convert to km/h
    }
}

// Function to draw battery icon
void drawBatteryIcon(float voltage) {
  int batteryLevel = 0;

  if (voltage >= 4.0) {
    batteryLevel = 5;
  } else if (voltage >= 3.8) {
    batteryLevel = 4;
  } else if (voltage >= 3.6) {
    batteryLevel = 3;
  } else if (voltage >= 3.4) {
    batteryLevel = 2;
  } else if (voltage >= 3.3) {
    batteryLevel = 1;
  }

  bool showBatteryIcon = true;
  if (voltage <= 3.2) {
    unsigned long currentMillis = millis();
    if (currentMillis - lastFlashTime >= flashInterval) {
      batteryFlashState = !batteryFlashState;
      lastFlashTime = currentMillis;
    }
    showBatteryIcon = batteryFlashState;
  }

  if (showBatteryIcon) {
    display.drawRect(100, 15, 20, 10);
    display.fillRect(120, 17, 2, 6);
    
    for (int i = 0; i < batteryLevel; i++) {
      display.fillRect(102 + (i * 3), 17, 2, 6);
    }
  }
}

// Function to draw clock with South African time zone adjustment (UTC+2)
void drawClock(int hour, int minute, int second) {
  hour = (hour + 2) % 24;  // Adjust for SAST time zone, wrap around 24-hour format

  String timeStr = (hour < 10 ? "0" + String(hour) : String(hour)) + ":" +
                   (minute < 10 ? "0" + String(minute) : String(minute)) + ":" +
                   (second < 10 ? "0" + String(second) : String(second));
  display.setFont(ArialMT_Plain_10);
  display.setTextAlignment(TEXT_ALIGN_RIGHT);
  display.drawString(128, 0, timeStr);
}

// Function to draw satellite icon if connected to enough satellites
void drawSatelliteIcon() {
  if (gps.satellites.value() >= 3) {
    display.fillCircle(120, 55, 2);
    display.drawLine(118, 53, 128, 57);
    display.drawLine(118, 57, 128, 53);
  }
}

// Updated display function with vertical freeze mode layout
void updateDisplay(float currentSpeed, float maxSpeed, float averageSpeed, 
                   float tripDistanceA, float tripDistanceB, float batteryVoltage, 
                   int hour, int minute, int second) {
    display.clear();

    // Always show clock
    drawClock(hour, minute, second);

    if (!freezeMode) {
        // Normal display mode
        drawBatteryIcon(batteryVoltage);
        
        display.setFont(ArialMT_Plain_24);
        display.setTextAlignment(TEXT_ALIGN_LEFT);
        display.drawString(0, 0, String(currentSpeed, 1));

        display.setFont(ArialMT_Plain_10);
        display.setTextAlignment(TEXT_ALIGN_RIGHT);
        display.drawString(128, 28, "km/h");
        display.drawString(128, 40, "Max: " + String(maxSpeed, 1));

        display.setFont(ArialMT_Plain_16);
        display.setTextAlignment(TEXT_ALIGN_LEFT);
        display.drawString(0, 25, "Avg: " + String(averageSpeed, 1));

        display.setFont(ArialMT_Plain_10);
        display.setTextAlignment(TEXT_ALIGN_LEFT);
        display.drawString(0, 43, "Trip A: " + String(tripDistanceA, 2) + " km");
        display.drawString(0, 52, "Trip B: " + String(tripDistanceB, 2) + " km");

        drawSatelliteIcon();
    } else {
        // Freeze mode - vertical layout
        display.setFont(ArialMT_Plain_16);
        display.setTextAlignment(TEXT_ALIGN_LEFT);
        
        // Trip A display
        display.drawString(0, 0, "Trip A:");
        display.setFont(ArialMT_Plain_24);
        display.drawString(0, 16, String(tripAverageSpeedA, 1));
        display.setFont(ArialMT_Plain_10);
        display.drawString(40, 28, "km/h");
        
        // Trip B display
        display.setFont(ArialMT_Plain_16);
        display.drawString(0, 45, "Trip B:");
        display.setFont(ArialMT_Plain_24);
        display.drawString(50, 40, String(tripAverageSpeedB, 1));
        display.setFont(ArialMT_Plain_10);
        display.drawString(90, 50, "km/h");
    }

    display.display();
}

// Function to handle Trip A reset button with debouncing
void handleTripAButton() {
    static bool buttonPressed = false;
    bool reading = digitalRead(RESET_TRIP_A_PIN);
    
    // Check for button press (LOW because of INPUT_PULLUP)
    if (reading == LOW && !buttonPressed) {
        if ((millis() - lastTripAButtonPress) > DEBOUNCE_DELAY) {
            tripDistanceA = 0.0;
            tripTimeA = 0;
            tripAverageSpeedA = 0.0;
            Serial.println("Trip A reset");
            lastTripAButtonPress = millis();
            buttonPressed = true;
        }
    } else if (reading == HIGH) {
        buttonPressed = false;
    }
}

void handleTripBButton() {
    static bool buttonPressed = false;
    bool reading = digitalRead(RESET_TRIP_B_PIN);
    
    if (reading == LOW && !buttonPressed) {
        if ((millis() - lastTripBButtonPress) > DEBOUNCE_DELAY) {
            tripDistanceB = 0.0;
            tripTimeB = 0;
            tripAverageSpeedB = 0.0;
            Serial.println("Trip B reset");
            lastTripBButtonPress = millis();
            buttonPressed = true;
        }
    } else if (reading == HIGH) {
        buttonPressed = false;
    }
}

void handleFreezeButton() {
    static bool buttonPressed = false;
    bool reading = digitalRead(FREEZE_MODE_PIN);
    
    if (reading == LOW && !buttonPressed) {
        if ((millis() - lastFreezeButtonPress) > DEBOUNCE_DELAY) {
            if (!freezeMode) {
                calculateTripAverageSpeeds();
            }
            freezeMode = !freezeMode;
            Serial.println(freezeMode ? "Freeze mode activated" : "Freeze mode deactivated");
            lastFreezeButtonPress = millis();
            buttonPressed = true;
        }
    } else if (reading == HIGH) {
        buttonPressed = false;
    }
}

void setup() {
    Serial.begin(115200);
    
    VextON();
    delay(100);

    display.init();
    display.clear();
    display.display();

    // Show startup screen
    display.setTextAlignment(TEXT_ALIGN_CENTER);
    display.setFont(ArialMT_Plain_10);
    display.drawString(64, 22, "Chemcool");
    display.drawString(64, 30, "Electronics");
    display.drawString(64, 38, "2024");
    display.display();
    delay(5000);

    display.clear();
    display.display();

    // Initialize GPS
    gpsSerial.begin(9600, SERIAL_8N1, RX_PIN, TX_PIN);
    Serial.println("GPS serial initialized");
    
    // Set GPS update rate
    gpsSerial.print(F("$PMTK220,100*2F\r\n"));
    Serial.println("GPS update rate set");

    // Initialize buttons with internal pull-up resistors
    pinMode(RESET_TRIP_A_PIN, INPUT_PULLUP);
    pinMode(RESET_TRIP_B_PIN, INPUT_PULLUP);
    pinMode(FREEZE_MODE_PIN, INPUT_PULLUP);

    initializeVoltageBuffer();
}

void loop() {
    bool newData = false;
    while (gpsSerial.available() > 0) {
        char c = gpsSerial.read();
        if (gps.encode(c)) {
            newData = true;
        }
    }

    unsigned long currentMillis = millis();

    // Handle all buttons with proper debouncing
    handleTripAButton();
    handleTripBButton();
    handleFreezeButton();

    if (newData && gps.speed.isValid()) {
        float currentSpeed = gps.speed.kmph();

        dormantStartTime = currentMillis;
        isDormant = false;

        if (currentSpeed > maxSpeed) {
            maxSpeed = currentSpeed;
        }

        recentSpeeds[speedIndex] = currentSpeed;
        speedIndex = (speedIndex + 1) % 5;

        float totalSpeed = 0;
        int validSpeeds = 0;
        for (int i = 0; i < 5; i++) {
            if (recentSpeeds[i] > 0) {
                totalSpeed += recentSpeeds[i];
                validSpeeds++;
            }
        }
        averageSpeed = validSpeeds > 0 ? totalSpeed / validSpeeds : 0;

        // Update trip times and distances only when moving and not in freeze mode
        if (currentSpeed > 0.5) {  // Only count when moving
            float distanceUpdateTime = (currentMillis - lastTripUpdate) / 3600000.0;
            if (!freezeMode) {
                tripDistanceA += currentSpeed * distanceUpdateTime;
                tripDistanceB += currentSpeed * distanceUpdateTime;
                tripTimeA += currentMillis - lastTripUpdate;
                tripTimeB += currentMillis - lastTripUpdate;
            }
        }
        lastTripUpdate = currentMillis;

        updateDisplay(currentSpeed, maxSpeed, averageSpeed, tripDistanceA, tripDistanceB, 
                     getSmoothedBatteryVoltage(), gps.time.hour(), gps.time.minute(), 
                     gps.time.second());
    } 
    
    if (!isDormant && (currentMillis - dormantStartTime >= dormantThreshold)) {
        isDormant = true;
        memset(recentSpeeds, 0, sizeof(recentSpeeds));
        speedIndex = 0;
        averageSpeed = 0.0;
        updateDisplay(0, maxSpeed, averageSpeed, tripDistanceA, tripDistanceB, 
                     getSmoothedBatteryVoltage(), gps.time.hour(), gps.time.minute(), 
                     gps.time.second());
    }

    delay(100);
}