first commit

This commit is contained in:
Carsten Graf
2025-06-01 11:51:02 +02:00
commit 2d2ee0a41a
22 changed files with 3241 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

15
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"files.associations": {
"functional": "cpp",
"array": "cpp",
"deque": "cpp",
"list": "cpp",
"string": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"vector": "cpp",
"string_view": "cpp",
"initializer_list": "cpp",
"regex": "cpp"
}
}

18
TODO.md Normal file
View File

@@ -0,0 +1,18 @@
- KOMPLETTES Messageing System auf MQTT Server und Broker umstellen.
- Testen wie zuverlässig das ist
- message system überarbeiten. Sehr unzuverlässig mit dem peering
- Uhrzeit abfragen (Eingabe der Zeitzone) DONE
- Sync der Buttons mit echtzeit
- implementierung einer RTC
v2.0
- ADD Hotspot Manager to connect to a Wifi
- ADD Licence Management (generate a programm where i can generate keys that get checked agains a private seed in the firmware)
enables the Wifimanager to connect DONE
- ADD option point for location (read from online table and select the location via dropdown)
- ADD option to enter a name, age
- ADD upload to a Online Database ()

47
apientpoints Normal file
View File

@@ -0,0 +1,47 @@
API-Routen Übersicht
GET /api/data
→ Gibt den aktuellen Timer-Status und Zeiten zurück
POST /api/reset-best
→ Setzt die besten Zeiten zurück
POST /api/unlearn-button
→ Verlernt alle Button-Zuordnungen
POST /api/set-max-time
→ Setzt die maximale Zeit und maxTimeDisplay
GET /api/get-settings
→ Gibt die aktuellen Einstellungen zurück
POST /api/start-learning
→ Startet den Anlernmodus
POST /api/stop-learning
→ Beendet den Anlernmodus
GET /api/learn/status
→ Gibt den Status des Anlernmodus zurück
GET /api/buttons/status
→ Gibt den Status der Button-Zuordnungen zurück
GET /api/info
→ Systeminformationen (IP, MAC, Speicher, verbundene Buttons)
(aus timesync.h)
GET /api/time
→ Gibt die aktuelle Systemzeit zurück
POST /api/set-time
→ Setzt die Systemzeit
(aus licenceing.h)
GET /api/get-licence
→ Gibt den gespeicherten Lizenzschlüssel zurück
POST /api/set-licence
→ Speichert einen neuen Lizenzschlüssel
Statische Dateien:
/ → index.html
/settings → settings.html

480
data/about.html Normal file
View File

@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Über NinjaCross Timer</title>
<style>
html {
overflow-x: hidden;
min-height: 100%;
width: 100%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Arial", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
color: white;
position: relative;
box-sizing: border-box;
}
.back-btn {
position: fixed;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 12px 20px;
border-radius: 25px;
text-decoration: none;
font-size: 1rem;
transition: all 0.3s ease;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-2px);
}
.header {
text-align: center;
margin-bottom: 3vh;
margin-top: 4vh;
flex-shrink: 0;
}
.header h1 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 1vh;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.header p {
font-size: clamp(1rem, 2vw, 1.3rem);
opacity: 0.9;
}
.content-container {
width: 100%;
max-width: 1000px;
display: flex;
flex-direction: column;
gap: 2rem;
padding: 0 2vw;
}
.content-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: clamp(20px, 3vh, 30px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.content-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
}
.content-card h2 {
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
margin-bottom: 1rem;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.content-card h3 {
font-size: clamp(1.1rem, 2vw, 1.4rem);
margin: 1.5rem 0 0.8rem 0;
color: #ffc107;
}
.content-card p {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
line-height: 1.6;
margin-bottom: 1rem;
opacity: 0.95;
}
.content-card ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.content-card li {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
line-height: 1.6;
margin-bottom: 0.5rem;
opacity: 0.95;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}
.feature-item {
background: rgba(255, 255, 255, 0.1);
padding: 1.5rem;
border-radius: 15px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.feature-item h4 {
font-size: clamp(1rem, 2vw, 1.2rem);
margin-bottom: 0.8rem;
color: #3498db;
}
.feature-item p {
font-size: clamp(0.85rem, 1.6vw, 1rem);
opacity: 0.9;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
background: rgba(46, 204, 113, 0.2);
border: 2px solid #2ecc71;
padding: 1.5rem;
border-radius: 15px;
text-align: center;
}
.stat-number {
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-weight: bold;
color: #2ecc71;
display: block;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: clamp(0.8rem, 1.5vw, 1rem);
opacity: 0.9;
}
.team-section {
text-align: center;
}
.team-member {
background: rgba(155, 89, 182, 0.2);
border: 2px solid #9b59b6;
padding: 1.5rem;
border-radius: 15px;
margin: 1rem 0;
}
.team-member h4 {
font-size: clamp(1.1rem, 2vw, 1.4rem);
color: #9b59b6;
margin-bottom: 0.5rem;
}
.team-member p {
font-size: clamp(0.9rem, 1.6vw, 1rem);
opacity: 0.9;
}
.cta-section {
background: rgba(52, 152, 219, 0.2);
border: 2px solid #3498db;
text-align: center;
position: relative;
overflow: hidden;
}
.cta-section::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.1), transparent);
animation: shine 3s infinite;
}
@keyframes shine {
0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); }
50% { transform: translateX(100%) translateY(100%) rotate(45deg); }
100% { transform: translateX(-100%) translateY(-100%) rotate(45deg); }
}
.cta-content {
position: relative;
z-index: 1;
}
.cta-btn {
display: inline-block;
background: #3498db;
color: white;
padding: 12px 30px;
border-radius: 25px;
text-decoration: none;
font-weight: bold;
margin-top: 1rem;
transition: all 0.3s ease;
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
}
.cta-btn:hover {
background: #2980b9;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
}
@media (max-width: 768px) {
.logo {
width: 40px;
height: 40px;
top: 10px;
left: 10px;
}
.back-btn {
top: 10px;
right: 10px;
padding: 8px 15px;
font-size: 0.9rem;
}
.content-container {
padding: 0 3vw;
gap: 1.5rem;
}
.feature-grid {
grid-template-columns: 1fr;
}
body {
padding: 10px;
}
}
@media (max-width: 480px) {
.logo {
width: 35px;
height: 35px;
top: 8px;
left: 8px;
}
.back-btn {
top: 8px;
right: 8px;
padding: 6px 12px;
font-size: 0.8rem;
}
.content-container {
padding: 0 2vw;
}
body {
padding: 8px;
}
}
</style>
</head>
<body>
<a href="/" class="back-btn">
← Zurück zum Timer
</a>
<div class="header">
<h1>🏊‍♀️ Über NinjaCross Timer</h1>
<p>Der professionelle Zeitmesser für Ninjacross Wettkämpfe</p>
</div>
<div class="content-container">
<div class="content-card">
<h2>🎯 Was ist NinjaCross?</h2>
<p>
NinjaCross ist ein aufregender Wassersport, der Geschwindigkeit, Technik und Athletik kombiniert.
Teilnehmer durchqueren Schwimmbahnen mit verschiedenen Hindernissen und Herausforderungen,
wobei Zeit und Präzision entscheidend sind.
</p>
<p>
Unser Timer-System wurde speziell entwickelt, um professionelle Wettkämpfe zu unterstützen
und präzise Zeitmessungen für bis zu zwei Bahnen gleichzeitig zu ermöglichen.
</p>
</div>
<div class="content-card">
<h2>⚡ Funktionen</h2>
<div class="feature-grid">
<div class="feature-item">
<h4>🎲 Dual-Timer</h4>
<p>Gleichzeitige Zeitmessung für zwei Bahnen mit präziser Synchronisation</p>
</div>
<div class="feature-item">
<h4>📱 Responsive Design</h4>
<p>Optimiert für alle Geräte - Desktop, Tablet und Smartphone</p>
</div>
<div class="feature-item">
<h4>🏆 Bestzeiten</h4>
<p>Automatische Verfolgung und Anzeige der besten Tageszeiten</p>
</div>
<div class="feature-item">
<h4>📚 Lernmodus</h4>
<p>Interaktiver Modus für Training und Schulungszwecke</p>
</div>
<div class="feature-item">
<h4>⚙️ Einfache Bedienung</h4>
<p>Intuitive Benutzeroberfläche für schnelle und fehlerfreie Bedienung</p>
</div>
<div class="feature-item">
<h4>🔄 Live-Sync</h4>
<p>Echtzeitaktualisierung aller Timer-Daten über Backend-Integration</p>
</div>
</div>
</div>
<div class="content-card">
<h2>📊 Technische Spezifikationen</h2>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-number">0.01s</span>
<span class="stat-label">Präzision</span>
</div>
<div class="stat-item">
<span class="stat-number">2</span>
<span class="stat-label">Bahnen</span>
</div>
<div class="stat-item">
<span class="stat-number">50ms</span>
<span class="stat-label">Update-Rate</span>
</div>
<div class="stat-item">
<span class="stat-number">100%</span>
<span class="stat-label">Responsive</span>
</div>
</div>
<h3>🔧 Technologie-Stack</h3>
<ul>
<li><strong>Frontend:</strong> HTML5, CSS3, Vanilla JavaScript</li>
<li><strong>Design:</strong> Responsive Grid Layout, Glassmorphism</li>
<li><strong>Performance:</strong> Optimierte Render-Zyklen, Smooth Animations</li>
<li><strong>Kompatibilität:</strong> Alle modernen Browser, Mobile-First</li>
</ul>
</div>
<div class="content-card team-section">
<h2>👥 Entwicklung</h2>
<div class="team-member">
<h4>🚀 Entwickelt mit ❤️ von Carsten Graf</h4>
<p>
Dieses Projekt wurde mit Leidenschaft für den NinjaCross-Sport entwickelt,
um Wettkämpfe professioneller und spannender zu gestalten.
</p>
</div>
</div>
<div class="content-card">
<h2>🎮 Bedienung</h2>
<h3>Grundfunktionen</h3>
<ul>
<li><strong>Timer starten:</strong> Automatische Synchronisation mit Backend-System</li>
<li><strong>Live-Anzeige:</strong> Echtzeitaktualisierung aller Zeiten und Status</li>
<li><strong>Bestzeiten:</strong> Automatische Speicherung der Tagesrekorde</li>
<li><strong>Lernmodus:</strong> Interaktive Anweisungen für neue Benutzer</li>
</ul>
<h3>Status-Anzeigen</h3>
<ul>
<li><strong>Bereit (Blau):</strong> Timer ist startbereit</li>
<li><strong>Läuft (Grün):</strong> Aktive Zeitmessung mit Pulsation</li>
<li><strong>Beendet (Rot):</strong> Zeitmessung abgeschlossen</li>
</ul>
</div>
<div class="content-card cta-section">
<div class="cta-content">
<h2>🏁 Bereit für den Wettkampf?</h2>
<p>
Starten Sie jetzt mit dem professionellen NinjaCross Timer
und erleben Sie präzise Zeitmessung auf höchstem Niveau!
</p>
<a href="/" class="cta-btn">Timer starten 🚀</a>
</div>
</div>
</div>
<script>
// Smooth scroll animations
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.style.opacity = '1';
entry.target.style.transform = 'translateY(0)';
}
});
}, observerOptions);
// Initialize animations
document.addEventListener('DOMContentLoaded', () => {
const cards = document.querySelectorAll('.content-card');
cards.forEach(card => {
card.style.opacity = '0';
card.style.transform = 'translateY(30px)';
card.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
observer.observe(card);
});
});
// Add click effects
document.querySelectorAll('.content-card').forEach(card => {
card.addEventListener('click', function() {
this.style.transform = 'translateY(-5px) scale(1.02)';
setTimeout(() => {
this.style.transform = 'translateY(-5px) scale(1)';
}, 150);
});
});
</script>
</body>
</html>

453
data/index.html Normal file
View File

@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NinjaCross Timer</title>
<style>
html {
overflow: hidden;
height: 100%;
width: 100%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Arial", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
color: white;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.logo {
position: fixed;
top: 20px;
left: 20px;
width: 200px;
height: 60px;
z-index: 1000;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: transform 0.3s ease;
text-decoration: none;
display: block;
cursor: pointer;
padding-left: 5px;
padding-right: 5px;
}
.logo:hover {
transform: scale(1.1);
}
.logo img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10px;
}
.settings-btn {
position: fixed;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px;
border-radius: 50%;
text-decoration: none;
font-size: 1.5rem;
transition: all 0.3s ease;
z-index: 1000;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.settings-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.header {
text-align: center;
margin-bottom: 2vh;
flex-shrink: 0;
}
.header h1 {
font-size: clamp(1.8rem, 4vw, 2.5rem);
margin-bottom: 0.5vh;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.header p {
font-size: clamp(0.8rem, 1.8vw, 1rem);
}
.timer-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(15px, 2vw, 30px);
width: 100%;
max-width: 100%;
flex-grow: 1;
padding: 0 2vw;
max-height: 60vh;
}
@media (max-width: 768px) {
.timer-container {
grid-template-columns: 1fr;
gap: clamp(15px, 3vw, 30px);
padding: 0 15px;
}
}
@media (max-width: 480px) {
.timer-container {
gap: 20px;
padding: 0 10px;
}
}
@media (min-width: 1400px) {
.timer-container {
max-width: 1400px;
padding: 0 60px;
}
}
.lane {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: clamp(15px, 2.5vh, 25px);
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
overflow: hidden;
}
.lane h2 {
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
margin-bottom: clamp(10px, 1vh, 15px);
color: #fff;
}
.time-display {
font-size: clamp(3rem, 9vw, 10rem);
font-weight: bold;
margin: clamp(10px, 1vh, 15px) 0;
font-family: "Courier New", monospace;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1;
}
.status {
font-size: clamp(3rem, 1.8vw, 1.2rem);
margin: clamp(8px, 1vh, 12px) 0;
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
border-radius: 20px;
display: inline-block;
font-weight: 600;
}
.status.ready {
background-color: rgba(52, 152, 219, 0.3);
border: 2px solid #3498db;
}
.status.running {
background-color: rgba(46, 204, 113, 0.3);
border: 2px solid #2ecc71;
animation: pulse 1s infinite;
}
.status.finished {
background-color: rgba(231, 76, 60, 0.3);
border: 2px solid #e74c3c;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
.best-times {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: clamp(10px, 1.5vh, 15px);
margin: 1vh 0 0 0;
width: 50%;
max-width: 50%;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
align-self: center;
}
.best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
margin-bottom: clamp(5px, 0.5vh, 8px);
}
.best-time-row {
display: flex;
justify-content: space-between;
margin: clamp(3px, 0.5vh, 5px) 0;
font-size: clamp(0.8rem, 1.5vw, 1rem);
font-weight: 500;
background: rgba(255, 255, 255, 0.1);
padding: clamp(5px, 1vh, 8px) clamp(8px, 1.5vw, 12px);
border-radius: 8px;
}
.learning-mode {
background: rgba(255, 193, 7, 0.2);
border: 2px solid #ffc107;
border-radius: 15px;
padding: clamp(15px, 2vh, 20px);
margin: 2vh 0;
text-align: center;
animation: pulse 2s infinite;
width: 100%;
position: absolute;
top: 15vh;
left: 50%;
transform: translateX(-50%);
z-index: 100;
}
.learning-mode h3 {
color: #ffc107;
margin-bottom: 10px;
font-size: clamp(1rem, 2vw, 1.3rem);
}
.learning-mode p {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
}
@media (max-width: 768px) {
.timer-container {
grid-template-columns: 1fr;
gap: clamp(10px, 2vh, 15px);
padding: 0 3vw;
max-height: 55vh;
}
.settings-btn {
top: 10px;
right: 10px;
width: 40px;
height: 40px;
font-size: 1rem;
padding: 8px;
}
.header h1 {
font-size: clamp(1.5rem, 4vw, 2rem);
}
.header p {
font-size: clamp(0.7rem, 2vw, 0.9rem);
}
body {
padding: 10px;
}
}
@media (max-width: 480px) {
.settings-btn {
top: 8px;
right: 8px;
width: 35px;
height: 35px;
font-size: 0.9rem;
padding: 6px;
}
body {
padding: 8px;
}
.timer-container {
padding: 0 2vw;
}
}
</style>
</head>
<body>
<a href="/about" class="logo" title="Über NinjaCross Timer">
<img src="logo.png" alt="NinjaCross Logo" />
</a>
<a href="/settings" class="settings-btn">⚙️</a>
<div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
</div>
<div id="learning-display" class="learning-mode" style="display: none">
<h3>📚 Lernmodus aktiv</h3>
<p>
Bitte drücken Sie den Button für: <span id="learning-button"></span>
</p>
</div>
<div class="timer-container">
<div class="lane">
<h2>🏊‍♀️ Bahn 1</h2>
<div id="time1" class="time-display">00.00</div>
<div id="status1" class="status ready">Bereit</div>
</div>
<div class="lane">
<h2>🏊‍♂️ Bahn 2</h2>
<div id="time2" class="time-display">00.00</div>
<div id="status2" class="status ready">Bereit</div>
</div>
</div>
<div class="best-times">
<h3>🏆 Beste Zeiten des Tages</h3>
<div class="best-time-row">
<span>Bahn 1:</span>
<span id="best1">--.-</span>
</div>
<div class="best-time-row">
<span>Bahn 2:</span>
<span id="best2">--.-</span>
</div>
</div>
<script>
// State variables
let timer1 = 0;
let timer2 = 0;
let status1 = "ready";
let status2 = "ready";
let best1 = 0;
let best2 = 0;
let lastSync = Date.now();
let learningMode = false;
let learningButton = "";
function formatTime(seconds) {
if (seconds === 0) return "00.00";
return seconds.toFixed(2);
}
function updateDisplay() {
// Calculate elapsed time since last sync
const now = Date.now();
let display1 = timer1;
let display2 = timer2;
if (status1 === "running") {
display1 += (now - lastSync) / 1000;
}
if (status2 === "running") {
display2 += (now - lastSync) / 1000;
}
document.getElementById("time1").textContent = formatTime(display1);
const s1 = document.getElementById("status1");
s1.className = `status ${status1}`;
s1.textContent =
status1 === "ready"
? "Bereit"
: status1 === "running"
? "Läuft..."
: "Beendet";
document.getElementById("time2").textContent = formatTime(display2);
const s2 = document.getElementById("status2");
s2.className = `status ${status2}`;
s2.textContent =
status2 === "ready"
? "Bereit"
: status2 === "running"
? "Läuft..."
: "Beendet";
document.getElementById("best1").textContent =
best1 > 0 ? formatTime(best1) + "s" : "--.-";
document.getElementById("best2").textContent =
best2 > 0 ? formatTime(best2) + "s" : "--.-";
// Lernmodus
const learningDisplay = document.getElementById("learning-display");
if (learningMode) {
document.getElementById("learning-button").textContent = learningButton;
learningDisplay.style.display = "block";
} else {
learningDisplay.style.display = "none";
}
}
function syncFromBackend() {
fetch("/api/data")
.then((response) => response.json())
.then((data) => {
timer1 = data.time1;
timer2 = data.time2;
status1 = data.status1;
status2 = data.status2;
best1 = data.best1;
best2 = data.best2;
learningMode = data.learningMode;
learningButton = data.learningButton || "";
lastSync = Date.now();
updateDisplay();
})
.catch((error) =>
console.error("Fehler beim Laden der Daten:", error)
);
}
// Sync with backend every 2 seconds
setInterval(syncFromBackend, 2000);
// Smooth update every 50ms
setInterval(updateDisplay, 50);
// Initial load
syncFromBackend();
</script>
</body>
</html>

BIN
data/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

998
data/settings.html Normal file
View File

@@ -0,0 +1,998 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ninjacross Timer - Einstellungen</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(10px);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="40%" r="50%"><stop offset="0%" stop-color="white" stop-opacity="0.1"/><stop offset="100%" stop-color="white" stop-opacity="0"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
opacity: 0.3;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
position: relative;
z-index: 1;
}
.content {
padding: 40px;
}
.nav-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.nav-button {
flex: 1;
padding: 12px 20px;
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px;
color: #495057;
text-decoration: none;
text-align: center;
font-weight: 600;
transition: all 0.3s ease;
}
.nav-button:hover {
background: #667eea;
color: white;
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.section {
margin-bottom: 30px;
background: #f8f9fa;
border-radius: 15px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
.section h2 {
color: #495057;
margin-bottom: 20px;
font-size: 1.4em;
display: flex;
align-items: center;
gap: 10px;
}
.section h2::before {
content: "";
width: 4px;
height: 25px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 2px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 10px;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.time-row {
display: flex;
gap: 15px;
align-items: end;
}
.time-input {
flex: 1;
}
.current-time {
background: white;
padding: 15px;
border-radius: 10px;
text-align: center;
font-family: monospace;
font-size: 18px;
color: #495057;
border: 2px solid #e9ecef;
margin-bottom: 15px;
}
.button-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.btn {
padding: 15px 25px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
min-width: 150px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(116, 185, 255, 0.3);
}
.btn-warning {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
color: #d84315;
}
.btn-warning:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(252, 182, 159, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
color: #c62828;
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 154, 158, 0.3);
}
.btn-disabled {
background: #e9ecef !important;
color: #6c757d !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
.btn-disabled:hover {
transform: none !important;
box-shadow: none !important;
}
.restriction-notice {
background: #fff3cd;
color: #856404;
padding: 15px;
border-radius: 10px;
border: 2px solid #ffeaa7;
margin-bottom: 15px;
font-weight: 600;
text-align: center;
}
.status-message {
margin-top: 20px;
padding: 15px;
border-radius: 10px;
font-weight: 600;
text-align: center;
display: none;
}
.status-success {
background: #d4edda;
color: #155724;
border: 2px solid #c3e6cb;
}
.status-error {
background: #f8d7da;
color: #721c24;
border: 2px solid #f5c6cb;
}
.status-info {
background: #cce7ff;
color: #004085;
border: 2px solid #b3d9ff;
}
.learning-mode {
display: none;
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border-radius: 15px;
margin-top: 20px;
}
.learning-mode h3 {
color: #d84315;
font-size: 1.5em;
margin-bottom: 15px;
}
.learning-mode p {
color: #bf360c;
font-size: 1.2em;
margin-bottom: 20px;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.nav-buttons {
flex-direction: column;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
.time-row {
flex-direction: column;
gap: 10px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⏱️ Ninjacross Timer</h1>
<p>Einstellungen & Konfiguration</p>
</div>
<div class="content">
<div class="nav-buttons">
<a href="/" class="nav-button">🏠 Hauptseite</a>
<a
href="/settings"
class="nav-button"
style="background: #667eea; color: white; border-color: #667eea"
>⚙️ Einstellungen</a
>
</div>
<div id="statusMessage" class="status-message"></div>
<div class="section">
<h2>🕐 Datum & Uhrzeit</h2>
<div class="current-time" id="currentTime">
Aktuelle Zeit: Laden...
</div>
<form id="timeForm">
<div class="time-row">
<div class="form-group time-input">
<label for="currentDate">Datum:</label>
<input type="date" id="currentDate" name="currentDate" />
</div>
<div class="form-group time-input">
<label for="currentTimeInput">Uhrzeit:</label>
<input
type="time"
id="currentTimeInput"
name="currentTime"
step="1"
/>
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-secondary">
🕐 Uhrzeit setzen
</button>
<button
type="button"
onclick="syncWithBrowserTime()"
class="btn btn-secondary"
>
💻 Browser-Zeit übernehmen
</button>
</div>
</form>
</div>
<div class="section">
<h2>🔧 Grundeinstellungen</h2>
<form id="settingsForm">
<div class="form-group">
<label for="maxTime">Maximale Zeit (Sekunden):</label>
<input
type="number"
id="maxTime"
name="maxTime"
min="10"
max="3600"
value="120"
title="Zeit nach der eine Bahn automatisch zurückgesetzt wird"
/>
</div>
<div class="form-group">
<label for="maxTimeDisplay"
>Wie lange bleibt die letzte Zeit stehen (Sekunden):</label
>
<input
type="number"
id="maxTimeDisplay"
name="maxTimeDisplay"
min="1"
max="3600"
value="20"
title="Zeit nach der die angezeigte Zeit zurückgesetzt wird"
/>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Einstellungen speichern
</button>
</div>
</form>
</div>
<div class="section">
<h2>🏆 Zeiten verwalten</h2>
<div class="button-group">
<button onclick="resetBestTimes()" class="btn btn-danger">
🔄 Beste Zeiten zurücksetzen
</button>
</div>
</div>
<div class="section">
<h2>📡 Button-Konfiguration</h2>
<div class="button-group">
<button onclick="startLearningMode()" class="btn btn-primary">
🎯 Anlernmodus starten
</button>
<button onclick="resetButtonAssign()" class="btn btn-danger">
❌ Buttons verlernen
</button>
<button onclick="showButtonStatus()" class="btn btn-primary">
📊 Button-Status anzeigen
</button>
</div>
<div id="learningMode" class="learning-mode">
<h3>🎯 Anlernmodus aktiv</h3>
<p id="learningInstruction" class="pulse">
Drücken Sie jetzt den Button für: <strong>Bahn 1 Start</strong>
</p>
<button onclick="cancelLearningMode()" class="btn btn-danger">
❌ Abbrechen
</button>
</div>
</div>
<div class="section">
<h2>🔄 OTA Update</h2>
<div id="otaRestrictionNotice" class="restriction-notice" style="display: none;">
🔒 OTA Updates sind nur mit Lizenz Level 2 oder höher verfügbar. Aktuelle Lizenz: Level <span id="currentLicenseLevel">0</span>
</div>
<div class="button-group">
<button
id="otaUpdateBtn"
onclick="performOTAUpdate()"
class="btn btn-danger"
>
🔄 Update durchführen
</button>
</div>
</div>
<div class="section">
<h2> System-Information</h2>
<div
id="systemInfo"
style="
background: white;
padding: 20px;
border-radius: 10px;
font-family: monospace;
font-size: 14px;
"
>
<div>IP-Adresse: <span id="ipAddress">Laden...</span></div>
<div>Kanal: <span id="channel">Laden...</span></div>
<div>MAC-Adresse: <span id="macAddress">Laden...</span></div>
<div>Freier Speicher: <span id="freeMemory">Laden...</span></div>
<div>Verbundene Buttons: <span id="connectedButtons">Laden...</span></div>
<div>Lizenz gültig: <span id="isLicenceValid">Laden...</span></div>
<div>Lizenz Level: <span id="licenceLevel">Laden...</span></div>
</div>
</div>
<div class="section">
<h2>🔧 Lizenz</h2>
<form id="licenceForm">
<div class="form-group">
<label for="licencekey">Lizensschlüssel:</label>
<input type="text" id="licencekey" name="licence" value="Key" />
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Lizenz speichern
</button>
</div>
</form>
</div>
</div>
</div>
<script>
let learningStep = 0;
const learningSteps = [
"Bahn 1 Start",
"Bahn 1 Stop",
"Bahn 2 Start",
"Bahn 2 Stop",
];
// Einstellungen laden beim Seitenaufruf
window.onload = function () {
loadSettings();
loadSystemInfo();
loadCurrentTime();
updateCurrentTimeDisplay();
loadLicence()
};
// Aktuelle Zeit anzeigen (Live-Update)
function updateCurrentTimeDisplay() {
setInterval(() => {
fetch("/api/time")
.then((response) => response.json())
.then((data) => {
if (data.timestamp) {
const date = new Date(data.timestamp * 1000);
const timeString = date.toLocaleString("de-DE", {
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
document.getElementById(
"currentTime"
).textContent = `System Zeit: ${timeString}`;
} else {
document.getElementById("currentTime").textContent =
"Aktuelle Zeit: Fehler beim Laden";
}
})
.catch((error) => {
document.getElementById("currentTime").textContent =
"Aktuelle Zeit: Fehler beim Laden";
});
}, 1000);
}
// Aktuelle Zeit vom Server laden
function loadCurrentTime() {
fetch("/api/time")
.then((response) => response.json())
.then((data) => {
if (data.timestamp) {
const date = new Date(data.timestamp * 1000);
document.getElementById("currentDate").value = date
.toISOString()
.split("T")[0];
document.getElementById("currentTimeInput").value = date
.toTimeString()
.split(" ")[0];
}
})
.catch((error) => {
console.log(
"Zeit konnte nicht geladen werden, verwende Browser-Zeit"
);
syncWithBrowserTime();
});
}
// Browser-Zeit in die Eingafelder übernehmen
function syncWithBrowserTime() {
const now = new Date();
document.getElementById("currentDate").value = now
.toISOString()
.split("T")[0];
document.getElementById("currentTimeInput").value = now
.toTimeString()
.split(" ")[0];
showMessage("Browser-Zeit übernommen", "info");
// Jetzt auch direkt an den Server senden:
const dateValue = document.getElementById("currentDate").value;
const timeValue = document.getElementById("currentTimeInput").value;
if (!dateValue || !timeValue) {
showMessage("Bitte Datum und Uhrzeit eingeben", "error");
return;
}
const datetime = new Date(`${dateValue}T${timeValue}`);
const timestamp = Math.floor(datetime.getTime() / 1000);
fetch("/api/set-time", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: "timestamp=" + encodeURIComponent(timestamp),
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage("Uhrzeit erfolgreich gesetzt!", "success");
} else {
showMessage("Fehler beim Setzen der Uhrzeit", "error");
}
})
.catch((error) =>
showMessage("Verbindungsfehler beim Setzen der Zeit", "error")
);
}
// Zeit setzen
document.getElementById("timeForm")
.addEventListener("submit", function (e) {
e.preventDefault();
const dateValue = document.getElementById("currentDate").value;
const timeValue = document.getElementById("currentTimeInput").value;
if (!dateValue || !timeValue) {
showMessage("Bitte Datum und Uhrzeit eingeben", "error");
return;
}
const datetime = new Date(`${dateValue}T${timeValue}`);
const timestamp = Math.floor(datetime.getTime() / 1000);
fetch("/api/set-time", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: "timestamp=" + encodeURIComponent(timestamp),
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage("Uhrzeit erfolgreich gesetzt!", "success");
} else {
showMessage("Fehler beim Setzen der Uhrzeit", "error");
}
})
.catch((error) =>
showMessage("Verbindungsfehler beim Setzen der Zeit", "error")
);
});
document.getElementById("licenceForm").addEventListener("submit", function(e) {
e.preventDefault();
saveLicence();
});
// Einstellungen laden
function loadSettings() {
fetch("/api/get-settings")
.then((response) => response.json())
.then((data) => {
document.getElementById("maxTime").value = data.maxTime || 300;
document.getElementById("maxTimeDisplay").value =
data.maxTimeDisplay || 20;
})
.catch((error) =>
showMessage("Fehler beim Laden der Einstellungen", "error")
);
}
// System-Informationen laden
function loadSystemInfo() {
fetch("/api/info")
.then((response) => response.json())
.then((data) => {
document.getElementById("ipAddress").textContent =
data.ip || "Unbekannt";
document.getElementById("channel").textContent =
data.channel || "Unbekannt";
document.getElementById("macAddress").textContent =
data.mac || "Unbekannt";
document.getElementById("freeMemory").textContent =
(data.freeMemory || 0) + " Bytes";
document.getElementById("connectedButtons").textContent =
data.connectedButtons || 0;
document.getElementById("isLicenceValid").textContent =
data.valid || "";
document.getElementById("licenceLevel").textContent =
data.tier || "";
// Check license level and update OTA button accordingly
updateOTAButtonAccess(data.tier || 0);
})
.catch((error) => console.log("Info konnte nicht geladen werden"));
}
function loadLicence() {
fetch("/api/get-licence")
.then((response) => response.json())
.then((data) => {
document.getElementById("licencekey").value = data.licence || "";
})
.catch((error) =>
showMessage("Fehler beim Laden der Lizenz", "error")
);
}
function saveLicence() {
const licence = document.getElementById("licencekey").value;
fetch("/api/set-licence", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: "licence=" + encodeURIComponent(licence),
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage("Lizenz erfolgreich gespeichert!", "success");
// Reload system info to update license level and OTA access
setTimeout(() => {
loadSystemInfo();
}, 1000);
} else {
showMessage("Fehler beim Speichern der Lizenz", "error");
}
})
.catch((error) => showMessage("Verbindungsfehler", "error"));
}
// Update OTA button access based on license level
function updateOTAButtonAccess(licenseLevel) {
const otaButton = document.getElementById("otaUpdateBtn");
const restrictionNotice = document.getElementById("otaRestrictionNotice");
const currentLevelSpan = document.getElementById("currentLicenseLevel");
const level = parseInt(licenseLevel) || 0;
if (level >= 2) {
// License level 2 or higher - enable OTA
otaButton.classList.remove("btn-disabled");
otaButton.disabled = false;
restrictionNotice.style.display = "none";
} else {
// License level below 2 - disable OTA
otaButton.classList.add("btn-disabled");
otaButton.disabled = true;
restrictionNotice.style.display = "block";
currentLevelSpan.textContent = level;
}
}
// OTA Update function with license check
function performOTAUpdate() {
const otaButton = document.getElementById("otaUpdateBtn");
if (otaButton.disabled || otaButton.classList.contains("btn-disabled")) {
showMessage("OTA Update erfordert Lizenz Level 2 oder höher", "error");
return;
}
if (confirm("Möchten Sie wirklich ein OTA Update durchführen? Das Gerät wird während des Updates neu gestartet.")) {
window.location.href = '/update';
}
}
// Einstellungen speichern
document.getElementById("settingsForm")
.addEventListener("submit", function (e) {
e.preventDefault();
const maxTime = parseInt(document.getElementById("maxTime").value);
const maxTimeDisplay = parseInt(
document.getElementById("maxTimeDisplay").value
);
fetch("/api/set-max-time", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body:
"maxTime=" +
encodeURIComponent(maxTime) +
"&maxTimeDisplay=" +
encodeURIComponent(maxTimeDisplay),
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage(
"Einstellungen erfolgreich gespeichert!",
"success"
);
} else {
showMessage("Fehler beim Speichern der Einstellungen", "error");
}
})
.catch((error) => showMessage("Verbindungsfehler", "error"));
});
// Beste Zeiten zurücksetzen
function resetBestTimes() {
if (confirm("Möchten Sie wirklich alle besten Zeiten zurücksetzen?")) {
fetch("/api/reset-best", { method: "POST" })
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage(
"Beste Zeiten erfolgreich zurückgesetzt!",
"success"
);
} else {
showMessage("Fehler beim Zurücksetzen", "error");
}
})
.catch((error) => showMessage("Verbindungsfehler", "error"));
}
}
// Anlernmodus starten
function startLearningMode() {
learningStep = 0;
document.getElementById("learningMode").style.display = "block";
updateLearningInstruction();
fetch("/api/start-learning", { method: "POST" })
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage("Anlernmodus gestartet", "info");
pollLearningStatus();
} else {
showMessage("Fehler beim Starten des Anlernmodus", "error");
cancelLearningMode();
}
})
.catch((error) => {
showMessage("Verbindungsfehler", "error");
cancelLearningMode();
});
}
// Anlernmodus abbrechen
function cancelLearningMode() {
document.getElementById("learningMode").style.display = "none";
fetch("/api/stop-learning", { method: "POST" })
.then((response) => response.json())
.then((data) => {
showMessage("Anlernmodus abgebrochen", "info");
})
.catch((error) => console.log("Fehler beim Abbrechen"));
}
// Anlern-Anweisung aktualisieren
function updateLearningInstruction() {
if (learningStep < learningSteps.length) {
document.getElementById(
"learningInstruction"
).innerHTML = `Drücken Sie jetzt den Button für: <strong>${learningSteps[learningStep]}</strong>`;
}
}
// Anlern-Status abfragen
function pollLearningStatus() {
const pollInterval = setInterval(() => {
fetch("/api/learn/status")
.then((response) => response.json())
.then((data) => {
if (!data.active) {
clearInterval(pollInterval);
document.getElementById("learningMode").style.display = "none";
if (data.completed) {
showMessage("Alle Buttons erfolgreich angelernt!", "success");
}
return;
}
if (data.step !== learningStep) {
learningStep = data.step;
updateLearningInstruction();
if (learningStep > 0) {
showMessage(
`${learningSteps[learningStep - 1]} erfolgreich angelernt!`,
"success"
);
}
}
})
.catch((error) => {
clearInterval(pollInterval);
cancelLearningMode();
});
}, 1000);
}
function resetButtonAssign() {
if (
confirm("Möchten Sie wirklich alle Button-Zuweisungen zurücksetzen?")
) {
fetch("/api/unlearn-button", { method: "POST" })
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage(
"Button-Zuweisungen erfolgreich zurückgesetzt!",
"success"
);
loadSystemInfo();
} else {
showMessage(
"Fehler beim Zurücksetzen der Button-Zuweisungen",
"error"
);
}
})
.catch((error) => showMessage("Verbindungsfehler", "error"));
}
}
// Button-Status anzeigen
function showButtonStatus() {
fetch("/api/buttons/status")
.then((response) => response.json())
.then((data) => {
let statusText = "Button-Status:\n\n";
statusText += `Bahn 1 Start: ${
data.lane1Start ? "Konfiguriert" : "Nicht konfiguriert"
}\n`;
statusText += `Bahn 1 Stop: ${
data.lane1Stop ? "Konfiguriert" : "Nicht konfiguriert"
}\n`;
statusText += `Bahn 2 Start: ${
data.lane2Start ? "Konfiguriert" : "Nicht konfiguriert"
}\n`;
statusText += `Bahn 2 Stop: ${
data.lane2Stop ? "Konfiguriert" : "Nicht konfiguriert"
}\n`;
alert(statusText);
})
.catch((error) =>
showMessage("Fehler beim Laden des Button-Status", "error")
);
}
// Status-Nachricht anzeigen
function showMessage(message, type) {
const statusDiv = document.getElementById("statusMessage");
statusDiv.textContent = message;
statusDiv.className = `status-message status-${type}`;
statusDiv.style.display = "block";
setTimeout(() => {
statusDiv.style.display = "none";
}, 5000);
}
// System-Info alle 30 Sekunden aktualisieren
setInterval(loadSystemInfo, 30000);
</script>
</body>
</html>

37
include/README Normal file
View File

@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html

46
lib/README Normal file
View File

@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html

55
platformio.ini Normal file
View File

@@ -0,0 +1,55 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = esp32dev
[env]
platform = https://github.com/platformio/platform-espressif32.git
framework = arduino
lib_deps = esp32async/ESPAsyncWebServer@^3.7.7
lib_compat_mode = strict
[env:wemos_d1_mini32]
board = wemos_d1_mini32
monitor_speed = 115200
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
lostincompilation/PrettyOTA@^1.1.3
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
[env:wemos_d1_mini32_OTA]
board = wemos_d1_mini32
monitor_speed = 115200
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
lostincompilation/PrettyOTA@^1.1.3
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
upload_protocol = espota
upload_port = 192.168.1.94
[env:esp32dev]
board = esp32dev
monitor_speed = 115200
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
targets = uploadfs
board_build.psram = disabled
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
lostincompilation/PrettyOTA@^1.1.3
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0

44
src/communication.h Normal file
View File

@@ -0,0 +1,44 @@
#include <Arduino.h>
#include "master.h"
#include <PicoMQTT.h>
#include <statusled.h>
#include "timesync.h"
// Datenstruktur für ESP-NOW Nachrichten
// Datenstruktur für ESP-NOW Nachrichten
typedef struct {
uint8_t messageType;
uint8_t buttonId;
int buttonPressed;
uint32_t timestamp;
char messageId[33]; // 32 hex chars + null terminator for 128-bit ID
} ButtonMessage;
PicoMQTT::Server mqtt;
void setupMqttServer() {
// Set up the MQTT server with the desired port
// Subscribe to a topic pattern and attach a callback
mqtt.subscribe("#", [](const char * topic, const char * payload) {
Serial.printf("Received message in topic '%s': %s\n", topic, payload);
});
// Start the MQTT server
mqtt.begin();
}
void loopMqttServer() {
// Handle incoming MQTT messages
mqtt.loop();
// Optionally, you can publish a message periodically
static unsigned long lastPublish = 0;
if (millis() - lastPublish > 5000) { // Publish every 5 seconds
mqtt.publish("test/topic", "Hello from ESP32!");
lastPublish = millis();
}
}

41
src/debug.h Normal file
View File

@@ -0,0 +1,41 @@
// Zeit-bezogene Variablen und Includes
#pragma once
#include <Arduino.h>
#include <master.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <time.h>
#include <sys/time.h>
void setupDebugAPI(AsyncWebServer& server);
void setupDebugAPI(AsyncWebServer& server) {
//DEBUG
server.on("/api/debug/start1", HTTP_GET, [](AsyncWebServerRequest *request){
handleStart1();
request->send(200, "text/plain", "handleStart1() called");
});
server.on("/api/debug/stop1", HTTP_GET, [](AsyncWebServerRequest *request){
handleStop1();
request->send(200, "text/plain", "handleStop1() called");
});
server.on("/api/debug/start2", HTTP_GET, [](AsyncWebServerRequest *request){
handleStart2();
request->send(200, "text/plain", "handleStart2() called");
});
server.on("/api/debug/stop2", HTTP_GET, [](AsyncWebServerRequest *request){
handleStop2();
request->send(200, "text/plain", "handleStop2() called");
});
Serial.println("Debug-API initialisiert");
}
//DEBUG END

106
src/licenceing.h Normal file
View File

@@ -0,0 +1,106 @@
#pragma once
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <esp_wifi.h>
#include <master.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include "mbedtls/md.h"
const char* secret = "542ff224606c61fb3024e22f76ef9ac8";
// Preferences für persistente Speicherung
Preferences preferences;
String licence;
//Prototype für Funktionen
String getUniqueDeviceID();
String hmacSHA256(const String& key, const String& message);
bool checkLicense(const String& deviceID, const String& licenseKey);
void setupLicenceAPI(AsyncWebServer& server);
void saveLicenceToPrefs();
void loadLicenceFromPrefs();
String getUniqueDeviceID() {
uint8_t mac[6];
esp_wifi_get_mac(WIFI_IF_STA, mac); // Use STA MAC for uniqueness
char id[13];
sprintf(id, "%02X%02X%02X%02X%02X%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
return String(id);
}
String hmacSHA256(const String& key, const String& message) {
byte hmacResult[32];
mbedtls_md_context_t ctx;
mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
mbedtls_md_init(&ctx);
const mbedtls_md_info_t* md_info = mbedtls_md_info_from_type(md_type);
mbedtls_md_setup(&ctx, md_info, 1);
mbedtls_md_hmac_starts(&ctx, (const unsigned char*)key.c_str(), key.length());
mbedtls_md_hmac_update(&ctx, (const unsigned char*)message.c_str(), message.length());
mbedtls_md_hmac_finish(&ctx, hmacResult);
mbedtls_md_free(&ctx);
String result = "";
for (int i = 0; i < 32; i++) {
char buf[3];
sprintf(buf, "%02X", hmacResult[i]);
result += buf;
}
return result;
}
int getLicenseTier(const String& deviceID, const String& licenseKey) {
for (int tier = 1; tier <= 4; ++tier) {
String data = deviceID + ":" + String(tier);
String expected = hmacSHA256(secret, data);
if (licenseKey.equalsIgnoreCase(expected)) {
return tier; // Found matching tier
}
}
return 0; // No valid tier found
}
void setupLicenceAPI(AsyncWebServer& server) {
server.on("/api/get-licence", HTTP_GET, [](AsyncWebServerRequest *request){
Serial.println("Received request to get licence");
loadLicenceFromPrefs();
String deviceID = getUniqueDeviceID();
int tier = getLicenseTier(deviceID, licence);
String json = "{\"licence\":\"" + licence + "\","
"\"valid\":" + String(tier > 0 ? "true" : "false") +
",\"tier\":" + String(tier) + "}";
request->send(200, "application/json", json);
});
server.on("/api/set-licence", HTTP_POST, [](AsyncWebServerRequest *request){
Serial.println("Received request to set licence");
if (request->hasParam("licence", true)) {
licence = request->getParam("licence", true)->value();
Serial.println("Received request to set licence " + licence);
saveLicenceToPrefs(); // eigene Funktion
request->send(200, "application/json", "{\"success\":true}");
} else {
request->send(400, "application/json", "{\"success\":false}");
}
});
Serial.println("Licence API setup complete");
}
void saveLicenceToPrefs() {
preferences.begin("key", false);
preferences.putString("key", licence);
preferences.end();
}
void loadLicenceFromPrefs() {
preferences.begin("key", true);
licence = preferences.getString("key", "");
preferences.end();
}

331
src/master.cpp Normal file
View File

@@ -0,0 +1,331 @@
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#include <Arduino.h>
#include "master.h"
// Aquacross Timer - ESP32 Master (Webserver + ESP-NOW + Anlernmodus)
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <esp_now.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <PrettyOTA.h>
#include <esp_wifi.h>
#include <AsyncTCP.h>
#include <timesync.h>
#include <licenceing.h>
#include <debug.h>
#include <wificlass.h>
#include <webserverrouter.h>
#include <communication.h>
const char* firmwareversion = "1.0.0"; // Version der Firmware
void handleLearningMode(const uint8_t* mac) {
// Prüfen ob MAC bereits einem anderen Button zugewiesen ist
if (buttonConfigs.start1.isAssigned && memcmp(buttonConfigs.start1.mac, mac, 6) == 0) {
Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert");
return;
}
if (buttonConfigs.stop1.isAssigned && memcmp(buttonConfigs.stop1.mac, mac, 6) == 0) {
Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert");
return;
}
if (buttonConfigs.start2.isAssigned && memcmp(buttonConfigs.start2.mac, mac, 6) == 0) {
Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert");
return;
}
if (buttonConfigs.stop2.isAssigned && memcmp(buttonConfigs.stop2.mac, mac, 6) == 0) {
Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert");
return;
}
// MAC ist noch nicht zugewiesen, normal fortfahren
switch(learningStep) {
case 0: // Start1
memcpy(buttonConfigs.start1.mac, mac, 6);
buttonConfigs.start1.isAssigned = true;
Serial.println("Start1 Button zugewiesen");
break;
case 1: // Stop1
memcpy(buttonConfigs.stop1.mac, mac, 6);
buttonConfigs.stop1.isAssigned = true;
Serial.println("Stop1 Button zugewiesen");
break;
case 2: // Start2
memcpy(buttonConfigs.start2.mac, mac, 6);
buttonConfigs.start2.isAssigned = true;
Serial.println("Start2 Button zugewiesen");
break;
case 3: // Stop2
memcpy(buttonConfigs.stop2.mac, mac, 6);
buttonConfigs.stop2.isAssigned = true;
Serial.println("Stop2 Button zugewiesen");
break;
}
learningStep++;
if (learningStep >= 4) {
learningMode = false;
learningStep = 0;
saveButtonConfig();
Serial.println("Lernmodus beendet!");
}
}
void handleStartLearning() {
learningMode = true;
// Count assigned buttons and set appropriate learning step
int assignedButtons = 0;
if (buttonConfigs.start1.isAssigned) assignedButtons++;
if (buttonConfigs.stop1.isAssigned) assignedButtons++;
if (buttonConfigs.start2.isAssigned) assignedButtons++;
if (buttonConfigs.stop2.isAssigned) assignedButtons++;
learningStep = assignedButtons;
Serial.printf("Learning mode started - %d buttons already assigned, continuing at step %d\n",
assignedButtons, learningStep);
}
void handleLearningStatus() {
DynamicJsonDocument doc(256);
doc["active"] = learningMode;
doc["step"] = learningStep;
String response;
serializeJson(doc, response);
}
void unlearnButton() {
memset(buttonConfigs.start1.mac, 0, 6);
buttonConfigs.start1.isAssigned = false;
memset(buttonConfigs.stop1.mac, 0, 6);
buttonConfigs.stop1.isAssigned = false;
memset(buttonConfigs.start2.mac, 0, 6);
buttonConfigs.start2.isAssigned = false;
memset(buttonConfigs.stop2.mac, 0, 6);
buttonConfigs.stop2.isAssigned = false;
saveButtonConfig();
Serial.println("Buttons wurden verlernt.");
}
void handleStart1() {
if (!timerData.isRunning1) {
timerData.startTime1 = millis();
timerData.isRunning1 = true;
timerData.endTime1 = 0;
Serial.println("Bahn 1 gestartet");
}
}
void handleStop1() {
if (timerData.isRunning1) {
timerData.endTime1 = millis();
timerData.isRunning1 = false;
unsigned long currentTime = timerData.endTime1 - timerData.startTime1;
if (timerData.bestTime1 == 0 || currentTime < timerData.bestTime1) {
timerData.bestTime1 = currentTime;
saveBestTimes();
}
timerData.finishedSince1 = millis();
Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime/1000.0) + "s");
}
}
void handleStart2() {
if (!timerData.isRunning2) {
timerData.startTime2 = millis();
timerData.isRunning2 = true;
timerData.endTime2 = 0;
Serial.println("Bahn 2 gestartet");
}
}
void handleStop2() {
if (timerData.isRunning2) {
timerData.endTime2 = millis();
timerData.isRunning2 = false;
unsigned long currentTime = timerData.endTime2 - timerData.startTime2;
if (timerData.bestTime2 == 0 || currentTime < timerData.bestTime2) {
timerData.bestTime2 = currentTime;
saveBestTimes();
}
timerData.finishedSince2 = millis();
Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime/1000.0) + "s");
}
}
void checkAutoReset() {
unsigned long currentTime = millis();
if (timerData.isRunning1 && (currentTime - timerData.startTime1 > maxTimeBeforeReset)) {
timerData.isRunning1 = false;
timerData.startTime1 = 0;
Serial.println("Bahn 1 automatisch zurückgesetzt");
}
if (timerData.isRunning2 && (currentTime - timerData.startTime2 > maxTimeBeforeReset)) {
timerData.isRunning2 = false;
timerData.startTime2 = 0;
Serial.println("Bahn 2 automatisch zurückgesetzt");
}
// Automatischer Reset nach 10 Sekunden "Beendet"
if (!timerData.isRunning1 && timerData.endTime1 > 0 && timerData.finishedSince1 > 0) {
if (currentTime - timerData.finishedSince1 > maxTimeDisplay) {
timerData.startTime1 = 0;
timerData.endTime1 = 0;
timerData.finishedSince1 = 0;
Serial.println("Bahn 1 automatisch auf 'Bereit' zurückgesetzt");
}
}
if (!timerData.isRunning2 && timerData.endTime2 > 0 && timerData.finishedSince2 > 0) {
if (currentTime - timerData.finishedSince2 > maxTimeDisplay) {
timerData.startTime2 = 0;
timerData.endTime2 = 0;
timerData.finishedSince2 = 0;
Serial.println("Bahn 2 automatisch auf 'Bereit' zurückgesetzt");
}
}
}
void saveButtonConfig() {
preferences.begin("buttons", false);
preferences.putBytes("config", &buttonConfigs, sizeof(buttonConfigs));
preferences.end();
}
void loadButtonConfig() {
preferences.begin("buttons", true);
size_t schLen = preferences.getBytesLength("config");
if (schLen == sizeof(buttonConfigs)) {
preferences.getBytes("config", &buttonConfigs, schLen);
}
preferences.end();
}
void saveBestTimes() {
preferences.begin("times", false);
preferences.putULong("best1", timerData.bestTime1);
preferences.putULong("best2", timerData.bestTime2);
preferences.end();
}
void loadBestTimes() {
preferences.begin("times", true);
timerData.bestTime1 = preferences.getULong("best1", 0);
timerData.bestTime2 = preferences.getULong("best2", 0);
preferences.end();
}
void saveSettings() {
preferences.begin("settings", false);
preferences.putULong("maxTime", maxTimeBeforeReset);
preferences.putULong("maxTimeDisplay", maxTimeDisplay);
preferences.end();
}
void loadSettings() {
preferences.begin("settings", true);
maxTimeBeforeReset = preferences.getULong("maxTime", 300000);
maxTimeDisplay = preferences.getULong("maxTimeDisplay", 20000);
preferences.end();
}
int checkLicence() {
loadLicenceFromPrefs();
String id = getUniqueDeviceID();
int tier = getLicenseTier(id, licence); // licence = stored or entered key
return tier;
}
String getTimerDataJSON() {
DynamicJsonDocument doc(1024);
unsigned long currentTime = millis();
// Bahn 1
if (timerData.isRunning1) {
doc["time1"] = (currentTime - timerData.startTime1) / 1000.0;
doc["status1"] = "running";
} else if (timerData.endTime1 > 0) {
doc["time1"] = (timerData.endTime1 - timerData.startTime1) / 1000.0;
doc["status1"] = "finished";
} else {
doc["time1"] = 0;
doc["status1"] = "ready";
}
// Bahn 2
if (timerData.isRunning2) {
doc["time2"] = (currentTime - timerData.startTime2) / 1000.0;
doc["status2"] = "running";
} else if (timerData.endTime2 > 0) {
doc["time2"] = (timerData.endTime2 - timerData.startTime2) / 1000.0;
doc["status2"] = "finished";
} else {
doc["time2"] = 0;
doc["status2"] = "ready";
}
// Beste Zeiten
doc["best1"] = timerData.bestTime1 / 1000.0;
doc["best2"] = timerData.bestTime2 / 1000.0;
// Lernmodus
doc["learningMode"] = learningMode;
if (learningMode) {
String buttons[] = {"Start Bahn 1", "Stop Bahn 1", "Start Bahn 2", "Stop Bahn 2"};
doc["learningButton"] = buttons[learningStep];
}
String result;
serializeJson(doc, result);
return result;
}
void setup() {
Serial.begin(115200);
if (!SPIFFS.begin(true)) {
Serial.println("SPIFFS Mount Failed");
return;
}
//setup external libararies
setupTimeAPI(server);
setupLicenceAPI(server);
setupDebugAPI(server);
// Gespeicherte Daten laden
loadButtonConfig();
loadBestTimes();
loadSettings();
setupWifi(); // WiFi initialisieren
setupOTA(&server);
setupRoutes();
setupMqttServer(); // MQTT Server initialisieren
}
void loop() {
checkAutoReset();
loopMqttServer(); // MQTT Server in der Loop aufrufen
delay(100);
}

64
src/master.h Normal file
View File

@@ -0,0 +1,64 @@
#pragma once
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <time.h>
#include <sys/time.h>
// Timer Struktur
struct TimerData {
unsigned long startTime1 = 0;
unsigned long startTime2 = 0;
unsigned long endTime1 = 0;
unsigned long endTime2 = 0;
unsigned long bestTime1 = 0;
unsigned long bestTime2 = 0;
bool isRunning1 = false;
bool isRunning2 = false;
unsigned long finishedSince1 = 0;
unsigned long finishedSince2 = 0;
};
// Button Konfiguration
struct ButtonConfig {
uint8_t mac[6];
bool isAssigned = false;
};
struct ButtonConfigs {
ButtonConfig start1;
ButtonConfig stop1;
ButtonConfig start2;
ButtonConfig stop2;
};
extern const char* firmwareversion;
// Globale Variablen
TimerData timerData;
ButtonConfigs buttonConfigs;
bool learningMode = false;
int learningStep = 0; // 0=Start1, 1=Stop1, 2=Start2, 3=Stop2
unsigned long maxTimeBeforeReset = 300000; // 5 Minuten default
unsigned long maxTimeDisplay = 20000; // 20 Sekunden Standard (in ms)
bool wifimodeAP = false; // AP-Modus deaktiviert
//Function Declarations
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len);
void handleLearningMode(const uint8_t* mac);
void handleStartLearning();
void handleStart1();
void handleStop1();
void handleStart2();
void handleStop2();
void checkAutoReset();
void saveButtonConfig();
void loadButtonConfig();
void saveBestTimes();
void loadBestTimes();
void saveSettings();
void loadSettings();
void unlearnButton();
int checkLicence();
String getTimerDataJSON();

54
src/statusled.h Normal file
View File

@@ -0,0 +1,54 @@
#include <Arduino.h>
#define LED_PIN LED_BUILTIN
// Status LED
unsigned long lastLedBlink = 0;
bool ledState = false;
void updateStatusLED(int blinkPattern) {
unsigned long currentTime = millis();
switch (blinkPattern) {
case 0: // Suche Master - Langsames Blinken
if (currentTime - lastLedBlink > 1000) {
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
lastLedBlink = currentTime;
}
break;
case 1: // Verbunden - Kurzes Blinken alle 3 Sekunden
if (currentTime - lastLedBlink > 3000) {
digitalWrite(LED_PIN, HIGH);
delay(100);
digitalWrite(LED_PIN, LOW);
lastLedBlink = currentTime;
}
break;
case 2: // Button gedrückt - Schnelles Blinken 3x
static int blinkCount = 0;
if (currentTime - lastLedBlink > 100) {
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
lastLedBlink = currentTime;
blinkCount++;
if (blinkCount >= 6) { // 3 komplette Blinks
blinkCount = 0;
blinkPattern = 1; // Zurück zu verbunden
}
}
case 3: // Flash bei Empfang - Einmaliges kurzes Blinken
{
digitalWrite(LED_PIN, HIGH);
delay(100);
digitalWrite(LED_PIN, LOW);
}
break;
}
}

198
src/timesync.h Normal file
View File

@@ -0,0 +1,198 @@
// Zeit-bezogene Variablen und Includes
#pragma once
#include <Arduino.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <time.h>
#include <sys/time.h>
// Globale Zeitvariablen
struct timeval tv;
struct timezone tz;
time_t now;
struct tm timeinfo;
void setupTimeAPI(AsyncWebServer& server);
String getCurrentTimeJSON();
bool setSystemTime(long timestamp);
// Hilfsfunktionen für Zeit-Management
String getCurrentTimeJSON() {
gettimeofday(&tv, &tz);
now = tv.tv_sec;
StaticJsonDocument<200> doc;
doc["timestamp"] = (long)now;
doc["success"] = true;
// Zusätzliche Zeitinformationen
gmtime_r(&now, &timeinfo);
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
doc["formatted"] = String(timeStr);
doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour;
doc["minute"] = timeinfo.tm_min;
doc["second"] = timeinfo.tm_sec;
String response;
serializeJson(doc, response);
return response;
}
bool setSystemTime(long timestamp) {
struct timeval tv;
tv.tv_sec = timestamp;
tv.tv_usec = 0;
if (settimeofday(&tv, NULL) == 0) {
Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp));
return true;
} else {
Serial.println("Fehler beim Setzen der Zeit");
return false;
}
}
void setupTimeAPI(AsyncWebServer& server) {
// API-Endpunkt: Aktuelle Zeit abrufen
server.on("/api/time", HTTP_GET, [](AsyncWebServerRequest *request){
String response = getCurrentTimeJSON();
request->send(200, "application/json", response);
});
// API-Endpunkt: Zeit setzen
server.on("/api/set-time", HTTP_POST, [](AsyncWebServerRequest *request){
StaticJsonDocument<100> doc;
if (request->hasParam("timestamp", true)) {
String timestampStr = request->getParam("timestamp", true)->value();
long timestamp = timestampStr.toInt();
if (timestamp > 0) {
bool success = setSystemTime(timestamp);
doc["success"] = success;
if (success) {
doc["message"] = "Zeit erfolgreich gesetzt";
doc["timestamp"] = timestamp;
} else {
doc["message"] = "Fehler beim Setzen der Zeit";
}
} else {
doc["success"] = false;
doc["message"] = "Ungültiger Timestamp";
}
} else {
doc["success"] = false;
doc["message"] = "Timestamp-Parameter fehlt";
}
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// Alternative Implementierung für manuelle Datum/Zeit-Eingabe
server.on("/api/set-datetime", HTTP_POST, [](AsyncWebServerRequest *request){
StaticJsonDocument<150> doc;
if (request->hasParam("year", true) &&
request->hasParam("month", true) &&
request->hasParam("day", true) &&
request->hasParam("hour", true) &&
request->hasParam("minute", true) &&
request->hasParam("second", true)) {
struct tm timeinfo;
timeinfo.tm_year = request->getParam("year", true)->value().toInt() - 1900;
timeinfo.tm_mon = request->getParam("month", true)->value().toInt() - 1;
timeinfo.tm_mday = request->getParam("day", true)->value().toInt();
timeinfo.tm_hour = request->getParam("hour", true)->value().toInt();
timeinfo.tm_min = request->getParam("minute", true)->value().toInt();
timeinfo.tm_sec = request->getParam("second", true)->value().toInt();
time_t timestamp = mktime(&timeinfo);
if (timestamp != -1) {
bool success = setSystemTime(timestamp);
doc["success"] = success;
if (success) {
doc["message"] = "Zeit erfolgreich gesetzt";
doc["timestamp"] = (long)timestamp;
} else {
doc["message"] = "Fehler beim Setzen der Zeit";
}
} else {
doc["success"] = false;
doc["message"] = "Ungültiges Datum/Zeit";
}
} else {
doc["success"] = false;
doc["message"] = "Datum/Zeit-Parameter fehlen";
}
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// Erweiterte Zeit-Informationen (optional)
server.on("/api/time/info", HTTP_GET, [](AsyncWebServerRequest *request){
gettimeofday(&tv, &tz);
now = tv.tv_sec;
gmtime_r(&now, &timeinfo);
StaticJsonDocument<400> doc;
doc["timestamp"] = (long)now;
doc["uptime"] = millis();
// Formatierte Zeitstrings
char buffer[64];
strftime(buffer, sizeof(buffer), "%Y-%m-%d", &timeinfo);
doc["date"] = String(buffer);
strftime(buffer, sizeof(buffer), "%H:%M:%S", &timeinfo);
doc["time"] = String(buffer);
strftime(buffer, sizeof(buffer), "%A", &timeinfo);
doc["weekday"] = String(buffer);
strftime(buffer, sizeof(buffer), "%B", &timeinfo);
doc["month_name"] = String(buffer);
// Zusätzliche Infos
doc["day_of_year"] = timeinfo.tm_yday + 1;
doc["week_of_year"] = (timeinfo.tm_yday + 7 - timeinfo.tm_wday) / 7;
doc["is_dst"] = timeinfo.tm_isdst;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
Serial.println("Zeit-API initialisiert");
}
// Hilfsfunktion: Zeit-Validierung
bool isValidDateTime(int year, int month, int day, int hour, int minute, int second) {
if (year < 2020 || year > 2099) return false;
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
if (hour < 0 || hour > 23) return false;
if (minute < 0 || minute > 59) return false;
if (second < 0 || second > 59) return false;
// Erweiterte Validierung für Monatstage
int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
// Schaltjahr-Prüfung
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) {
daysInMonth[1] = 29;
}
return day <= daysInMonth[month - 1];
}

169
src/webserverrouter.h Normal file
View File

@@ -0,0 +1,169 @@
#include <Arduino.h>
#include "master.h"
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
#include <esp_wifi.h>
AsyncWebServer server(80);
void setupRoutes(){
// Web Server Routes
// SPIFFS initialisieren
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/index.html", "text/html");
});
server.on("/settings", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/settings.html", "text/html");
});
server.on("/about", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SPIFFS, "/about.html", "text/html");
});
server.on("/api/data", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(200, "application/json", getTimerDataJSON());
});
server.on("/api/reset-best", HTTP_POST, [](AsyncWebServerRequest *request){
Serial.println("/api/reset-best called");
timerData.bestTime1 = 0;
timerData.bestTime2 = 0;
saveBestTimes();
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
server.on("/api/unlearn-button", HTTP_POST, [](AsyncWebServerRequest *request){
Serial.println("/api/unlearn-button called");
unlearnButton();
request->send(200, "application/json", "{\"success\":true}");
});
server.on("/api/set-max-time", HTTP_POST, [](AsyncWebServerRequest *request){
Serial.println("/api/set-max-time called");
bool changed = false;
if (request->hasParam("maxTime", true)) {
maxTimeBeforeReset = request->getParam("maxTime", true)->value().toInt() * 1000;
changed = true;
}
if (request->hasParam("maxTimeDisplay", true)) {
maxTimeDisplay = request->getParam("maxTimeDisplay", true)->value().toInt() * 1000;
changed = true;
}
if (changed) {
saveSettings();
DynamicJsonDocument doc(32);
doc["success"] = true;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
} else {
request->send(400, "application/json", "{\"success\":false}");
}
});
server.on("/api/get-settings", HTTP_GET, [](AsyncWebServerRequest *request){
Serial.println("/api/get-settings called");
DynamicJsonDocument doc(256);
doc["maxTime"] = maxTimeBeforeReset / 1000;
doc["maxTimeDisplay"] = maxTimeDisplay / 1000;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
server.on("/api/start-learning", HTTP_POST, [](AsyncWebServerRequest *request){
Serial.println("/api/start-learning called");
learningMode = true;
learningStep = 0;
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
serializeJson(doc, result);
Serial.println("Learning mode started");
request->send(200, "application/json", result);
});
server.on("/api/stop-learning", HTTP_POST, [](AsyncWebServerRequest *request){
Serial.println("/api/stop-learning called");
learningMode = false;
learningStep = 0;
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
serializeJson(doc, result);
Serial.println("Learning mode stopped");
request->send(200, "application/json", result);
});
server.on("/api/learn/status", HTTP_GET, [](AsyncWebServerRequest *request){
DynamicJsonDocument doc(256);
doc["active"] = learningMode;
doc["step"] = learningStep;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
server.on("/api/buttons/status", HTTP_GET, [](AsyncWebServerRequest *request){
DynamicJsonDocument doc(128);
doc["lane1Start"] = buttonConfigs.start1.isAssigned;
doc["lane1Stop"] = buttonConfigs.stop1.isAssigned;
doc["lane2Start"] = buttonConfigs.start2.isAssigned;
doc["lane2Stop"] = buttonConfigs.stop2.isAssigned;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
server.on("/api/info", HTTP_GET, [](AsyncWebServerRequest *request){
DynamicJsonDocument doc(256);
// IP address
IPAddress ip = WiFi.softAPIP();
doc["ip"] = ip.toString();
doc["channel"] = WiFi.channel();
// MAC address
uint8_t mac[6];
esp_wifi_get_mac(WIFI_IF_STA, mac);
char macStr[18];
sprintf(macStr, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
doc["mac"] = macStr;
// Free memory
doc["freeMemory"] = ESP.getFreeHeap();
// Connected buttons (count assigned)
int connected = 0;
if (buttonConfigs.start1.isAssigned) connected++;
if (buttonConfigs.stop1.isAssigned) connected++;
if (buttonConfigs.start2.isAssigned) connected++;
if (buttonConfigs.stop2.isAssigned) connected++;
doc["connectedButtons"] = connected;
doc["valid"] = checkLicence() > 0 ? "Ja" : "Nein";
doc["tier"] = checkLicence() ;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
// Statische Dateien
server.serveStatic("/", SPIFFS, "/");
server.begin();
Serial.println("Web Server gestartet");
}

59
src/wificlass.h Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
#include <Arduino.h>
#include <esp_wifi.h>
#include <PrettyOTA.h>
#include <esp_now.h>
#include <WiFi.h>
#include "master.h"
#include "licenceing.h"
const char* ssidAP = "AquaCross-Timer";
const char* passwordAP = "aquacross123";
const char* ssidSTA = "Obiwlankenobi";
const char* passwordSTA = "Delfine1!";
PrettyOTA OTAUpdates;
void setupWifi() {
WiFi.mode(WIFI_MODE_APSTA);
WiFi.softAP(ssidAP, passwordAP);
WiFi.begin(ssidSTA, passwordSTA);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.println("Verbunden mit WLAN!");
Serial.print("IP-Adresse: ");
Serial.println(WiFi.localIP());
Serial.println("WiFi AP gestartet");
Serial.print("IP Adresse: ");
Serial.println(WiFi.softAPIP());
Serial.println("PrettyOTA can be accessed at: http://" + WiFi.softAPIP().toString() + "/update");
}
void setupOTA(AsyncWebServer *server) {
// Initialize PrettyOTA
OTAUpdates.Begin(server);
// Set unique Hardware-ID for your hardware/board
OTAUpdates.SetHardwareID("AquaCross-Master");
// Set firmware version to 1.0.0
OTAUpdates.SetAppVersion(firmwareversion);
// Set current build time and date
PRETTY_OTA_SET_CURRENT_BUILD_TIME_AND_DATE();
}
// WiFi als Access Point

11
test/README Normal file
View File

@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html