vibe coded website (plus captive portal)
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m57s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m18s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m14s
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m57s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m18s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m14s
needs missing ESP32 implementation Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
138
firmware/storage/www/captive.html
Normal file
138
firmware/storage/www/captive.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<title data-i18n="captive.title">System Control - WLAN Setup</title>
|
||||
<link rel="stylesheet" href="css/shared.css">
|
||||
<link rel="stylesheet" href="css/captive.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header-controls captive-header">
|
||||
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Sprache wechseln">
|
||||
<span class="lang-flag" id="lang-flag">🇩🇪</span>
|
||||
<span id="lang-label">DE</span>
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Theme wechseln">
|
||||
<span class="theme-toggle-icon" id="theme-icon">🌙</span>
|
||||
<span id="theme-label">Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚂 System Control</h1>
|
||||
<p data-i18n="captive.subtitle">WLAN-Einrichtung</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div id="scan-section">
|
||||
<button class="btn btn-secondary" onclick="scanNetworks()" data-i18n="captive.scan">
|
||||
📡 Netzwerke suchen
|
||||
</button>
|
||||
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p data-i18n="captive.scanning">Suche nach Netzwerken...</p>
|
||||
</div>
|
||||
|
||||
<div id="network-list" class="network-list" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"><span data-i18n="captive.or.manual">oder manuell eingeben</span></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ssid" data-i18n="wifi.ssid">WLAN-Name (SSID)</label>
|
||||
<input type="text" id="ssid" data-i18n-placeholder="wifi.ssid.placeholder"
|
||||
placeholder="Netzwerkname eingeben">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" data-i18n="wifi.password.short">Passwort</label>
|
||||
<div class="password-toggle">
|
||||
<input type="password" id="password" data-i18n-placeholder="captive.password.placeholder"
|
||||
placeholder="WLAN-Passwort">
|
||||
<button type="button" onclick="togglePassword()" id="password-btn">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="saveWifi()" data-i18n="captive.connect">
|
||||
💾 Verbinden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="wifi-status" class="status"></div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>ℹ️ <span data-i18n="captive.note.title">Hinweis:</span></strong>
|
||||
<span data-i18n="captive.note.text">Nach dem Speichern verbindet sich das Gerät mit dem gewählten
|
||||
Netzwerk.
|
||||
Diese Seite wird dann nicht mehr erreichbar sein. Verbinden Sie sich mit Ihrem normalen WLAN,
|
||||
um auf das Gerät zuzugreifen.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/wifi-shared.js"></script>
|
||||
<script>
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const icon = document.getElementById('theme-icon');
|
||||
const label = document.getElementById('theme-label');
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||
|
||||
if (theme === 'light') {
|
||||
icon.textContent = '☀️';
|
||||
label.textContent = 'Light';
|
||||
if (metaTheme) metaTheme.content = '#faf8f5';
|
||||
} else {
|
||||
icon.textContent = '🌙';
|
||||
label.textContent = 'Dark';
|
||||
if (metaTheme) metaTheme.content = '#1a1a2e';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
initI18n();
|
||||
// Auto-scan on load
|
||||
setTimeout(scanNetworks, 500);
|
||||
});
|
||||
|
||||
// Toggle password visibility
|
||||
function togglePassword() {
|
||||
const input = document.getElementById('password');
|
||||
const btn = document.getElementById('password-btn');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
btn.textContent = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
btn.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
201
firmware/storage/www/css/captive.css
Normal file
201
firmware/storage/www/css/captive.css
Normal file
@@ -0,0 +1,201 @@
|
||||
/* Captive Portal CSS - WiFi setup specific styles */
|
||||
/* Base styles are in shared.css */
|
||||
|
||||
body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Theme Toggle - Absolute positioned */
|
||||
/* Handled by .captive-header in shared.css */
|
||||
|
||||
/* Card - Larger padding for captive */
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
}
|
||||
|
||||
/* Form Group - More spacing */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Inputs - Thicker border for captive */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
padding: 14px 16px;
|
||||
border-width: 2px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Buttons - Full width for captive */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Status - Centered text */
|
||||
.status {
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Network List */
|
||||
.network-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.network-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.network-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.network-item:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.network-item.selected {
|
||||
background: var(--accent);
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.network-name {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.network-signal {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.signal-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Password Toggle */
|
||||
.password-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-toggle input {
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.password-toggle button {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.info-box {
|
||||
background: var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
margin-top: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Spinner - Smaller for captive */
|
||||
.spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 20px;
|
||||
}
|
||||
1201
firmware/storage/www/css/index.css
Normal file
1201
firmware/storage/www/css/index.css
Normal file
File diff suppressed because it is too large
Load Diff
300
firmware/storage/www/css/shared.css
Normal file
300
firmware/storage/www/css/shared.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* Shared CSS - Base styles for all pages */
|
||||
|
||||
/* CSS Variables - Dark Mode (default) */
|
||||
:root {
|
||||
--bg-color: #1a1a2e;
|
||||
--card-bg: #16213e;
|
||||
--accent: #0f3460;
|
||||
--text: #eaeaea;
|
||||
--text-muted: #a0a0a0;
|
||||
--success: #00d26a;
|
||||
--error: #ff6b6b;
|
||||
--border: #2a2a4a;
|
||||
--input-bg: #1a1a2e;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
|
||||
/* CSS Variables - Light Mode */
|
||||
[data-theme="light"] {
|
||||
--bg-color: #faf8f5;
|
||||
--card-bg: #ffffff;
|
||||
--accent: #fef2f2;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--success: #c41e3a;
|
||||
--error: #dc2626;
|
||||
--border: #e5d9d0;
|
||||
--input-bg: #ffffff;
|
||||
--shadow: rgba(196, 30, 58, 0.1);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: var(--success);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, opacity 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-primary:hover {
|
||||
background: #a31830;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Status Messages */
|
||||
.status {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
display: block;
|
||||
background: rgba(0, 210, 106, 0.15);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
display: block;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status.info {
|
||||
display: block;
|
||||
background: rgba(15, 52, 96, 0.5);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--card-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.theme-toggle-icon {
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Language Toggle */
|
||||
.lang-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--card-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lang-toggle:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.lang-flag {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Header Controls */
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.captive-header {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .header h1 {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--success);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 12px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for notched phones */
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
1
firmware/storage/www/favicon.svg
Normal file
1
firmware/storage/www/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚂</text></svg>
|
||||
|
After Width: | Height: | Size: 109 B |
429
firmware/storage/www/index.html
Normal file
429
firmware/storage/www/index.html
Normal file
@@ -0,0 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<title data-i18n="page.title">System Control</title>
|
||||
<link rel="stylesheet" href="css/shared.css">
|
||||
<link rel="stylesheet" href="css/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚂 System Control</h1>
|
||||
<div class="header-controls">
|
||||
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Sprache wechseln">
|
||||
<span class="lang-flag" id="lang-flag">🇩🇪</span>
|
||||
<span class="lang-label" id="lang-label">DE</span>
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Theme wechseln">
|
||||
<span class="theme-toggle-icon" id="theme-icon">🌙</span>
|
||||
<span class="theme-toggle-label" id="theme-label">Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('control')" data-i18n="tab.control">🎛️ Bedienung</button>
|
||||
<button class="tab" onclick="switchTab('config')" data-i18n="tab.config">⚙️ Konfiguration</button>
|
||||
</div>
|
||||
|
||||
<!-- Bedienung Tab -->
|
||||
<div id="tab-control" class="tab-content active">
|
||||
<div class="card">
|
||||
<h2 data-i18n="control.light.title">Lichtsteuerung</h2>
|
||||
|
||||
<div class="control-section">
|
||||
<h3 data-i18n="control.light.onoff">Ein/Aus</h3>
|
||||
<div class="control-group">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label" data-i18n="control.light.light">Licht</span>
|
||||
<button class="toggle-switch" id="light-toggle" onclick="toggleLight()">
|
||||
<span class="toggle-state" id="light-state" data-i18n="common.off">AUS</span>
|
||||
<span class="toggle-icon" id="light-icon">💡</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="light-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h3 data-i18n="control.mode.title">Betriebsmodus</h3>
|
||||
<div class="mode-selector">
|
||||
<button class="mode-btn" id="mode-day" onclick="setMode('day')">
|
||||
<span class="mode-icon">☀️</span>
|
||||
<span class="mode-name" data-i18n="mode.day">Tag</span>
|
||||
</button>
|
||||
<button class="mode-btn" id="mode-night" onclick="setMode('night')">
|
||||
<span class="mode-icon">🌙</span>
|
||||
<span class="mode-name" data-i18n="mode.night">Nacht</span>
|
||||
</button>
|
||||
<button class="mode-btn active" id="mode-simulation" onclick="setMode('simulation')">
|
||||
<span class="mode-icon">🔄</span>
|
||||
<span class="mode-name" data-i18n="mode.simulation">Simulation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="simulation-options" class="simulation-options">
|
||||
<div class="form-group">
|
||||
<label for="active-schema" data-i18n="control.schema.active">Aktives Schema</label>
|
||||
<select id="active-schema" onchange="setActiveSchema()">
|
||||
<option value="schema_01.csv" data-i18n="schema.name.1">Schema 1 (Standard)</option>
|
||||
<option value="schema_02.csv" data-i18n="schema.name.2">Schema 2 (Warm)</option>
|
||||
<option value="schema_03.csv" data-i18n="schema.name.3">Schema 3 (Natur)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mode-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h3 data-i18n="control.status.title">Aktueller Status</h3>
|
||||
<div class="status-display">
|
||||
<div class="status-item">
|
||||
<span class="status-label" data-i18n="control.status.mode">Modus</span>
|
||||
<span class="status-value" id="current-mode" data-i18n="mode.simulation">Simulation</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label" data-i18n="control.status.schema">Schema</span>
|
||||
<span class="status-value" id="current-schema">Schema 1</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label" data-i18n="control.status.color">Aktuelle Farbe</span>
|
||||
<div class="current-color-preview" id="current-color"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Szenen Card -->
|
||||
<div class="card" id="scenes-control-card">
|
||||
<h2 data-i18n="scenes.title">Szenen</h2>
|
||||
<div id="scenes-control-list" class="scenes-grid">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-scenes-control">
|
||||
<span class="empty-icon">🎬</span>
|
||||
<p data-i18n="scenes.empty">Keine Szenen definiert</p>
|
||||
<p class="empty-hint" data-i18n="scenes.empty.hint">Erstelle Szenen unter Konfiguration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scenes-control-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Externe Geräte Card -->
|
||||
<div class="card" id="devices-control-card">
|
||||
<h2 data-i18n="devices.external">Externe Geräte</h2>
|
||||
<div id="devices-control-list" class="devices-control-grid">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-devices-control">
|
||||
<span class="empty-icon">🔗</span>
|
||||
<p data-i18n="devices.control.empty">Keine Geräte hinzugefügt</p>
|
||||
<p class="empty-hint" data-i18n="devices.control.empty.hint">Füge Geräte unter Konfiguration
|
||||
hinzu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konfiguration Tab -->
|
||||
<div id="tab-config" class="tab-content">
|
||||
<div class="sub-tabs">
|
||||
<button class="sub-tab active" onclick="switchSubTab('wifi')" data-i18n="subtab.wifi">📶 WLAN</button>
|
||||
<button class="sub-tab" onclick="switchSubTab('schema')" data-i18n="subtab.schema">💡 Schema</button>
|
||||
<button class="sub-tab" onclick="switchSubTab('devices')" data-i18n="subtab.devices">🔗 Geräte</button>
|
||||
<button class="sub-tab" onclick="switchSubTab('scenes')" data-i18n="subtab.scenes">🎬 Szenen</button>
|
||||
</div>
|
||||
|
||||
<!-- WLAN Sub-Tab -->
|
||||
<div id="subtab-wifi" class="sub-tab-content active">
|
||||
<div class="card">
|
||||
<h2 data-i18n="wifi.config.title">WLAN Konfiguration</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ssid" data-i18n="wifi.ssid">WLAN Name (SSID)</label>
|
||||
<input type="text" id="ssid" data-i18n-placeholder="wifi.ssid.placeholder"
|
||||
placeholder="Netzwerkname eingeben" autocomplete="off" autocapitalize="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" data-i18n="wifi.password">WLAN Passwort</label>
|
||||
<input type="password" id="password" data-i18n-placeholder="wifi.password.placeholder"
|
||||
placeholder="Passwort eingeben" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="wifi.available">Verfügbare Netzwerke</label>
|
||||
<select id="available-networks"
|
||||
onchange="if(this.value) document.getElementById('ssid').value = this.value">
|
||||
<option value="" data-i18n="wifi.scan.hint">Nach Netzwerken suchen...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="scanNetworks()" data-i18n="btn.scan">🔍
|
||||
Suchen</button>
|
||||
<button class="btn btn-primary" onclick="saveWifi()" data-i18n="btn.save">💾 Speichern</button>
|
||||
</div>
|
||||
|
||||
<div id="wifi-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="wifi.status.title">Verbindungsstatus</h2>
|
||||
<div id="connection-info">
|
||||
<p><strong data-i18n="wifi.status.status">Status:</strong> <span id="conn-status"
|
||||
data-i18n="common.loading">Wird geladen...</span></p>
|
||||
<p><strong data-i18n="wifi.status.ip">IP-Adresse:</strong> <span id="conn-ip">-</span></p>
|
||||
<p><strong data-i18n="wifi.status.signal">Signal:</strong> <span id="conn-rssi">-</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schema Sub-Tab -->
|
||||
<div id="subtab-schema" class="sub-tab-content">
|
||||
<div class="card">
|
||||
<h2 data-i18n="schema.editor.title">Licht-Schema Editor</h2>
|
||||
|
||||
<div class="schema-controls">
|
||||
<div class="form-group">
|
||||
<label for="schema-select" data-i18n="schema.file">Schema-Datei</label>
|
||||
<select id="schema-select" onchange="loadSchema()">
|
||||
<option value="schema_01.csv" data-i18n="schema.name.1">Schema 1 (Standard)</option>
|
||||
<option value="schema_02.csv" data-i18n="schema.name.2">Schema 2 (Warm)</option>
|
||||
<option value="schema_03.csv" data-i18n="schema.name.3">Schema 3 (Natur)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="loadSchema()" data-i18n="btn.load">🔄 Laden</button>
|
||||
<button class="btn btn-primary" onclick="saveSchema()" data-i18n="btn.save">💾
|
||||
Speichern</button>
|
||||
</div>
|
||||
|
||||
<div id="schema-status" class="status"></div>
|
||||
|
||||
<div id="schema-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p data-i18n="schema.loading">Schema wird geladen...</p>
|
||||
</div>
|
||||
|
||||
<div class="value-header">
|
||||
<span data-i18n="schema.header.time">Zeit</span>
|
||||
<span data-i18n="schema.header.color">Farbe</span>
|
||||
<span>R</span>
|
||||
<span>G</span>
|
||||
<span>B</span>
|
||||
<span>V1</span>
|
||||
<span>V2</span>
|
||||
<span>V3</span>
|
||||
</div>
|
||||
|
||||
<div id="schema-grid" class="time-grid">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Szenen Sub-Tab -->
|
||||
<div id="subtab-scenes" class="sub-tab-content">
|
||||
<div class="card">
|
||||
<h2 data-i18n="scenes.manage.title">Szenen verwalten</h2>
|
||||
<p class="card-description" data-i18n="scenes.manage.desc">Erstelle und bearbeite Szenen für
|
||||
schnellen Zugriff</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="openSceneModal()" data-i18n="btn.new.scene">➕ Neue
|
||||
Szene</button>
|
||||
</div>
|
||||
|
||||
<div id="scenes-config-list" class="scenes-config-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-scenes-config">
|
||||
<span class="empty-icon">🎬</span>
|
||||
<p data-i18n="scenes.config.empty">Keine Szenen erstellt</p>
|
||||
<p class="empty-hint" data-i18n="scenes.config.empty.hint">Klicke auf "Neue Szene" um eine
|
||||
Szene zu erstellen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scenes-status" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geräte Sub-Tab -->
|
||||
<div id="subtab-devices" class="sub-tab-content">
|
||||
<div class="card">
|
||||
<h2 data-i18n="devices.new.title">Neue Geräte</h2>
|
||||
<p class="card-description" data-i18n="devices.new.desc">Unprovisionierte Matter-Geräte in der Nähe
|
||||
</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="scanDevices()" data-i18n="btn.scan.devices">🔍 Geräte
|
||||
suchen</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p data-i18n="devices.searching">Suche nach Geräten...</p>
|
||||
</div>
|
||||
|
||||
<div id="unpaired-devices" class="device-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-unpaired-devices">
|
||||
<span class="empty-icon">📡</span>
|
||||
<p data-i18n="devices.unpaired.empty">Keine neuen Geräte gefunden</p>
|
||||
<p class="empty-hint" data-i18n="devices.unpaired.empty.hint">Drücke "Geräte suchen" um nach
|
||||
Matter-Geräten zu suchen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="devices-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="devices.paired.title">Zugeordnete Geräte</h2>
|
||||
<p class="card-description" data-i18n="devices.paired.desc">Bereits hinzugefügte externe Geräte</p>
|
||||
|
||||
<div id="paired-devices" class="device-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-paired-devices">
|
||||
<span class="empty-icon">📦</span>
|
||||
<p data-i18n="devices.paired.empty">Keine Geräte hinzugefügt</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker Modal -->
|
||||
<div id="color-modal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3><span data-i18n="modal.color.title">Farbe wählen</span> - <span id="modal-time"></span></h3>
|
||||
|
||||
<div class="color-picker-container">
|
||||
<div class="color-slider">
|
||||
<label style="color: #f66;">R</label>
|
||||
<input type="range" id="rangeR" min="0" max="255" value="255" oninput="updateModalColor()">
|
||||
<span id="valR" class="value">255</span>
|
||||
</div>
|
||||
<div class="color-slider">
|
||||
<label style="color: #6f6;">G</label>
|
||||
<input type="range" id="rangeG" min="0" max="255" value="255" oninput="updateModalColor()">
|
||||
<span id="valG" class="value">255</span>
|
||||
</div>
|
||||
<div class="color-slider">
|
||||
<label style="color: #66f;">B</label>
|
||||
<input type="range" id="rangeB" min="0" max="255" value="255" oninput="updateModalColor()">
|
||||
<span id="valB" class="value">255</span>
|
||||
</div>
|
||||
|
||||
<div id="preview-large" class="preview-large"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-secondary" onclick="closeColorModal()" data-i18n="btn.cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="applyColor()" data-i18n="btn.apply">Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene Modal -->
|
||||
<div id="scene-modal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 id="scene-modal-title" data-i18n="modal.scene.new">Neue Szene erstellen</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scene-name" data-i18n="scene.name">Name</label>
|
||||
<input type="text" id="scene-name" data-i18n-placeholder="scene.name.placeholder"
|
||||
placeholder="z.B. Abendstimmung" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="scene.icon">Icon auswählen</label>
|
||||
<div class="icon-selector">
|
||||
<button type="button" class="icon-btn active" data-icon="🌅"
|
||||
onclick="selectSceneIcon('🌅')">🌅</button>
|
||||
<button type="button" class="icon-btn" data-icon="🌙" onclick="selectSceneIcon('🌙')">🌙</button>
|
||||
<button type="button" class="icon-btn" data-icon="☀️" onclick="selectSceneIcon('☀️')">☀️</button>
|
||||
<button type="button" class="icon-btn" data-icon="🎬" onclick="selectSceneIcon('🎬')">🎬</button>
|
||||
<button type="button" class="icon-btn" data-icon="💤" onclick="selectSceneIcon('💤')">💤</button>
|
||||
<button type="button" class="icon-btn" data-icon="🎉" onclick="selectSceneIcon('🎉')">🎉</button>
|
||||
<button type="button" class="icon-btn" data-icon="🍿" onclick="selectSceneIcon('🍿')">🍿</button>
|
||||
<button type="button" class="icon-btn" data-icon="📚" onclick="selectSceneIcon('📚')">📚</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="scene.actions">Aktionen</label>
|
||||
<div class="scene-actions-editor">
|
||||
<div class="scene-action-row">
|
||||
<label>
|
||||
<input type="checkbox" id="scene-action-light" checked>
|
||||
<span data-i18n="scene.action.light">Licht Ein/Aus</span>
|
||||
</label>
|
||||
<select id="scene-light-state">
|
||||
<option value="on" data-i18n="scene.light.on">Einschalten</option>
|
||||
<option value="off" data-i18n="scene.light.off">Ausschalten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="scene-action-row">
|
||||
<label>
|
||||
<input type="checkbox" id="scene-action-mode">
|
||||
<span data-i18n="scene.action.mode">Modus setzen</span>
|
||||
</label>
|
||||
<select id="scene-mode-value">
|
||||
<option value="day" data-i18n="mode.day">Tag</option>
|
||||
<option value="night" data-i18n="mode.night">Nacht</option>
|
||||
<option value="simulation" data-i18n="mode.simulation">Simulation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="scene-action-row">
|
||||
<label>
|
||||
<input type="checkbox" id="scene-action-schema">
|
||||
<span data-i18n="scene.action.schema">Schema wählen</span>
|
||||
</label>
|
||||
<select id="scene-schema-value">
|
||||
<option value="schema_01.csv">Schema 1</option>
|
||||
<option value="schema_02.csv">Schema 2</option>
|
||||
<option value="schema_03.csv">Schema 3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="devices.external">Externe Geräte</label>
|
||||
<div id="scene-devices-list" class="scene-devices-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state small" id="no-scene-devices">
|
||||
<span class="empty-icon">🔗</span>
|
||||
<p data-i18n="devices.none.available">Keine Geräte verfügbar</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-secondary" onclick="closeSceneModal()" data-i18n="btn.cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="saveScene()" data-i18n="btn.save">💾 Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript Modules -->
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/wifi-shared.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/websocket.js"></script>
|
||||
<script src="js/light.js"></script>
|
||||
<script src="js/scenes.js"></script>
|
||||
<script src="js/devices.js"></script>
|
||||
<script src="js/schema.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
60
firmware/storage/www/js/app.js
Normal file
60
firmware/storage/www/js/app.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Global variables
|
||||
let schemaData = [];
|
||||
let currentEditRow = null;
|
||||
let lightOn = false;
|
||||
let currentMode = 'simulation';
|
||||
let ws = null;
|
||||
let wsReconnectTimer = null;
|
||||
let pairedDevices = [];
|
||||
let scenes = [];
|
||||
let currentEditScene = null;
|
||||
let selectedSceneIcon = '🌅';
|
||||
|
||||
// Event listeners
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeColorModal();
|
||||
closeSceneModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('color-modal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
closeColorModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('scene-modal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
closeSceneModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent zoom on double-tap for iOS
|
||||
let lastTouchEnd = 0;
|
||||
document.addEventListener('touchend', (e) => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
e.preventDefault();
|
||||
}
|
||||
lastTouchEnd = now;
|
||||
}, false);
|
||||
|
||||
// Initialization
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initI18n();
|
||||
initTheme();
|
||||
initWebSocket();
|
||||
updateConnectionStatus();
|
||||
loadScenes();
|
||||
loadPairedDevices();
|
||||
// WiFi status polling (less frequent)
|
||||
setInterval(updateConnectionStatus, 30000);
|
||||
});
|
||||
|
||||
// Close WebSocket on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
231
firmware/storage/www/js/devices.js
Normal file
231
firmware/storage/www/js/devices.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// Device management
|
||||
function renderDevicesControl() {
|
||||
const list = document.getElementById('devices-control-list');
|
||||
const noDevices = document.getElementById('no-devices-control');
|
||||
|
||||
list.querySelectorAll('.device-control-item').forEach(el => el.remove());
|
||||
|
||||
if (pairedDevices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
noDevices.style.display = 'none';
|
||||
pairedDevices.forEach(device => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-control-item';
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
item.innerHTML = `
|
||||
<span class="device-control-icon">${icon}</span>
|
||||
<span class="device-control-name">${device.name}</span>
|
||||
${device.type === 'light' ? `<button class="toggle-switch small" onclick="toggleExternalDevice('${device.id}')"><span class="toggle-icon">💡</span></button>` : ''}
|
||||
`;
|
||||
list.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExternalDevice(deviceId) {
|
||||
try {
|
||||
await fetch('/api/devices/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: deviceId })
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Demo: Gerät umgeschaltet');
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDevices() {
|
||||
const loading = document.getElementById('devices-loading');
|
||||
const unpairedList = document.getElementById('unpaired-devices');
|
||||
const noDevices = document.getElementById('no-unpaired-devices');
|
||||
|
||||
loading.classList.add('active');
|
||||
noDevices.style.display = 'none';
|
||||
|
||||
// Entferne vorherige Ergebnisse (außer empty-state)
|
||||
unpairedList.querySelectorAll('.device-item').forEach(el => el.remove());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/devices/scan');
|
||||
const devices = await response.json();
|
||||
|
||||
loading.classList.remove('active');
|
||||
|
||||
if (devices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
devices.forEach(device => {
|
||||
const item = createUnpairedDeviceItem(device);
|
||||
unpairedList.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
|
||||
showStatus('devices-status', t('devices.found', { count: devices.length }), 'success');
|
||||
} catch (error) {
|
||||
loading.classList.remove('active');
|
||||
// Demo data
|
||||
const demoDevices = [
|
||||
{ id: 'matter-001', type: 'light', name: 'Matter Lamp' },
|
||||
{ id: 'matter-002', type: 'sensor', name: 'Temperature Sensor' }
|
||||
];
|
||||
|
||||
demoDevices.forEach(device => {
|
||||
const item = createUnpairedDeviceItem(device);
|
||||
unpairedList.insertBefore(item, noDevices);
|
||||
});
|
||||
|
||||
showStatus('devices-status', `Demo: ${t('devices.found', { count: 2 })}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function createUnpairedDeviceItem(device) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-item unpaired';
|
||||
item.dataset.id = device.id;
|
||||
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
const unknownDevice = getCurrentLanguage() === 'en' ? 'Unknown Device' : 'Unbekanntes Gerät';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="device-info">
|
||||
<span class="device-icon">${icon}</span>
|
||||
<div class="device-details">
|
||||
<span class="device-name">${device.name || unknownDevice}</span>
|
||||
<span class="device-id">${device.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-small" onclick="pairDevice('${device.id}', '${device.name || unknownDevice}', '${device.type || 'unknown'}')">
|
||||
➕ ${t('btn.add')}
|
||||
</button>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async function pairDevice(id, name, type) {
|
||||
try {
|
||||
const response = await fetch('/api/devices/pair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('devices-status', t('devices.added', { name }), 'success');
|
||||
// Entferne aus unpaired Liste
|
||||
document.querySelector(`.device-item[data-id="${id}"]`)?.remove();
|
||||
// Lade paired Geräte neu
|
||||
loadPairedDevices();
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
showStatus('devices-status', `Demo: ${t('devices.added', { name })}`, 'success');
|
||||
document.querySelector(`.device-item.unpaired[data-id="${id}"]`)?.remove();
|
||||
|
||||
// Füge zu Demo-Liste hinzu
|
||||
pairedDevices.push({ id, name, type });
|
||||
renderPairedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPairedDevices() {
|
||||
try {
|
||||
const response = await fetch('/api/devices/paired');
|
||||
pairedDevices = await response.json();
|
||||
renderPairedDevices();
|
||||
} catch (error) {
|
||||
// Keep demo data
|
||||
renderPairedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPairedDevices() {
|
||||
const list = document.getElementById('paired-devices');
|
||||
const noDevices = document.getElementById('no-paired-devices');
|
||||
|
||||
// Remove previous entries
|
||||
list.querySelectorAll('.device-item').forEach(el => el.remove());
|
||||
|
||||
if (pairedDevices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
noDevices.style.display = 'none';
|
||||
pairedDevices.forEach(device => {
|
||||
const item = createPairedDeviceItem(device);
|
||||
list.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
|
||||
// Also update the control page
|
||||
renderDevicesControl();
|
||||
}
|
||||
|
||||
function createPairedDeviceItem(device) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-item paired';
|
||||
item.dataset.id = device.id;
|
||||
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
const placeholder = getCurrentLanguage() === 'en' ? 'Device name' : 'Gerätename';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="device-info">
|
||||
<span class="device-icon">${icon}</span>
|
||||
<div class="device-details">
|
||||
<input type="text" class="device-name-input" value="${device.name}"
|
||||
onchange="updateDeviceName('${device.id}', this.value)"
|
||||
placeholder="${placeholder}">
|
||||
<span class="device-id">${device.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small btn-danger" onclick="unpairDevice('${device.id}', '${device.name}')">
|
||||
🗑️
|
||||
</button>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async function updateDeviceName(id, newName) {
|
||||
try {
|
||||
const response = await fetch('/api/devices/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name: newName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('devices-status', t('devices.name.updated'), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode - update locally
|
||||
const device = pairedDevices.find(d => d.id === id);
|
||||
if (device) device.name = newName;
|
||||
showStatus('devices-status', `Demo: ${t('devices.name.updated')}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function unpairDevice(id, name) {
|
||||
if (!confirm(t('devices.confirm.remove', { name }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/devices/unpair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('devices-status', t('devices.removed', { name }), 'success');
|
||||
loadPairedDevices();
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
pairedDevices = pairedDevices.filter(d => d.id !== id);
|
||||
renderPairedDevices();
|
||||
showStatus('devices-status', `Demo: ${t('devices.removed', { name })}`, 'success');
|
||||
}
|
||||
}
|
||||
445
firmware/storage/www/js/i18n.js
Normal file
445
firmware/storage/www/js/i18n.js
Normal file
@@ -0,0 +1,445 @@
|
||||
// Internationalization (i18n) - Language support
|
||||
// Supported languages: German (de), English (en)
|
||||
|
||||
const translations = {
|
||||
de: {
|
||||
// Page
|
||||
'page.title': 'System Control',
|
||||
|
||||
// Main Tabs
|
||||
'tab.control': '🎛️ Bedienung',
|
||||
'tab.config': '⚙️ Konfiguration',
|
||||
|
||||
// Sub Tabs
|
||||
'subtab.wifi': '📶 WLAN',
|
||||
'subtab.schema': '💡 Schema',
|
||||
'subtab.devices': '🔗 Geräte',
|
||||
'subtab.scenes': '🎬 Szenen',
|
||||
|
||||
// Light Control
|
||||
'control.light.title': 'Lichtsteuerung',
|
||||
'control.light.onoff': 'Ein/Aus',
|
||||
'control.light.light': 'Licht',
|
||||
'control.mode.title': 'Betriebsmodus',
|
||||
'control.schema.active': 'Aktives Schema',
|
||||
'control.status.title': 'Aktueller Status',
|
||||
'control.status.mode': 'Modus',
|
||||
'control.status.schema': 'Schema',
|
||||
'control.status.color': 'Aktuelle Farbe',
|
||||
|
||||
// Common
|
||||
'common.on': 'AN',
|
||||
'common.off': 'AUS',
|
||||
'common.loading': 'Wird geladen...',
|
||||
|
||||
// Modes
|
||||
'mode.day': 'Tag',
|
||||
'mode.night': 'Nacht',
|
||||
'mode.simulation': 'Simulation',
|
||||
|
||||
// Schema names
|
||||
'schema.name.1': 'Schema 1 (Standard)',
|
||||
'schema.name.2': 'Schema 2 (Warm)',
|
||||
'schema.name.3': 'Schema 3 (Natur)',
|
||||
|
||||
// Scenes
|
||||
'scenes.title': 'Szenen',
|
||||
'scenes.empty': 'Keine Szenen definiert',
|
||||
'scenes.empty.hint': 'Erstelle Szenen unter Konfiguration',
|
||||
'scenes.manage.title': 'Szenen verwalten',
|
||||
'scenes.manage.desc': 'Erstelle und bearbeite Szenen für schnellen Zugriff',
|
||||
'scenes.config.empty': 'Keine Szenen erstellt',
|
||||
'scenes.config.empty.hint': 'Klicke auf "Neue Szene" um eine Szene zu erstellen',
|
||||
'scenes.activated': '"{name}" aktiviert',
|
||||
'scenes.created': 'Szene erstellt',
|
||||
'scenes.updated': 'Szene aktualisiert',
|
||||
'scenes.deleted': '"{name}" gelöscht',
|
||||
'scenes.confirm.delete': '"{name}" wirklich löschen?',
|
||||
'scenes.error.name': 'Bitte Namen eingeben',
|
||||
|
||||
// Devices
|
||||
'devices.external': 'Externe Geräte',
|
||||
'devices.control.empty': 'Keine Geräte hinzugefügt',
|
||||
'devices.control.empty.hint': 'Füge Geräte unter Konfiguration hinzu',
|
||||
'devices.new.title': 'Neue Geräte',
|
||||
'devices.new.desc': 'Unprovisionierte Matter-Geräte in der Nähe',
|
||||
'devices.searching': 'Suche nach Geräten...',
|
||||
'devices.unpaired.empty': 'Keine neuen Geräte gefunden',
|
||||
'devices.unpaired.empty.hint': 'Drücke "Geräte suchen" um nach Matter-Geräten zu suchen',
|
||||
'devices.paired.title': 'Zugeordnete Geräte',
|
||||
'devices.paired.desc': 'Bereits hinzugefügte externe Geräte',
|
||||
'devices.paired.empty': 'Keine Geräte hinzugefügt',
|
||||
'devices.none.available': 'Keine Geräte verfügbar',
|
||||
'devices.found': '{count} Gerät(e) gefunden',
|
||||
'devices.added': '"{name}" erfolgreich hinzugefügt',
|
||||
'devices.removed': '"{name}" entfernt',
|
||||
'devices.name.updated': 'Name aktualisiert',
|
||||
'devices.confirm.remove': '"{name}" wirklich entfernen?',
|
||||
|
||||
// WiFi
|
||||
'wifi.config.title': 'WLAN Konfiguration',
|
||||
'wifi.ssid': 'WLAN Name (SSID)',
|
||||
'wifi.ssid.placeholder': 'Netzwerkname eingeben',
|
||||
'wifi.password': 'WLAN Passwort',
|
||||
'wifi.password.short': 'Passwort',
|
||||
'wifi.password.placeholder': 'Passwort eingeben',
|
||||
'wifi.available': 'Verfügbare Netzwerke',
|
||||
'wifi.scan.hint': 'Nach Netzwerken suchen...',
|
||||
'wifi.status.title': 'Verbindungsstatus',
|
||||
'wifi.status.status': 'Status:',
|
||||
'wifi.status.ip': 'IP-Adresse:',
|
||||
'wifi.status.signal': 'Signal:',
|
||||
'wifi.connected': '✅ Verbunden',
|
||||
'wifi.disconnected': '❌ Nicht verbunden',
|
||||
'wifi.unavailable': '⚠️ Status nicht verfügbar',
|
||||
'wifi.searching': 'Suche läuft...',
|
||||
'wifi.scan.error': 'Fehler beim Scannen',
|
||||
'wifi.scan.failed': 'Netzwerksuche fehlgeschlagen',
|
||||
'wifi.saved': 'WLAN-Konfiguration gespeichert! Gerät verbindet sich...',
|
||||
'wifi.error.ssid': 'Bitte WLAN-Name eingeben',
|
||||
'wifi.error.save': 'Fehler beim Speichern',
|
||||
'wifi.networks.found': '{count} Netzwerk(e) gefunden',
|
||||
|
||||
// Schema Editor
|
||||
'schema.editor.title': 'Licht-Schema Editor',
|
||||
'schema.file': 'Schema-Datei',
|
||||
'schema.loading': 'Schema wird geladen...',
|
||||
'schema.header.time': 'Zeit',
|
||||
'schema.header.color': 'Farbe',
|
||||
'schema.loaded': '{file} erfolgreich geladen',
|
||||
'schema.saved': '{file} erfolgreich gespeichert!',
|
||||
'schema.demo': 'Demo-Daten geladen (Server nicht erreichbar)',
|
||||
|
||||
// Color Modal
|
||||
'modal.color.title': 'Farbe wählen',
|
||||
|
||||
// Scene Modal
|
||||
'modal.scene.new': 'Neue Szene erstellen',
|
||||
'modal.scene.edit': 'Szene bearbeiten',
|
||||
'scene.name': 'Name',
|
||||
'scene.name.placeholder': 'z.B. Abendstimmung',
|
||||
'scene.icon': 'Icon auswählen',
|
||||
'scene.actions': 'Aktionen',
|
||||
'scene.action.light': 'Licht Ein/Aus',
|
||||
'scene.action.mode': 'Modus setzen',
|
||||
'scene.action.schema': 'Schema wählen',
|
||||
'scene.light.on': 'Einschalten',
|
||||
'scene.light.off': 'Ausschalten',
|
||||
|
||||
// Buttons
|
||||
'btn.scan': '🔍 Suchen',
|
||||
'btn.save': '💾 Speichern',
|
||||
'btn.load': '🔄 Laden',
|
||||
'btn.cancel': 'Abbrechen',
|
||||
'btn.apply': 'Übernehmen',
|
||||
'btn.new.scene': '➕ Neue Szene',
|
||||
'btn.scan.devices': '🔍 Geräte suchen',
|
||||
'btn.add': 'Hinzufügen',
|
||||
'btn.remove': 'Entfernen',
|
||||
'btn.edit': 'Bearbeiten',
|
||||
'btn.delete': 'Löschen',
|
||||
|
||||
// Captive Portal
|
||||
'captive.title': 'System Control - WLAN Setup',
|
||||
'captive.subtitle': 'WLAN-Einrichtung',
|
||||
'captive.scan': '📡 Netzwerke suchen',
|
||||
'captive.scanning': 'Suche nach Netzwerken...',
|
||||
'captive.or.manual': 'oder manuell eingeben',
|
||||
'captive.password.placeholder': 'WLAN-Passwort',
|
||||
'captive.connect': '💾 Verbinden',
|
||||
'captive.note.title': 'Hinweis:',
|
||||
'captive.note.text': 'Nach dem Speichern verbindet sich das Gerät mit dem gewählten Netzwerk. Diese Seite wird dann nicht mehr erreichbar sein. Verbinden Sie sich mit Ihrem normalen WLAN, um auf das Gerät zuzugreifen.',
|
||||
'captive.connecting': 'Verbindung wird hergestellt... {seconds}s',
|
||||
'captive.done': 'Gerät sollte jetzt verbunden sein. Sie können diese Seite schließen.',
|
||||
|
||||
// General
|
||||
'loading': 'Laden...',
|
||||
'error': 'Fehler',
|
||||
'success': 'Erfolg'
|
||||
},
|
||||
|
||||
en: {
|
||||
// Page
|
||||
'page.title': 'System Control',
|
||||
|
||||
// Main Tabs
|
||||
'tab.control': '🎛️ Control',
|
||||
'tab.config': '⚙️ Settings',
|
||||
|
||||
// Sub Tabs
|
||||
'subtab.wifi': '📶 WiFi',
|
||||
'subtab.schema': '💡 Schema',
|
||||
'subtab.devices': '🔗 Devices',
|
||||
'subtab.scenes': '🎬 Scenes',
|
||||
|
||||
// Light Control
|
||||
'control.light.title': 'Light Control',
|
||||
'control.light.onoff': 'On/Off',
|
||||
'control.light.light': 'Light',
|
||||
'control.mode.title': 'Operating Mode',
|
||||
'control.schema.active': 'Active Schema',
|
||||
'control.status.title': 'Current Status',
|
||||
'control.status.mode': 'Mode',
|
||||
'control.status.schema': 'Schema',
|
||||
'control.status.color': 'Current Color',
|
||||
|
||||
// Common
|
||||
'common.on': 'ON',
|
||||
'common.off': 'OFF',
|
||||
'common.loading': 'Loading...',
|
||||
|
||||
// Modes
|
||||
'mode.day': 'Day',
|
||||
'mode.night': 'Night',
|
||||
'mode.simulation': 'Simulation',
|
||||
|
||||
// Schema names
|
||||
'schema.name.1': 'Schema 1 (Standard)',
|
||||
'schema.name.2': 'Schema 2 (Warm)',
|
||||
'schema.name.3': 'Schema 3 (Natural)',
|
||||
|
||||
// Scenes
|
||||
'scenes.title': 'Scenes',
|
||||
'scenes.empty': 'No scenes defined',
|
||||
'scenes.empty.hint': 'Create scenes in settings',
|
||||
'scenes.manage.title': 'Manage Scenes',
|
||||
'scenes.manage.desc': 'Create and edit scenes for quick access',
|
||||
'scenes.config.empty': 'No scenes created',
|
||||
'scenes.config.empty.hint': 'Click "New Scene" to create a scene',
|
||||
'scenes.activated': '"{name}" activated',
|
||||
'scenes.created': 'Scene created',
|
||||
'scenes.updated': 'Scene updated',
|
||||
'scenes.deleted': '"{name}" deleted',
|
||||
'scenes.confirm.delete': 'Really delete "{name}"?',
|
||||
'scenes.error.name': 'Please enter a name',
|
||||
|
||||
// Devices
|
||||
'devices.external': 'External Devices',
|
||||
'devices.control.empty': 'No devices added',
|
||||
'devices.control.empty.hint': 'Add devices in settings',
|
||||
'devices.new.title': 'New Devices',
|
||||
'devices.new.desc': 'Unprovisioned Matter devices nearby',
|
||||
'devices.searching': 'Searching for devices...',
|
||||
'devices.unpaired.empty': 'No new devices found',
|
||||
'devices.unpaired.empty.hint': 'Press "Scan devices" to search for Matter devices',
|
||||
'devices.paired.title': 'Paired Devices',
|
||||
'devices.paired.desc': 'Already added external devices',
|
||||
'devices.paired.empty': 'No devices added',
|
||||
'devices.none.available': 'No devices available',
|
||||
'devices.found': '{count} device(s) found',
|
||||
'devices.added': '"{name}" added successfully',
|
||||
'devices.removed': '"{name}" removed',
|
||||
'devices.name.updated': 'Name updated',
|
||||
'devices.confirm.remove': 'Really remove "{name}"?',
|
||||
|
||||
// WiFi
|
||||
'wifi.config.title': 'WiFi Configuration',
|
||||
'wifi.ssid': 'WiFi Name (SSID)',
|
||||
'wifi.ssid.placeholder': 'Enter network name',
|
||||
'wifi.password': 'WiFi Password',
|
||||
'wifi.password.short': 'Password',
|
||||
'wifi.password.placeholder': 'Enter password',
|
||||
'wifi.available': 'Available Networks',
|
||||
'wifi.scan.hint': 'Search for networks...',
|
||||
'wifi.status.title': 'Connection Status',
|
||||
'wifi.status.status': 'Status:',
|
||||
'wifi.status.ip': 'IP Address:',
|
||||
'wifi.status.signal': 'Signal:',
|
||||
'wifi.connected': '✅ Connected',
|
||||
'wifi.disconnected': '❌ Not connected',
|
||||
'wifi.unavailable': '⚠️ Status unavailable',
|
||||
'wifi.searching': 'Searching...',
|
||||
'wifi.scan.error': 'Scan error',
|
||||
'wifi.scan.failed': 'Network scan failed',
|
||||
'wifi.saved': 'WiFi configuration saved! Device connecting...',
|
||||
'wifi.error.ssid': 'Please enter WiFi name',
|
||||
'wifi.error.save': 'Error saving',
|
||||
'wifi.networks.found': '{count} network(s) found',
|
||||
|
||||
// Schema Editor
|
||||
'schema.editor.title': 'Light Schema Editor',
|
||||
'schema.file': 'Schema File',
|
||||
'schema.loading': 'Loading schema...',
|
||||
'schema.header.time': 'Time',
|
||||
'schema.header.color': 'Color',
|
||||
'schema.loaded': '{file} loaded successfully',
|
||||
'schema.saved': '{file} saved successfully!',
|
||||
'schema.demo': 'Demo data loaded (server unreachable)',
|
||||
|
||||
// Color Modal
|
||||
'modal.color.title': 'Choose Color',
|
||||
|
||||
// Scene Modal
|
||||
'modal.scene.new': 'Create New Scene',
|
||||
'modal.scene.edit': 'Edit Scene',
|
||||
'scene.name': 'Name',
|
||||
'scene.name.placeholder': 'e.g. Evening Mood',
|
||||
'scene.icon': 'Choose Icon',
|
||||
'scene.actions': 'Actions',
|
||||
'scene.action.light': 'Light On/Off',
|
||||
'scene.action.mode': 'Set Mode',
|
||||
'scene.action.schema': 'Choose Schema',
|
||||
'scene.light.on': 'Turn On',
|
||||
'scene.light.off': 'Turn Off',
|
||||
|
||||
// Buttons
|
||||
'btn.scan': '🔍 Scan',
|
||||
'btn.save': '💾 Save',
|
||||
'btn.load': '🔄 Load',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.apply': 'Apply',
|
||||
'btn.new.scene': '➕ New Scene',
|
||||
'btn.scan.devices': '🔍 Scan Devices',
|
||||
'btn.add': 'Add',
|
||||
'btn.remove': 'Remove',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.delete': 'Delete',
|
||||
|
||||
// Captive Portal
|
||||
'captive.title': 'System Control - WiFi Setup',
|
||||
'captive.subtitle': 'WiFi Setup',
|
||||
'captive.scan': '📡 Scan Networks',
|
||||
'captive.scanning': 'Scanning for networks...',
|
||||
'captive.or.manual': 'or enter manually',
|
||||
'captive.password.placeholder': 'WiFi password',
|
||||
'captive.connect': '💾 Connect',
|
||||
'captive.note.title': 'Note:',
|
||||
'captive.note.text': 'After saving, the device will connect to the selected network. This page will no longer be accessible. Connect to your regular WiFi to access the device.',
|
||||
'captive.connecting': 'Connecting... {seconds}s',
|
||||
'captive.done': 'Device should now be connected. You can close this page.',
|
||||
|
||||
// General
|
||||
'loading': 'Loading...',
|
||||
'error': 'Error',
|
||||
'success': 'Success'
|
||||
}
|
||||
};
|
||||
|
||||
// Current language
|
||||
let currentLang = localStorage.getItem('lang') || 'de';
|
||||
|
||||
/**
|
||||
* Get translation for a key
|
||||
* @param {string} key - Translation key
|
||||
* @param {object} params - Optional parameters for interpolation
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
function t(key, params = {}) {
|
||||
const lang = translations[currentLang] || translations.de;
|
||||
let text = lang[key] || translations.de[key] || key;
|
||||
|
||||
// Replace parameters like {count}, {name}, etc.
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current language
|
||||
* @param {string} lang - Language code ('de' or 'en')
|
||||
*/
|
||||
function setLanguage(lang) {
|
||||
if (translations[lang]) {
|
||||
currentLang = lang;
|
||||
localStorage.setItem('lang', lang);
|
||||
document.documentElement.lang = lang;
|
||||
updatePageLanguage();
|
||||
updateLanguageToggle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between languages
|
||||
*/
|
||||
function toggleLanguage() {
|
||||
setLanguage(currentLang === 'de' ? 'en' : 'de');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
* @returns {string} Current language code
|
||||
*/
|
||||
function getCurrentLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all elements with data-i18n attribute
|
||||
*/
|
||||
function updatePageLanguage() {
|
||||
// Update elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-placeholder attribute
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.placeholder = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-title attribute
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.title = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-aria attribute
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-aria');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.setAttribute('aria-label', translated);
|
||||
}
|
||||
});
|
||||
|
||||
// Update page title
|
||||
const titleEl = document.querySelector('title[data-i18n]');
|
||||
if (titleEl) {
|
||||
document.title = t(titleEl.getAttribute('data-i18n'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update language toggle button
|
||||
*/
|
||||
function updateLanguageToggle() {
|
||||
const langFlag = document.getElementById('lang-flag');
|
||||
const langLabel = document.getElementById('lang-label');
|
||||
|
||||
if (langFlag) {
|
||||
langFlag.textContent = currentLang === 'de' ? '🇩🇪' : '🇬🇧';
|
||||
}
|
||||
if (langLabel) {
|
||||
langLabel.textContent = currentLang.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n
|
||||
*/
|
||||
function initI18n() {
|
||||
// Check browser language as fallback
|
||||
if (!localStorage.getItem('lang')) {
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (translations[browserLang]) {
|
||||
currentLang = browserLang;
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.lang = currentLang;
|
||||
updatePageLanguage();
|
||||
updateLanguageToggle();
|
||||
}
|
||||
102
firmware/storage/www/js/light.js
Normal file
102
firmware/storage/www/js/light.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// Light control
|
||||
async function toggleLight() {
|
||||
lightOn = !lightOn;
|
||||
updateLightToggle();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/light/power', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ on: lightOn })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('light-status', `${t('control.light.light')} ${lightOn ? t('common.on') : t('common.off')}`, 'success');
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('light-status', `Demo: ${t('control.light.light')} ${lightOn ? t('common.on') : t('common.off')}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function updateLightToggle() {
|
||||
const toggle = document.getElementById('light-toggle');
|
||||
const state = document.getElementById('light-state');
|
||||
const icon = document.getElementById('light-icon');
|
||||
|
||||
if (lightOn) {
|
||||
toggle.classList.add('active');
|
||||
state.textContent = t('common.on');
|
||||
icon.textContent = '💡';
|
||||
} else {
|
||||
toggle.classList.remove('active');
|
||||
state.textContent = t('common.off');
|
||||
icon.textContent = '💡';
|
||||
}
|
||||
}
|
||||
|
||||
// Mode control
|
||||
async function setMode(mode) {
|
||||
currentMode = mode;
|
||||
updateModeButtons();
|
||||
updateSimulationOptions();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/light/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const modeName = t(`mode.${mode}`);
|
||||
showStatus('mode-status', `${t('control.status.mode')}: "${modeName}"`, 'success');
|
||||
document.getElementById('current-mode').textContent = modeName;
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
const modeName = t(`mode.${mode}`);
|
||||
showStatus('mode-status', `Demo: ${t('control.status.mode')} "${modeName}"`, 'success');
|
||||
document.getElementById('current-mode').textContent = modeName;
|
||||
}
|
||||
}
|
||||
|
||||
function updateModeButtons() {
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(`mode-${currentMode}`).classList.add('active');
|
||||
}
|
||||
|
||||
function updateSimulationOptions() {
|
||||
const options = document.getElementById('simulation-options');
|
||||
if (currentMode === 'simulation') {
|
||||
options.classList.add('visible');
|
||||
} else {
|
||||
options.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveSchema() {
|
||||
const schema = document.getElementById('active-schema').value;
|
||||
const schemaNum = schema.replace('schema_0', '').replace('.csv', '');
|
||||
const schemaName = t(`schema.name.${schemaNum}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/light/schema', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ schema })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('mode-status', `${t('control.status.schema')}: "${schemaName}"`, 'success');
|
||||
document.getElementById('current-schema').textContent = schemaName;
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('mode-status', `Demo: ${schemaName}`, 'success');
|
||||
document.getElementById('current-schema').textContent = schemaName;
|
||||
}
|
||||
}
|
||||
330
firmware/storage/www/js/scenes.js
Normal file
330
firmware/storage/www/js/scenes.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// Scene functions
|
||||
async function loadScenes() {
|
||||
try {
|
||||
const response = await fetch('/api/scenes');
|
||||
scenes = await response.json();
|
||||
} catch (error) {
|
||||
// Demo data
|
||||
scenes = [
|
||||
{ id: 'scene-1', name: 'Abendstimmung', icon: '🌅', actions: { light: 'on', mode: 'simulation', schema: 'schema_02.csv' } },
|
||||
{ id: 'scene-2', name: 'Nachtmodus', icon: '🌙', actions: { light: 'on', mode: 'night' } }
|
||||
];
|
||||
}
|
||||
renderScenesConfig();
|
||||
renderScenesControl();
|
||||
}
|
||||
|
||||
function renderScenesConfig() {
|
||||
const list = document.getElementById('scenes-config-list');
|
||||
const noScenes = document.getElementById('no-scenes-config');
|
||||
|
||||
list.querySelectorAll('.scene-config-item').forEach(el => el.remove());
|
||||
|
||||
if (scenes.length === 0) {
|
||||
noScenes.style.display = 'flex';
|
||||
} else {
|
||||
noScenes.style.display = 'none';
|
||||
scenes.forEach(scene => {
|
||||
const item = createSceneConfigItem(scene);
|
||||
list.insertBefore(item, noScenes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createSceneConfigItem(scene) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-config-item';
|
||||
item.dataset.id = scene.id;
|
||||
|
||||
const actionsText = [];
|
||||
if (scene.actions.light) actionsText.push(`${t('control.light.light')} ${scene.actions.light === 'on' ? t('common.on') : t('common.off')}`);
|
||||
if (scene.actions.mode) actionsText.push(`${t('control.status.mode')}: ${t('mode.' + scene.actions.mode)}`);
|
||||
if (scene.actions.schema) actionsText.push(`${t('control.status.schema')}: ${scene.actions.schema.replace('.csv', '')}`);
|
||||
if (scene.actions.devices && scene.actions.devices.length > 0) {
|
||||
actionsText.push(t('devices.found', { count: scene.actions.devices.length }));
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="scene-info">
|
||||
<span class="scene-icon">${scene.icon}</span>
|
||||
<div class="scene-details">
|
||||
<span class="scene-name">${scene.name}</span>
|
||||
<span class="scene-actions-text">${actionsText.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scene-buttons">
|
||||
<button class="btn btn-secondary btn-small" onclick="editScene('${scene.id}')">✏️</button>
|
||||
<button class="btn btn-secondary btn-small btn-danger" onclick="deleteScene('${scene.id}', '${scene.name}')">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderScenesControl() {
|
||||
const list = document.getElementById('scenes-control-list');
|
||||
const noScenes = document.getElementById('no-scenes-control');
|
||||
|
||||
list.querySelectorAll('.scene-btn').forEach(el => el.remove());
|
||||
|
||||
if (scenes.length === 0) {
|
||||
noScenes.style.display = 'flex';
|
||||
} else {
|
||||
noScenes.style.display = 'none';
|
||||
scenes.forEach(scene => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'scene-btn';
|
||||
btn.onclick = () => activateScene(scene.id);
|
||||
btn.innerHTML = `
|
||||
<span class="scene-btn-icon">${scene.icon}</span>
|
||||
<span class="scene-btn-name">${scene.name}</span>
|
||||
`;
|
||||
list.insertBefore(btn, noScenes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openSceneModal() {
|
||||
currentEditScene = null;
|
||||
selectedSceneIcon = '🌅';
|
||||
document.getElementById('scene-modal-title').textContent = t('modal.scene.new');
|
||||
document.getElementById('scene-name').value = '';
|
||||
document.getElementById('scene-action-light').checked = true;
|
||||
document.getElementById('scene-light-state').value = 'on';
|
||||
document.getElementById('scene-action-mode').checked = false;
|
||||
document.getElementById('scene-mode-value').value = 'simulation';
|
||||
document.getElementById('scene-action-schema').checked = false;
|
||||
document.getElementById('scene-schema-value').value = 'schema_01.csv';
|
||||
|
||||
document.querySelectorAll('.icon-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.icon === '🌅');
|
||||
});
|
||||
|
||||
renderSceneDevicesList();
|
||||
|
||||
document.getElementById('scene-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function editScene(sceneId) {
|
||||
const scene = scenes.find(s => s.id === sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
currentEditScene = sceneId;
|
||||
selectedSceneIcon = scene.icon;
|
||||
document.getElementById('scene-modal-title').textContent = t('modal.scene.edit');
|
||||
document.getElementById('scene-name').value = scene.name;
|
||||
|
||||
document.getElementById('scene-action-light').checked = !!scene.actions.light;
|
||||
document.getElementById('scene-light-state').value = scene.actions.light || 'on';
|
||||
document.getElementById('scene-action-mode').checked = !!scene.actions.mode;
|
||||
document.getElementById('scene-mode-value').value = scene.actions.mode || 'simulation';
|
||||
document.getElementById('scene-action-schema').checked = !!scene.actions.schema;
|
||||
document.getElementById('scene-schema-value').value = scene.actions.schema || 'schema_01.csv';
|
||||
|
||||
document.querySelectorAll('.icon-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.icon === scene.icon);
|
||||
});
|
||||
|
||||
renderSceneDevicesList(scene.actions.devices || []);
|
||||
|
||||
document.getElementById('scene-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Render device list in scene modal
|
||||
function renderSceneDevicesList(selectedDevices = []) {
|
||||
const list = document.getElementById('scene-devices-list');
|
||||
const noDevices = document.getElementById('no-scene-devices');
|
||||
|
||||
// Remove previous entries (except empty-state)
|
||||
list.querySelectorAll('.scene-device-item').forEach(el => el.remove());
|
||||
|
||||
if (pairedDevices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
noDevices.style.display = 'none';
|
||||
pairedDevices.forEach(device => {
|
||||
const selectedDevice = selectedDevices.find(d => d.id === device.id);
|
||||
const isSelected = !!selectedDevice;
|
||||
const deviceState = selectedDevice ? selectedDevice.state : 'on';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-device-item';
|
||||
item.dataset.id = device.id;
|
||||
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
|
||||
item.innerHTML = `
|
||||
<label class="scene-device-checkbox">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''}
|
||||
onchange="toggleSceneDevice('${device.id}')">
|
||||
<span class="device-icon">${icon}</span>
|
||||
<span class="device-name">${device.name}</span>
|
||||
</label>
|
||||
${device.type === 'light' ? `
|
||||
<select class="scene-device-state" id="scene-device-state-${device.id}"
|
||||
${!isSelected ? 'disabled' : ''}>
|
||||
<option value="on" ${deviceState === 'on' ? 'selected' : ''}>${t('scene.light.on')}</option>
|
||||
<option value="off" ${deviceState === 'off' ? 'selected' : ''}>${t('scene.light.off')}</option>
|
||||
</select>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
list.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSceneDevice(deviceId) {
|
||||
const stateSelect = document.getElementById(`scene-device-state-${deviceId}`);
|
||||
if (stateSelect) {
|
||||
const checkbox = document.querySelector(`.scene-device-item[data-id="${deviceId}"] input[type="checkbox"]`);
|
||||
stateSelect.disabled = !checkbox.checked;
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedSceneDevices() {
|
||||
const devices = [];
|
||||
document.querySelectorAll('.scene-device-item').forEach(item => {
|
||||
const checkbox = item.querySelector('input[type="checkbox"]');
|
||||
if (checkbox && checkbox.checked) {
|
||||
const deviceId = item.dataset.id;
|
||||
const stateSelect = document.getElementById(`scene-device-state-${deviceId}`);
|
||||
devices.push({
|
||||
id: deviceId,
|
||||
state: stateSelect ? stateSelect.value : 'on'
|
||||
});
|
||||
}
|
||||
});
|
||||
return devices;
|
||||
}
|
||||
|
||||
function closeSceneModal() {
|
||||
document.getElementById('scene-modal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentEditScene = null;
|
||||
}
|
||||
|
||||
function selectSceneIcon(icon) {
|
||||
selectedSceneIcon = icon;
|
||||
document.querySelectorAll('.icon-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.icon === icon);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveScene() {
|
||||
const name = document.getElementById('scene-name').value.trim();
|
||||
if (!name) {
|
||||
showStatus('scenes-status', t('scenes.error.name'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = {};
|
||||
if (document.getElementById('scene-action-light').checked) {
|
||||
actions.light = document.getElementById('scene-light-state').value;
|
||||
}
|
||||
if (document.getElementById('scene-action-mode').checked) {
|
||||
actions.mode = document.getElementById('scene-mode-value').value;
|
||||
}
|
||||
if (document.getElementById('scene-action-schema').checked) {
|
||||
actions.schema = document.getElementById('scene-schema-value').value;
|
||||
}
|
||||
|
||||
// Add device actions
|
||||
const selectedDevices = getSelectedSceneDevices();
|
||||
if (selectedDevices.length > 0) {
|
||||
actions.devices = selectedDevices;
|
||||
}
|
||||
|
||||
const sceneData = {
|
||||
id: currentEditScene || `scene-${Date.now()}`,
|
||||
name,
|
||||
icon: selectedSceneIcon,
|
||||
actions
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scenes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sceneData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('scenes-status', currentEditScene ? t('scenes.updated') : t('scenes.created'), 'success');
|
||||
loadScenes();
|
||||
closeSceneModal();
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
if (currentEditScene) {
|
||||
const index = scenes.findIndex(s => s.id === currentEditScene);
|
||||
if (index !== -1) scenes[index] = sceneData;
|
||||
} else {
|
||||
scenes.push(sceneData);
|
||||
}
|
||||
renderScenesConfig();
|
||||
renderScenesControl();
|
||||
showStatus('scenes-status', `Demo: ${currentEditScene ? t('scenes.updated') : t('scenes.created')}`, 'success');
|
||||
closeSceneModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScene(sceneId, name) {
|
||||
if (!confirm(t('scenes.confirm.delete', { name }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scenes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: sceneId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('scenes-status', t('scenes.deleted', { name }), 'success');
|
||||
loadScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
scenes = scenes.filter(s => s.id !== sceneId);
|
||||
renderScenesConfig();
|
||||
renderScenesControl();
|
||||
showStatus('scenes-status', `Demo: ${t('scenes.deleted', { name })}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function activateScene(sceneId) {
|
||||
const scene = scenes.find(s => s.id === sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/scenes/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: sceneId })
|
||||
});
|
||||
showStatus('scenes-control-status', t('scenes.activated', { name: scene.name }), 'success');
|
||||
} catch (error) {
|
||||
// Demo: Execute actions
|
||||
if (scene.actions.light === 'on') {
|
||||
lightOn = true;
|
||||
updateLightToggle();
|
||||
} else if (scene.actions.light === 'off') {
|
||||
lightOn = false;
|
||||
updateLightToggle();
|
||||
}
|
||||
if (scene.actions.mode) {
|
||||
currentMode = scene.actions.mode;
|
||||
updateModeButtons();
|
||||
updateSimulationOptions();
|
||||
}
|
||||
// Device actions in demo mode
|
||||
if (scene.actions.devices && scene.actions.devices.length > 0) {
|
||||
scene.actions.devices.forEach(deviceAction => {
|
||||
console.log(`Demo: Device ${deviceAction.id} -> ${deviceAction.state}`);
|
||||
});
|
||||
}
|
||||
showStatus('scenes-control-status', `Demo: ${t('scenes.activated', { name: scene.name })}`, 'success');
|
||||
}
|
||||
}
|
||||
208
firmware/storage/www/js/schema.js
Normal file
208
firmware/storage/www/js/schema.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// Schema functions
|
||||
async function loadSchema() {
|
||||
const schemaFile = document.getElementById('schema-select').value;
|
||||
const grid = document.getElementById('schema-grid');
|
||||
const loading = document.getElementById('schema-loading');
|
||||
|
||||
grid.innerHTML = '';
|
||||
loading.classList.add('active');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schema/${schemaFile}`);
|
||||
const text = await response.text();
|
||||
|
||||
schemaData = parseCSV(text);
|
||||
renderSchemaGrid();
|
||||
showStatus('schema-status', t('schema.loaded', { file: schemaFile }), 'success');
|
||||
} catch (error) {
|
||||
// Demo data for local testing
|
||||
schemaData = generateDemoData();
|
||||
renderSchemaGrid();
|
||||
showStatus('schema-status', t('schema.demo'), 'error');
|
||||
} finally {
|
||||
loading.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
const lines = text.trim().split('\n');
|
||||
return lines
|
||||
.filter(line => line.trim() && !line.startsWith('#'))
|
||||
.map(line => {
|
||||
const values = line.split(',').map(v => parseInt(v.trim()));
|
||||
return {
|
||||
r: values[0] || 0,
|
||||
g: values[1] || 0,
|
||||
b: values[2] || 0,
|
||||
v1: values[3] || 0,
|
||||
v2: values[4] || 0,
|
||||
v3: values[5] || 250
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateDemoData() {
|
||||
const data = [];
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const hour = i / 2;
|
||||
let r, g, b;
|
||||
|
||||
if (hour < 6 || hour >= 22) {
|
||||
r = 25; g = 25; b = 112;
|
||||
} else if (hour < 8) {
|
||||
const t = (hour - 6) / 2;
|
||||
r = Math.round(25 + 230 * t);
|
||||
g = Math.round(25 + 150 * t);
|
||||
b = Math.round(112 + 50 * t);
|
||||
} else if (hour < 18) {
|
||||
r = 255; g = 240; b = 220;
|
||||
} else {
|
||||
const t = (hour - 18) / 4;
|
||||
r = Math.round(255 - 230 * t);
|
||||
g = Math.round(240 - 215 * t);
|
||||
b = Math.round(220 - 108 * t);
|
||||
}
|
||||
|
||||
data.push({
|
||||
r, g, b,
|
||||
v1: 0,
|
||||
v2: Math.round(100 + 155 * Math.sin(Math.PI * hour / 12)),
|
||||
v3: 250
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderSchemaGrid() {
|
||||
const grid = document.getElementById('schema-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const hour = Math.floor(i / 2);
|
||||
const minute = (i % 2) * 30;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
const data = schemaData[i] || { r: 0, g: 0, b: 0, v1: 0, v2: 100, v3: 250 };
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'time-row';
|
||||
row.dataset.index = i;
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="time-label">${time}</span>
|
||||
<div class="color-preview"
|
||||
style="background: rgb(${data.r}, ${data.g}, ${data.b})"
|
||||
onclick="openColorModal(${i})"
|
||||
title="Tippen zum Bearbeiten"></div>
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.r}"
|
||||
onchange="updateValue(${i}, 'r', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.g}"
|
||||
onchange="updateValue(${i}, 'g', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.b}"
|
||||
onchange="updateValue(${i}, 'b', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v1}"
|
||||
onchange="updateValue(${i}, 'v1', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v2}"
|
||||
onchange="updateValue(${i}, 'v2', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v3}"
|
||||
onchange="updateValue(${i}, 'v3', this.value)">
|
||||
`;
|
||||
|
||||
grid.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue(index, field, value) {
|
||||
const numValue = Math.max(0, Math.min(255, parseInt(value) || 0));
|
||||
schemaData[index][field] = numValue;
|
||||
|
||||
const row = document.querySelector(`.time-row[data-index="${index}"]`);
|
||||
if (row) {
|
||||
const preview = row.querySelector('.color-preview');
|
||||
const data = schemaData[index];
|
||||
preview.style.background = `rgb(${data.r}, ${data.g}, ${data.b})`;
|
||||
}
|
||||
}
|
||||
|
||||
function openColorModal(index) {
|
||||
currentEditRow = index;
|
||||
const data = schemaData[index];
|
||||
|
||||
document.getElementById('rangeR').value = data.r;
|
||||
document.getElementById('rangeG').value = data.g;
|
||||
document.getElementById('rangeB').value = data.b;
|
||||
|
||||
const hour = Math.floor(index / 2);
|
||||
const minute = (index % 2) * 30;
|
||||
document.getElementById('modal-time').textContent =
|
||||
`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
updateModalColor();
|
||||
document.getElementById('color-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeColorModal() {
|
||||
document.getElementById('color-modal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentEditRow = null;
|
||||
}
|
||||
|
||||
function updateModalColor() {
|
||||
const r = document.getElementById('rangeR').value;
|
||||
const g = document.getElementById('rangeG').value;
|
||||
const b = document.getElementById('rangeB').value;
|
||||
|
||||
document.getElementById('valR').textContent = r;
|
||||
document.getElementById('valG').textContent = g;
|
||||
document.getElementById('valB').textContent = b;
|
||||
|
||||
document.getElementById('preview-large').style.background = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function applyColor() {
|
||||
if (currentEditRow === null) return;
|
||||
|
||||
const r = parseInt(document.getElementById('rangeR').value);
|
||||
const g = parseInt(document.getElementById('rangeG').value);
|
||||
const b = parseInt(document.getElementById('rangeB').value);
|
||||
|
||||
schemaData[currentEditRow].r = r;
|
||||
schemaData[currentEditRow].g = g;
|
||||
schemaData[currentEditRow].b = b;
|
||||
|
||||
const row = document.querySelector(`.time-row[data-index="${currentEditRow}"]`);
|
||||
if (row) {
|
||||
const inputs = row.querySelectorAll('.value-input');
|
||||
inputs[0].value = r;
|
||||
inputs[1].value = g;
|
||||
inputs[2].value = b;
|
||||
row.querySelector('.color-preview').style.background = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
closeColorModal();
|
||||
}
|
||||
|
||||
async function saveSchema() {
|
||||
const schemaFile = document.getElementById('schema-select').value;
|
||||
|
||||
const csv = schemaData.map(row =>
|
||||
`${row.r},${row.g},${row.b},${row.v1},${row.v2},${row.v3}`
|
||||
).join('\n');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schema/${schemaFile}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/csv' },
|
||||
body: csv
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('schema-status', t('schema.saved', { file: schemaFile }), 'success');
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('schema-status', t('error') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
53
firmware/storage/www/js/ui.js
Normal file
53
firmware/storage/www/js/ui.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const icon = document.getElementById('theme-icon');
|
||||
const label = document.getElementById('theme-label');
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||
|
||||
if (theme === 'light') {
|
||||
icon.textContent = '☀️';
|
||||
label.textContent = 'Light';
|
||||
metaTheme.content = '#f0f2f5';
|
||||
} else {
|
||||
icon.textContent = '🌙';
|
||||
label.textContent = 'Dark';
|
||||
metaTheme.content = '#1a1a2e';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
document.querySelector(`.tab[onclick="switchTab('${tabName}')"]`).classList.add('active');
|
||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||
}
|
||||
|
||||
// Sub-tab switching
|
||||
function switchSubTab(subTabName) {
|
||||
document.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.sub-tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
document.querySelector(`.sub-tab[onclick="switchSubTab('${subTabName}')"]`).classList.add('active');
|
||||
document.getElementById(`subtab-${subTabName}`).classList.add('active');
|
||||
|
||||
if (subTabName === 'schema' && schemaData.length === 0) {
|
||||
loadSchema();
|
||||
}
|
||||
}
|
||||
|
||||
// Note: showStatus is defined in wifi-shared.js (loaded first)
|
||||
138
firmware/storage/www/js/websocket.js
Normal file
138
firmware/storage/www/js/websocket.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// WebSocket connection
|
||||
function initWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
clearTimeout(wsReconnectTimer);
|
||||
// Request initial status
|
||||
ws.send(JSON.stringify({ type: 'getStatus' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch (e) {
|
||||
console.error('WebSocket message error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||
ws = null;
|
||||
wsReconnectTimer = setTimeout(initWebSocket, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.close();
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('WebSocket not available, using demo mode');
|
||||
initDemoMode();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'status':
|
||||
updateStatusFromData(data);
|
||||
break;
|
||||
case 'color':
|
||||
updateColorPreview(data.r, data.g, data.b);
|
||||
break;
|
||||
case 'wifi':
|
||||
updateWifiStatus(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusFromData(status) {
|
||||
if (status.on !== undefined) {
|
||||
lightOn = status.on;
|
||||
updateLightToggle();
|
||||
}
|
||||
|
||||
if (status.mode) {
|
||||
currentMode = status.mode;
|
||||
updateModeButtons();
|
||||
updateSimulationOptions();
|
||||
}
|
||||
|
||||
if (status.schema) {
|
||||
document.getElementById('active-schema').value = status.schema;
|
||||
const schemaNames = {
|
||||
'schema_01.csv': 'Schema 1',
|
||||
'schema_02.csv': 'Schema 2',
|
||||
'schema_03.csv': 'Schema 3'
|
||||
};
|
||||
document.getElementById('current-schema').textContent = schemaNames[status.schema] || status.schema;
|
||||
}
|
||||
|
||||
if (status.color) {
|
||||
updateColorPreview(status.color.r, status.color.g, status.color.b);
|
||||
}
|
||||
}
|
||||
|
||||
function updateColorPreview(r, g, b) {
|
||||
const colorPreview = document.getElementById('current-color');
|
||||
colorPreview.style.background = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function updateWifiStatus(status) {
|
||||
document.getElementById('conn-status').textContent = status.connected ? '✅ Verbunden' : '❌ Nicht verbunden';
|
||||
document.getElementById('conn-ip').textContent = status.ip || '-';
|
||||
document.getElementById('conn-rssi').textContent = status.rssi ? `${status.rssi} dBm` : '-';
|
||||
}
|
||||
|
||||
// Send via WebSocket
|
||||
function wsSend(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Demo mode for local testing
|
||||
function initDemoMode() {
|
||||
updateSimulationOptions();
|
||||
updateColorPreview(255, 240, 220);
|
||||
|
||||
// Simulate color changes in demo mode
|
||||
let hue = 0;
|
||||
setInterval(() => {
|
||||
if (!ws) {
|
||||
hue = (hue + 1) % 360;
|
||||
const rgb = hslToRgb(hue / 360, 0.7, 0.6);
|
||||
updateColorPreview(rgb.r, rgb.g, rgb.b);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
||||
}
|
||||
249
firmware/storage/www/js/wifi-shared.js
Normal file
249
firmware/storage/www/js/wifi-shared.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// Shared WiFi configuration functions
|
||||
// Used by both captive.html and index.html
|
||||
|
||||
/**
|
||||
* Show status message
|
||||
* @param {string} elementId - ID of the status element
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Type: 'success', 'error', or 'info'
|
||||
*/
|
||||
function showStatus(elementId, message, type) {
|
||||
const status = document.getElementById(elementId);
|
||||
if (!status) return;
|
||||
|
||||
status.textContent = message;
|
||||
status.className = `status ${type}`;
|
||||
|
||||
if (type !== 'info') {
|
||||
setTimeout(() => {
|
||||
status.className = 'status';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for available WiFi networks
|
||||
*/
|
||||
async function scanNetworks() {
|
||||
const loading = document.getElementById('loading');
|
||||
const networkList = document.getElementById('network-list');
|
||||
const select = document.getElementById('available-networks');
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
loading.classList.add('active');
|
||||
}
|
||||
if (networkList) {
|
||||
networkList.style.display = 'none';
|
||||
networkList.innerHTML = '';
|
||||
}
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="">${t('wifi.searching')}</option>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wifi/scan');
|
||||
const networks = await response.json();
|
||||
|
||||
if (loading) {
|
||||
loading.classList.remove('active');
|
||||
}
|
||||
|
||||
// Sort by signal strength
|
||||
networks.sort((a, b) => b.rssi - a.rssi);
|
||||
|
||||
// Render for captive portal (network list)
|
||||
if (networkList) {
|
||||
if (networks.length === 0) {
|
||||
networkList.innerHTML = `<div class="network-item"><span class="network-name">${t('devices.unpaired.empty')}</span></div>`;
|
||||
} else {
|
||||
networks.forEach(network => {
|
||||
const signalIcon = getSignalIcon(network.rssi);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'network-item';
|
||||
item.onclick = () => selectNetwork(network.ssid, item);
|
||||
item.innerHTML = `
|
||||
<span class="network-name">
|
||||
<span class="signal-icon">${signalIcon}</span>
|
||||
${escapeHtml(network.ssid)}
|
||||
</span>
|
||||
<span class="network-signal">${network.rssi} dBm</span>
|
||||
`;
|
||||
networkList.appendChild(item);
|
||||
});
|
||||
}
|
||||
networkList.style.display = 'block';
|
||||
}
|
||||
|
||||
// Render for main interface (select dropdown)
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
|
||||
networks.forEach(network => {
|
||||
const option = document.createElement('option');
|
||||
option.value = network.ssid;
|
||||
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
// Note: onchange handler is set inline in HTML
|
||||
}
|
||||
|
||||
showStatus('wifi-status', t('wifi.networks.found', { count: networks.length }), 'success');
|
||||
} catch (error) {
|
||||
if (loading) {
|
||||
loading.classList.remove('active');
|
||||
}
|
||||
|
||||
// Demo mode for local testing
|
||||
const demoNetworks = [
|
||||
{ ssid: 'Demo-Netzwerk', rssi: -45 },
|
||||
{ ssid: 'Gast-WLAN', rssi: -67 },
|
||||
{ ssid: 'Nachbar-WiFi', rssi: -82 }
|
||||
];
|
||||
|
||||
if (networkList) {
|
||||
demoNetworks.forEach(network => {
|
||||
const signalIcon = getSignalIcon(network.rssi);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'network-item';
|
||||
item.onclick = () => selectNetwork(network.ssid, item);
|
||||
item.innerHTML = `
|
||||
<span class="network-name">
|
||||
<span class="signal-icon">${signalIcon}</span>
|
||||
${escapeHtml(network.ssid)}
|
||||
</span>
|
||||
<span class="network-signal">${network.rssi} dBm</span>
|
||||
`;
|
||||
networkList.appendChild(item);
|
||||
});
|
||||
networkList.style.display = 'block';
|
||||
}
|
||||
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
|
||||
demoNetworks.forEach(network => {
|
||||
const option = document.createElement('option');
|
||||
option.value = network.ssid;
|
||||
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
showStatus('wifi-status', 'Demo: ' + t('wifi.networks.found', { count: demoNetworks.length }), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a network from the list (captive portal)
|
||||
* @param {string} ssid - Network SSID
|
||||
* @param {HTMLElement} element - Clicked element
|
||||
*/
|
||||
function selectNetwork(ssid, element) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.network-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked item
|
||||
element.classList.add('selected');
|
||||
|
||||
// Fill in SSID
|
||||
document.getElementById('ssid').value = ssid;
|
||||
|
||||
// Focus password field
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signal strength icon
|
||||
* @param {number} rssi - Signal strength in dBm
|
||||
* @returns {string} Emoji icon
|
||||
*/
|
||||
function getSignalIcon(rssi) {
|
||||
if (rssi >= -50) return '📶';
|
||||
if (rssi >= -60) return '📶';
|
||||
if (rssi >= -70) return '📶';
|
||||
return '📶';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save WiFi configuration
|
||||
*/
|
||||
async function saveWifi() {
|
||||
const ssid = document.getElementById('ssid').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!ssid) {
|
||||
showStatus('wifi-status', t('wifi.error.ssid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('wifi-status', t('common.loading'), 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wifi/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid, password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('wifi-status', t('wifi.saved'), 'success');
|
||||
|
||||
// Show countdown for captive portal
|
||||
if (document.querySelector('.info-box')) {
|
||||
let countdown = 10;
|
||||
const countdownInterval = setInterval(() => {
|
||||
showStatus('wifi-status', t('captive.connecting', { seconds: countdown }), 'success');
|
||||
countdown--;
|
||||
if (countdown < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
showStatus('wifi-status', t('captive.done'), 'success');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || t('wifi.error.save'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
// Demo mode
|
||||
showStatus('wifi-status', 'Demo: ' + t('wifi.saved'), 'success');
|
||||
} else {
|
||||
showStatus('wifi-status', t('error') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status (for main interface)
|
||||
*/
|
||||
async function updateConnectionStatus() {
|
||||
const connStatus = document.getElementById('conn-status');
|
||||
const connIp = document.getElementById('conn-ip');
|
||||
const connRssi = document.getElementById('conn-rssi');
|
||||
|
||||
if (!connStatus) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wifi/status');
|
||||
const status = await response.json();
|
||||
|
||||
connStatus.textContent = status.connected ? t('wifi.connected') : t('wifi.disconnected');
|
||||
if (connIp) connIp.textContent = status.ip || '-';
|
||||
if (connRssi) connRssi.textContent = status.rssi ? `${status.rssi} dBm` : '-';
|
||||
} catch (error) {
|
||||
connStatus.textContent = t('wifi.unavailable');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user