This commit is contained in:
Rainer 2025-04-29 23:36:37 +02:00
commit ce1095bb24
27 changed files with 621 additions and 0 deletions

0
README.md Normal file
View file

10
app/init.py Normal file
View file

@ -0,0 +1,10 @@
from flask import Flask
def create_app():
app = Flask(__name__)
app.secret_key = "rennstopuhr2025"
from app.routes import main as main_blueprint
app.register_blueprint(main_blueprint)
return app

99
app/routes.py Normal file
View file

@ -0,0 +1,99 @@
from flask import Blueprint, render_template, request, redirect, url_for, jsonify
import json
import os
from app.utils import gpio_handler, countdown, zeitmessung, system_tools
main = Blueprint("main", __name__)
@main.route("/")
def index():
return render_template("index.html")
@main.route("/rennen_starten")
def rennen_starten():
with open("config/fahrer.json") as f:
fahrer = json.load(f)
return render_template("rennen_starten.html", fahrer=fahrer)
@main.route("/countdown_starten", methods=["POST"])
def countdown_starten():
countdown.start()
return redirect(url_for("main.rennansicht"))
@main.route("/rennansicht")
def rennansicht():
with open("config/fahrer.json") as f:
fahrer = json.load(f)
return render_template("rennbildschirm.html", fahrer=fahrer)
@main.route("/fahrerverwaltung")
def fahrerverwaltung():
with open("config/fahrer.json") as f:
fahrer = json.load(f)
return render_template("fahrerverwaltung.html", fahrer=fahrer)
@main.route("/fahrerverwaltung/speichern", methods=["POST"])
def fahrer_speichern():
data = request.get_json()
with open("config/fahrer.json", "w") as f:
json.dump(data, f, indent=2)
return jsonify({"status": "ok"})
@main.route("/admin")
def admin():
return render_template("admin.html")
@main.route("/zeit_zuordnen")
def zeit_zuordnen():
with open("data/zeiten.json") as f:
zeiten = json.load(f)
with open("config/fahrer.json") as f:
fahrer = json.load(f)
return render_template("zeit_zuordnen.html", zeiten=zeiten, fahrer=fahrer)
@main.route("/zuordnung/speichern", methods=["POST"])
def zuordnung_speichern():
data = request.get_json()
with open("data/zuordnung.json", "w") as f:
json.dump(data, f, indent=2)
return jsonify({"status": "ok"})
@main.route("/ergebnis")
def ergebnis():
zuordnung_path = "data/zuordnung.json"
fahrer_path = "config/fahrer.json"
with open(fahrer_path) as f:
fahrer_list = json.load(f)
if os.path.exists(zuordnung_path):
with open(zuordnung_path) as f:
zuordnungen = json.load(f)
else:
zuordnungen = []
fahrer_dict = {str(f["nummer"]): {"name": f["name"], "zeiten": [], "farbe": f["farbe"]} for f in fahrer_list}
for eintrag in zuordnungen:
fahrer_id = str(eintrag["fahrer"])
zeit = float(eintrag["zeit"])
if fahrer_id in fahrer_dict:
fahrer_dict[fahrer_id]["zeiten"].append(zeit)
ergebnisse = []
for nummer, daten in fahrer_dict.items():
zeiten = daten["zeiten"]
if zeiten:
beste = min(zeiten)
durchschnitt = sum(zeiten) / len(zeiten)
gesamt = sum(zeiten)
ergebnisse.append({
"nummer": nummer,
"name": daten["name"],
"farbe": daten["farbe"],
"beste": f"{beste:.3f}",
"durchschnitt": f"{durchschnitt:.3f}",
"gesamt": f"{gesamt:.3f}"
})
return render_template("ergebnis.html", ergebnisse=ergebnisse)

102
app/static/css/styles.css Normal file
View file

@ -0,0 +1,102 @@
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f7f7f7;
color: #333;
}
h1, h2, h3 {
color: #222;
}
form {
margin-bottom: 20px;
}
input, select, button {
padding: 8px;
margin: 5px;
font-size: 1em;
}
button {
border: none;
border-radius: 6px;
padding: 10px 20px;
cursor: pointer;
}
button.green {
background-color: #4CAF50;
color: white;
}
button.red {
background-color: #e53935;
color: white;
}
button.orange {
background-color: orange;
color: white;
}
.fahrer-eintrag {
margin: 5px 0;
}
.licht {
display: inline-block;
width: 80px;
height: 80px;
margin: 10px;
border-radius: 50%;
background-color: grey;
opacity: 0.4;
}
.licht.rot.aktiv {
background-color: red;
opacity: 1;
}
.licht.gruen {
background-color: green !important;
opacity: 1 !important;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 10, 0.9);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: white;
font-size: 2em;
}
.ampel-klein {
display: flex;
justify-content: center;
margin: 10px auto;
height: 100px;
}
.ergebnis {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.fahrerbox {
padding: 20px;
border-radius: 10px;
color: white;
flex: 1 1 200px;
}

30
app/static/js/admin.js Normal file
View file

@ -0,0 +1,30 @@
function systemCommand(cmd) {
fetch(`/admin/${cmd}`, { method: "POST" })
.then(res => res.text())
.then(alert);
}
function fetchLogs() {
fetch("/admin/logs")
.then(res => res.text())
.then(data => {
document.getElementById("logs").innerText = data;
});
}
document.getElementById("adminForm").addEventListener("submit", function(e) {
e.preventDefault();
const data = new FormData(e.target);
const json = {};
for (let [k, v] of data.entries()) {
json[k] = v;
}
json["ziel_aktiviert"] = data.get("ziel_aktiviert") === "on";
fetch("/admin/save", {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json)
}).then(res => res.text())
.then(alert);
});

17
app/static/js/fahrer.js Normal file
View file

@ -0,0 +1,17 @@
function addFahrer() {
const fahrerListe = document.getElementById("fahrerListe");
const index = fahrerListe.children.length + 1;
const farben = ["#ff0000", "#00ff00", "#0000ff", "#ffa500"];
const neueFarbe = farben[index % farben.length];
const div = document.createElement("div");
div.className = "fahrer-eintrag";
div.innerHTML = `
<label>Fahrer ${index}:
<input type="text" name="name" value="Fahrer ${index}">
<input type="number" name="nummer" value="${10 + index}">
<input type="color" name="farbe" value="${neueFarbe}">
</label>
`;
fahrerListe.appendChild(div);
}

31
app/static/js/main.js Normal file
View file

@ -0,0 +1,31 @@
function startCountdown() {
const beep = new Audio("/static/sounds/beep.wav");
const go = new Audio("/static/sounds/go.wav");
setTimeout(() => {
document.getElementById("licht1").classList.add("aktiv");
beep.play();
}, 1000);
setTimeout(() => {
document.getElementById("licht2").classList.add("aktiv");
beep.play();
}, 2000);
setTimeout(() => {
document.getElementById("licht3").classList.add("aktiv");
beep.play();
}, 3000);
setTimeout(() => {
document.querySelectorAll(".licht").forEach(el => {
el.classList.remove("rot");
el.classList.add("gruen");
});
go.play();
}, 4000);
setTimeout(() => {
window.location.href = "/rennen";
}, 7000);
}

20
app/static/js/zuordnen.js Normal file
View file

@ -0,0 +1,20 @@
document.getElementById("zuordnungForm").addEventListener("submit", function(e) {
e.preventDefault();
const zuordnungen = [];
document.querySelectorAll(".zuordnung").forEach(row => {
const zeit = row.querySelector("span").innerText;
const fahrer = row.querySelector("select").value;
if (fahrer) {
zuordnungen.push({ zeit: zeit, fahrer: fahrer });
}
});
fetch("/zeit_zuordnen", {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zuordnungen: zuordnungen })
}).then(() => {
window.location.href = "/ergebnis";
});
});

BIN
app/static/sounds/beep.wav Normal file

Binary file not shown.

BIN
app/static/sounds/go.wav Normal file

Binary file not shown.

37
app/templates/admin.html Normal file
View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Admin</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
</head>
<body>
<h2>Admin-Einstellungen</h2>
<form id="adminForm">
<label>
Startlichtschranke GPIO:
<input type="number" name="start_pin" value="17">
</label><br>
<label>
Zielllichtschranke GPIO:
<input type="number" name="ziel_pin" value="27">
</label><br>
<label>
Schutzzeit (Sekunden):
<input type="number" name="schutzzeit" value="3">
</label><br>
<label>
Ziellichtschranke aktiv:
<input type="checkbox" name="ziel_aktiviert" checked>
</label><br><br>
<button type="submit" class="green">Speichern</button>
</form>
<hr>
<h3>Systemsteuerung</h3>
<button onclick="systemCommand('restart')">Dienst neustarten</button>
<button onclick="systemCommand('reboot')">Neu starten</button>
<button onclick="systemCommand('shutdown')">Herunterfahren</button>
<button onclick="fetchLogs()">Live-Logs anzeigen</button>
<pre id="logs"></pre>
</body>
</html>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Countdown</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body class="countdown">
<div class="ampel-overlay">
<div class="licht rot" id="licht1"></div>
<div class="licht rot" id="licht2"></div>
<div class="licht rot" id="licht3"></div>
</div>
<script>
startCountdown();
</script>
</body>
</html>

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<title>Ergebnis</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<h1>🏁 Ergebnis</h1>
<div class="ergebnis">
{% for e in ergebnisse %}
<div class="fahrerbox" style="background-color: {{ e.farbe }}">
<h3>#{{ e.nummer }} - {{ e.name }}</h3>
<p>Beste Zeit: {{ e.beste }} s</p>
<p>Durchschnitt: {{ e.durchschnitt }} s</p>
<p>Gesamtzeit: {{ e.gesamt }} s</p>
</div>
{% endfor %}
</div>
</body>
</html>

View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Fahrerverwaltung</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/fahrer.js') }}"></script>
</head>
<body>
<h2>Fahrer verwalten</h2>
<form id="fahrerForm">
<div id="fahrerListe">
{% for f in fahrer %}
<div class="fahrer-eintrag">
<label>Fahrer {{ loop.index }}:
<input type="text" name="name" value="{{ f.name }}">
<input type="number" name="nummer" value="{{ f.nummer }}">
<input type="color" name="farbe" value="{{ f.farbe }}">
</label>
</div>
{% endfor %}
</div>
<button type="button" onclick="addFahrer()">+ Fahrer hinzufügen</button>
<button type="submit" class="green">Speichern</button>
</form>
<a href="{{ url_for('main.index') }}">Zurück</a>
</body>
</html>

15
app/templates/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Rennstoppuhr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<h1>Rennstoppuhr</h1>
<div class="menu">
<a href="{{ url_for('main.rennen_starten') }}">🏁 Rennen starten</a>
<a href="{{ url_for('main.fahrerverwaltung') }}">👤 Fahrerverwaltung</a>
<a href="{{ url_for('main.admin') }}">⚙️ Einstellungen (Admin)</a>
</div>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Rennbildschirm</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<div class="kleine-ampel"></div>
<div class="zeitinfo">
<h3>Aktuelle Runde: <span id="aktuell">--.--</span></h3>
<p>Beste Runde: <span id="beste">--.--</span></p>
<p>Durchschnitt: <span id="schnitt">--.--</span></p>
</div>
<div class="rundenstand">
Runde <span id="runde">1</span> / <span id="gesamt">3</span>
</div>
<div class="fahrer">
{% for f in fahrer %}
<div class="fahrerbox" style="background-color: {{ f.farbe }}">
#{{ f.nummer }} - {{ f.name }}
</div>
{% endfor %}
</div>
<div class="aktionen">
<button class="gelb">Safety</button>
<button class="rot">Unterbrechen</button>
</div>
</body>
</html>

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Rennen starten</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<h2>Rennen starten</h2>
<form method="POST" action="{{ url_for('main.countdown_starten') }}">
<label>Anzahl Runden:
<input type="number" name="runden" min="1" value="1">
</label>
<button type="submit" class="green">Countdown starten</button>
</form>
<p><a href="{{ url_for('main.fahrerverwaltung') }}">Zur Fahrerverwaltung</a></p>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<title>Zeit zuordnen</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script src="{{ url_for('static', filename='js/zuordnen.js') }}"></script>
</head>
<body>
<h2>Zeit zuordnen</h2>
<form id="zuordnungForm">
{% for z in zeiten %}
<div class="zuordnung">
<span>{{ z.zeit }}</span>
<select name="fahrer">
<option value="">-- Fahrer wählen --</option>
{% for f in fahrer %}
<option value="{{ f.nummer }}">#{{ f.nummer }} - {{ f.name }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
<button type="submit" class="green">Speichern</button>
</form>
</body>
</html>

17
app/utils/countdown.py Normal file
View file

@ -0,0 +1,17 @@
import time
import threading
import pygame
def play_sound(path):
pygame.mixer.init()
sound = pygame.mixer.Sound(path)
sound.play()
time.sleep(sound.get_length())
def start():
def countdown_thread():
for i in range(3):
play_sound("app/static/sounds/beep.wav")
time.sleep(1)
play_sound("app/static/sounds/go.wav")
threading.Thread(target=countdown_thread).start()

21
app/utils/gpio_handler.py Normal file
View file

@ -0,0 +1,21 @@
from gpiozero import Button
from signal import pause
import time
class Lichtschranke:
def __init__(self, gpio_pin, callback, schutzzeit=3):
self.pin = gpio_pin
self.callback = callback
self.last_trigger = 0
self.button = Button(gpio_pin, pull_up=False, bounce_time=0.05)
self.button.when_pressed = self._ausgeloest
self.schutzzeit = schutzzeit
def _ausgeloest(self):
jetzt = time.time()
if jetzt - self.last_trigger > self.schutzzeit:
self.last_trigger = jetzt
self.callback()
def stop(self):
self.button.close()

1
app/utils/init.py Normal file
View file

@ -0,0 +1 @@
# leer

14
app/utils/system_tools.py Normal file
View file

@ -0,0 +1,14 @@
import subprocess
def reboot():
subprocess.run(["sudo", "reboot"])
def shutdown():
subprocess.run(["sudo", "shutdown", "now"])
def restart_service():
subprocess.run(["sudo", "systemctl", "restart", "rennstopuhr.service"])
def get_logs():
result = subprocess.run(["journalctl", "-u", "rennstopuhr.service", "--no-pager"], capture_output=True, text=True)
return result.stdout

22
app/utils/zeitmessung.py Normal file
View file

@ -0,0 +1,22 @@
import time
class Zeitmesser:
def __init__(self):
self.startzeit = None
self.runden = []
def start(self):
self.startzeit = time.perf_counter()
def runde(self):
if self.startzeit is None:
return None
jetzt = time.perf_counter()
rundenzeit = jetzt - self.startzeit
self.runden.append(rundenzeit)
self.startzeit = jetzt
return rundenzeit
def reset(self):
self.startzeit = None
self.runden = []

27
install.sh Normal file
View file

@ -0,0 +1,27 @@
#!/bin/bash
echo "📦 Projekt wird installiert..."
# System aktualisieren
sudo apt update && sudo apt upgrade -y
sudo apt install python3-venv git -y
# Projekt klonen
git clone https://github.com/DEIN_GIT_REPO/pi-race-timer.git /home/pi/pi-race-timer
cd /home/pi/pi-race-timer
# Virtuelle Umgebung einrichten
python3 -m venv venv
source venv/bin/activate
# Python-Abhängigkeiten installieren
pip install -U pip
pip install -r requirements.txt
# systemd-Dienst einrichten
sudo cp pi-race-timer.service /etc/systemd/system/
sudo systemctl daemon-reexec
sudo systemctl enable pi-race-timer
sudo systemctl start pi-race-timer
echo "✅ Installation abgeschlossen. Webinterface erreichbar unter: http://<raspberry-ip>:5000"

12
pi-race-timer.service Normal file
View file

@ -0,0 +1,12 @@
[Unit]
Description=Race Timer Webserver
After=network.target
[Service]
ExecStart=/home/pi/pi-race-timer/venv/bin/python3 /home/pi/pi-race-timer/main.py
WorkingDirectory=/home/pi/pi-race-timer
Restart=always
User=pi
[Install]
WantedBy=multi-user.target

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
setuptools
wheel
Flask==2.3.3
gpiozero==1.6.2

6
run.py Normal file
View file

@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)