/*
Titel: Downhill Stopwatch
Author: Gregor Mausser
*/
//-------Variablen, Bibliotheken--------------------------------------
//Bibliotheken
//Display
#include <Wire.h> //I2C Kommunikation
#include <Adafruit_GFX.h> //Grafikbilbliothek
#include <Adafruit_SSD1306.h> //OLED-Display Treiber
//Zeiten speichern
#include <LittleFS.h> //Flash-Speicher
#include <ArduinoJson.h> //JSON lesen und schreiben
//Webserver
#include <WiFi.h> //Hotspot
#include <ESPAsyncWebServer.h> //Asynchroner Webserver
//Pins definieren
const int ButtonPin = 2; //Start/Stop Button
const int ButtonResetPin = 3; //Reset Button (mit Interrupt)
const int BuzzerPin = 7; //Aktiver Buzzer
const int RGB_red = 4; //RGB-Led Rot
const int RGB_green = 5; //RGB-Led Grün
const int RGB_blue = 6; //RGB-Led Blau
//Display Variablen
const int ScreenHeight = 64; //Höhe
const int ScreenWidth = 128; //Breite
//Display Objekt erstellen
Adafruit_SSD1306 display(ScreenWidth, ScreenHeight, &Wire, -1);
//Variablen für die Zeitmessung
unsigned long duration; //Gesamte Zeit in ms
int minute; //Minuten Anteil der Zeit
int seconds; //Sekunden Anteil der Zeit
int milliseconds; //Millisekunden Anteil der Zeit
unsigned long starttime = 0; //Startzeit der Messung
unsigned long endtime = 0; //endzeit der Messung
bool timerRunning = false; //Zeitmessung aktiv?
bool stopMeasure = false; //Wurde die Messung gestoppt?
unsigned long showStart = 0;
unsigned long notShowStart = 0;
unsigned long showMeasurements = 0;
unsigned long notShowMeasurements = 0;
int setting_endtime = 5; //Anzeigedauer der Endzeit in Sekunden
int setting_countdown = 10; //Dauer der Startfrequenze
int showingSTART = 5; //wie lange "Start" angezeigt wurde
bool setting_buzzer = false; //Buzzer nicht aktiv
//Variablen für millis (Button Debounce)
bool lastReading = HIGH;
bool stableState = HIGH;
unsigned long lastDebounceTime = 0;
const unsigned long debounceDelay = 30;
//Interup Variable
volatile bool resetPressed = false;
//Variablen für den Webserver
const char* ssid = "Downhill-Timer";
const char* password = "12345678";
//blockiert loop() nicht während auf Anfragen gewartet wird
AsyncWebServer server(80);
//States erstellen
enum mode { READY,
START,
TIME_MEASURE,
STOP };
//state definieren
enum mode state = READY;
//Interrupt Funktion
void IRAM_ATTR myISR() {
resetPressed = true;
}
//----------Web-Server und Zeit Speicherung----------------------------------
void loadSettings() {
File file = LittleFS.open("/settings.json", "r");
if (!file) return;
JsonDocument doc;
deserializeJson(doc, file);
file.close();
setting_endtime = doc["show_endtime"] | 5;
setting_countdown = doc["countdown"] | 10;
setting_buzzer = doc["buzzer_enabled"] | true;
}
void saveSettings(int endtime, int countdown, bool buzzer) {
JsonDocument doc;
doc["show_endtime"] = endtime;
doc["countdown"] = countdown;
doc["buzzer_enabled"] = buzzer;
File out = LittleFS.open("/settings.json", "w");
serializeJson(doc, out);
out.close();
setting_endtime = endtime;
setting_countdown = countdown;
setting_buzzer = buzzer;
}
//Funktion für den Webserver
void setupWebserver() {
//HTML-Datei aus LittleFS ausliefern
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(LittleFS, "/index.html", "text/html");
});
//alle gespeicherten Zeiten als JSON senden
server.on("/times", HTTP_GET, [](AsyncWebServerRequest *request) {
File file = LittleFS.open("/times.json", "r");
if (!file) {
//noch keine Zeiten vorhanden
request->send(200, "application/json", "{\"times\":[]}");
return;
}
request->send(LittleFS, "/times.json", "application/json");
});
//Trail einer Zeit zuordnen
server.on("/assign", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
JsonDocument req;
deserializeJson(req, data, len);
int id = req["id"];
String trail = req["trail"].as<String>();
//bestehende Zeiten laden
JsonDocument doc;
File file = LittleFS.open("/times.json", "r");
if (file) {
deserializeJson(doc, file);
file.close();
}
//Eintrag mit passender ID finden und Trail setzen
JsonArray arr = doc["times"].as<JsonArray>();
for (JsonObject entry : arr) {
if (entry["id"] == id) {
entry["trail"] = trail;
break;
}
}
//aktualisierte Zeiten zurückschreiben
File out = LittleFS.open("/times.json", "w");
serializeJson(doc, out);
out.close();
request->send(200, "application/json", "{\"ok\":true}");
}
);
//einzelne Zeit löschen
server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
JsonDocument req;
deserializeJson(req, data, len);
int id = req["id"];
//bestehende Zeiten laden
JsonDocument doc;
File file = LittleFS.open("/times.json", "r");
if (file) {
deserializeJson(doc, file);
file.close();
}
//neues Array ohne den gelöschten Eintrag aufbauen
JsonDocument newDoc;
JsonArray newArr = newDoc["times"].to<JsonArray>();
for (JsonObject entry : doc["times"].as<JsonArray>()) {
if (entry["id"] != id) {
newArr.add(entry);
}
}
//aktualisierte Zeiten zurückschreiben
File out = LittleFS.open("/times.json", "w");
serializeJson(newDoc, out);
out.close();
request->send(200, "application/json", "{\"ok\":true}");
}
);
//alle Zeiten löschen
server.on("/deleteAll", HTTP_POST, [](AsyncWebServerRequest *request) {
File out = LittleFS.open("/times.json", "w");
out.print("{\"times\":[]}");
out.close();
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/settings", HTTP_GET, [](AsyncWebServerRequest *request) {
File file = LittleFS.open("/settings.json", "r");
if (!file) {
request->send(200, "application/json",
"{\"show_endtime\":5,\"countdown\":10,\"buzzer_enabled\":true}");
return;
}
request->send(LittleFS, "/settings.json", "application/json");
});
server.on("/settings", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
JsonDocument req;
deserializeJson(req, data, len);
saveSettings(
req["show_endtime"] | 5,
req["countdown"] | 10,
req["buzzer_enabled"] | true
);
request->send(200, "application/json", "{\"ok\":true}");
}
);
//CORS Header damit Browser keine Verbindungsfehler wirft
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
server.begin();
}
//-----Setup--------------------------------------------------
void setup() {
//Baudrate
Serial.begin(115200);
//Ein- und Ausgäge definieren
pinMode(BuzzerPin, OUTPUT);
pinMode(ButtonPin, INPUT_PULLUP);
pinMode(ButtonResetPin, INPUT_PULLUP);
pinMode(RGB_red, OUTPUT);
pinMode(RGB_green, OUTPUT);
pinMode(RGB_blue, OUTPUT);
//Interrupt
attachInterrupt(
digitalPinToInterrupt(ButtonResetPin),
myISR,
FALLING
);
//Display initialisieren
Wire.begin(8, 9);
//Wenn Display nicht erkannt wird
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("Display Fehler!"));
while (true);
}
display.display();
/*Debug4 Displayadresse
Wire.begin(8, 9);
for (byte addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) {
Serial.print("Gefunden: 0x");
Serial.println(addr, HEX);
}
}
*/
//Dispaly reinigen
display.clearDisplay();
display.display();
//LittleFS initialisieren
if (!LittleFS.begin(true)) {
Serial.println("LittleFS Fehler");
while (true);
}
loadSettings();
//Hotspot starten
WiFi.softAP(ssid, password);
Serial.print("Hotspot IP: ");
Serial.println(WiFi.softAPIP());
//Webserver starten
setupWebserver();
}
//----------Funktionen----------------------------------------------------------
//Funktion zum Speichern der Zeit
void saveTime(unsigned long duration) {
//Zeiten laden
JsonDocument doc;
//File im Lesemodus (read) öffnen
File file = LittleFS.open("/times.json", "r");
//Wenn die Datei existiert Inhalt ins doc laden
if (file) {
deserializeJson(doc, file);
//File schließen
file.close();
}
//Array erstellen wenn keines vorhanden ist
JsonArray arr = doc["times"].is<JsonArray>()
? doc["times"].as<JsonArray>()
: doc["times"].to<JsonArray>();
//nächste ID berechnen
int nextId = arr.size() + 1;
//neuen Eintrag anhängen
JsonObject entry = arr.add<JsonObject>();
entry["id"] = nextId;
entry["ms"] = duration;
entry["trail"] = ""; //wird später in der Web-App zugeordnet
entry["date"] = String(millis());
//Datei im Schreibmodus (write) öffnen und aktualisiertes JSON hinein schreiben
File out = LittleFS.open("/times.json", "w");
serializeJson(doc, out);
out.close();
}
//Funktion zum Text zentrieren
void printCentered(const char* text, int textSize, int y) {
int charWidth = 6 * textSize;
int x = (ScreenWidth - (int)strlen(text) * charWidth) / 2;
if (x < 0) x = 0;
display.setTextSize(textSize);
display.setTextColor(WHITE);
display.setCursor(x, y);
display.print(text);
}
//Funktion für den Countdown/Startfrequenze
void StartFrequence() {
for (int i = setting_countdown; i >= 1; i--) {
reset_pushed();
//Aus der Schleife springen wenn der Reset Button gedrückt wurde
if (state == READY) {
return;
}
//Debugg2 Countdownausgeben
Serial.print("Countdown: ");
Serial.println(i);
if (i > 5) {
display.clearDisplay();
char buffer[4];
sprintf(buffer, "%d", i);
printCentered(buffer, 3, 10);
display.display();
digitalWrite(RGB_green, LOW);
digitalWrite(RGB_blue, LOW);
digitalWrite(RGB_red, HIGH);
delay(1000);
}
else {
display.clearDisplay();
char buffer[4];
sprintf(buffer, "%d", i);
printCentered(buffer, 3, 10);
display.display();
//RGB-Led auschalten und 1/2 sec warten
digitalWrite(RGB_red, LOW);
delay(350);
//RGB-Led einschalten
digitalWrite(RGB_red, HIGH);
if (setting_buzzer == true) {
//Buzzer piepsen
if (i == 1) {
tone(BuzzerPin, 880, 300); //höherer Ton beim letzten Blinken
}
else {
tone(BuzzerPin, 440, 80);
}
}
//1/2 sec warten
delay(650);
}
}
//RGB auschalten
digitalWrite(RGB_red, LOW);
}
//Funktion um auf Knopfdruck in den nächsten State zu kommen (millis)
void button_pushed(mode next_state) {
bool reading = digitalRead(ButtonPin);
if (reading != lastReading) {
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > debounceDelay) {
if (reading != stableState) {
stableState = reading;
if (stableState == LOW) {
//Debugg3 Zeige wenn der Button gedrückt wurde
Serial.println("Button pressed");
state = next_state; //in den nächsten State wechseln
}
}
}
lastReading = reading;
}
void reset_pushed() {
//Interrupt überprüfen
if (resetPressed == true) {
timerRunning = false;
stopMeasure = false;
lastReading = HIGH;
stableState = HIGH;
lastDebounceTime = 0;
//Debug5 Zurücksetzen
//Serial.println("Zeitmessung zurückgesetzt");
resetPressed = false;
state = READY; //in den State READY springen
}
}
//------------Loop-------------------------------------------------------
void loop() {
//Interrupt überprüfen
reset_pushed();
//Debug1: State ausgeben
//Serial.print("State: ");
//Serial.println(state);
switch (state) {
//State "READY"
//nächster State wenn der Start Knopf (ButtonPin) gedrückt wurde
case READY:
timerRunning = false;
digitalWrite(RGB_red, LOW);
digitalWrite(RGB_green, LOW);
digitalWrite(RGB_blue, LOW);
display.clearDisplay();
printCentered("Ready", 2, 5);
printCentered("to", 2, 25);
printCentered("Race", 2, 45);
display.display();
button_pushed(START);
break;
//State "START"
//nächster State wenn die Startfrequenze vorbei ist
case START:
stopMeasure = false;
//Display reinigen
display.clearDisplay();
display.display();
//Startfrequenze aufrufen
StartFrequence();
if (state != READY) {
state = TIME_MEASURE;
}
else{
state = READY;
}
break;
//State "TIME_MEASURE"
//nächster State wenn Stop Knopf (ButtonPin) gedrückt wurde
case TIME_MEASURE:
// starttime und Display nur EINMAL setzen
if (!timerRunning) {
showStart = millis();
starttime = millis();
timerRunning = true;
//Dispaly reinigen
display.clearDisplay();
display.display();
}
notShowStart = millis();
if (notShowStart - showStart <= showingSTART * 1000) {
//Mittig "Start!" anzeigen
display.clearDisplay();
printCentered("Start!", 3, 20);
display.display();
digitalWrite(RGB_green, HIGH);
}
else {
//Mittig "Start!" anzeigen
display.clearDisplay();
display.display();
digitalWrite(RGB_green, LOW);
}
button_pushed(STOP);
break;
//State "STOP"
//nächter State wenn die Zeit eine gewisse Zeit angezeigt wurde
case STOP:
if (!stopMeasure) {
digitalWrite(RGB_green, LOW);
showMeasurements = millis();
endtime = millis();
//Zeit berechnen
duration = endtime - starttime;
minute = (duration / 1000) / 60;
seconds = (duration / 1000) % 60;
milliseconds = duration % 1000;
saveTime(duration);
stopMeasure = true;
}
notShowMeasurements = millis();
if (notShowMeasurements - showMeasurements <= setting_endtime * 1000) {
{
char timeStr[12];
sprintf(timeStr, "%d:%02d.%03d", minute, seconds, milliseconds);
display.clearDisplay();
//Endzeit
printCentered(timeStr, 2, 25);
display.display();
button_pushed(START);
}
}
else {
stopMeasure = false;
state = READY;
}
break;
//Default
default:
display.clearDisplay();
printCentered("Error!", 1, 0);
printCentered("Restarting...", 1, 24);
display.display();
delay(3000);
state = READY;
break;
}
//Macht den Ablauf flüssiger bzw. entschleunigt Loop
delay(10);
}
Loading
esp32-c3-devkitm-1
esp32-c3-devkitm-1
Start
Reset