feat: модернизировать WebSocket клиент с UI управлением

Новые возможности client.html:
- 🔊 Управление громкостью (слайдер 0-100%)
- 🔇/🔊 Кнопки Mute/Unmute
- 🔔 Тест звука (play_sound команда)
- 💡 LED управление (цвет, яркость)
- 📊 RMS Audio Level визуализация (реальное время)
- 🎨 Современный UI (карточки, градиенты, анимации)
- 📱 Адаптивный дизайн

start_web_server.py:
- Простой HTTP сервер для тестирования
- Авто-определение WebSocket URL
- Красивый вывод в консоль

Запуск:
  cd examples/websocket-service
  ./start_web_server.py --port 8080

Открыть: http://localhost:8080/client.html
This commit is contained in:
2026-05-20 22:23:34 +03:00
parent b794f4cafb
commit d8ab4736de
2 changed files with 623 additions and 88 deletions
+559 -87
View File
@@ -1,103 +1,491 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wyoming Satellite WebSocket</title>
<title>Wyoming Satellite Control</title>
<style>
* { box-sizing: border-box; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier New', monospace;
padding: 20px;
background: #1a1a2e;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
margin: 0;
min-height: 100vh;
padding: 20px;
}
h1 { color: #00d9ff; margin-bottom: 10px; }
#status {
padding: 8px 12px;
border-radius: 4px;
display: inline-block;
margin-bottom: 15px;
font-weight: bold;
.container {
max-width: 1200px;
margin: 0 auto;
}
#status.connected { background: #2d5a3d; color: #7fff7f; }
#status.disconnected { background: #5a2d2d; color: #ff7f7f; }
#status.connecting { background: #5a5a2d; color: #ffff7f; }
#log {
background: #0f0f1a;
border: 1px solid #333;
border-radius: 4px;
padding: 15px;
height: calc(100vh - 180px);
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
backdrop-filter: blur(10px);
}
.event {
border-left: 3px solid #00d9ff;
padding-left: 10px;
margin: 10px 0;
background: rgba(0, 217, 255, 0.05);
padding: 10px;
border-radius: 0 4px 4px 0;
.header h1 {
font-size: 24px;
background: linear-gradient(90deg, #00d9ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 5px;
}
.event-type { color: #00d9ff; font-weight: bold; }
.event-data { color: #aaa; }
.clear-btn {
background: #00d9ff;
color: #1a1a2e;
border: none;
.status {
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.status::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status.connected { background: rgba(0, 255, 136, 0.2); color: #00ff88; }
.status.connected::before { background: #00ff88; }
.status.disconnected { background: rgba(255, 71, 87, 0.2); color: #ff4757; }
.status.disconnected::before { background: #ff4757; animation: none; }
.status.connecting { background: rgba(255, 193, 7, 0.2); color: #ffc107; }
.status.connecting::before { background: #ffc107; }
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
/* Cards */
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 18px;
margin-bottom: 20px;
color: #00d9ff;
display: flex;
align-items: center;
gap: 10px;
}
/* Controls */
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #aaa;
}
/* Volume Slider */
.volume-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(90deg, #00d9ff 0%, #00ff88 100%);
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.volume-value {
text-align: center;
font-size: 24px;
font-weight: bold;
color: #00d9ff;
margin-top: 10px;
}
/* Buttons */
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: linear-gradient(90deg, #00d9ff, #00ff88);
color: #1a1a2e;
}
.btn-danger {
background: linear-gradient(90deg, #ff4757, #ff6b81);
color: white;
}
.btn-success {
background: linear-gradient(90deg, #00ff88, #00d9ff);
color: #1a1a2e;
}
.btn-warning {
background: linear-gradient(90deg, #ffc107, #ff9800);
color: #1a1a2e;
}
.btn-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* RMS Meter */
.rms-meter {
margin-bottom: 20px;
}
.rms-bar {
height: 30px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.rms-fill {
height: 100%;
background: linear-gradient(90deg, #00ff88 0%, #ffc107 70%, #ff4757 100%);
transition: width 0.1s ease;
border-radius: 4px;
}
.rms-value {
text-align: center;
font-size: 32px;
font-weight: bold;
margin-top: 10px;
font-family: 'Courier New', monospace;
}
.rms-label {
text-align: center;
color: #aaa;
font-size: 12px;
margin-top: 5px;
}
/* Event Log */
.log-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 20px;
height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.log-entry {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid #00d9ff;
background: rgba(0, 217, 255, 0.05);
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.log-entry.audio-level {
border-left-color: #00ff88;
background: rgba(0, 255, 136, 0.05);
}
.log-entry.error {
border-left-color: #ff4757;
background: rgba(255, 71, 87, 0.05);
}
.log-entry.system {
border-left-color: #ffc107;
background: rgba(255, 193, 7, 0.05);
}
.log-time {
color: #666;
margin-right: 10px;
}
.log-type {
color: #00d9ff;
font-weight: bold;
}
.log-data {
color: #aaa;
margin-left: 10px;
}
.clear-btn:hover { background: #00b8d9; }
.header { display: flex; align-items: center; justify-content: space-between; }
/* Settings */
.settings-panel {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: none;
}
.settings-panel.show {
display: block;
}
.settings-panel input[type="text"] {
width: 100%;
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #eee;
font-size: 14px;
margin-bottom: 10px;
}
.settings-panel input[type="text"]:focus {
outline: none;
border-color: #00d9ff;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
color: #aaa;
cursor: pointer;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.grid {
grid-template-columns: 1fr;
}
.btn-group {
justify-content: center;
}
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>🛰️ Wyoming Satellite WebSocket</h1>
<div id="status" class="connecting">Connecting...</div>
<div class="container">
<!-- Header -->
<div class="header">
<div>
<h1>🛰️ Wyoming Satellite Control</h1>
<div style="color: #aaa; font-size: 14px;">WebSocket Client & Audio Level Monitor</div>
</div>
<div class="status" id="status">
Connecting...
</div>
</div>
<div>
<button class="clear-btn" onclick="toggleSettings()">⚙️ Settings</button>
<button class="clear-btn" onclick="clearLog()">Clear Log</button>
<!-- Settings -->
<div class="settings-panel" id="settingsPanel">
<label style="color: #00d9ff; font-weight: 600; display: block; margin-bottom: 8px;">
WebSocket URL:
</label>
<input type="text" id="wsUrl" value="ws://localhost:8675">
<div style="display: flex; gap: 10px; align-items: center; margin-top: 10px;">
<button class="btn btn-primary" onclick="connect()">
🔌 Connect
</button>
<label class="checkbox-label">
<input type="checkbox" id="autoReconnect" checked>
Auto-reconnect
</label>
</div>
</div>
<!-- Control Grid -->
<div class="grid">
<!-- Volume Control -->
<div class="card">
<h2>🔊 Volume Control</h2>
<div class="control-group">
<label>Master Volume</label>
<input type="range" class="volume-slider" id="volumeSlider"
min="0" max="100" value="100" oninput="updateVolume(this.value)">
<div class="volume-value" id="volumeValue">100%</div>
</div>
<div class="btn-group">
<button class="btn btn-danger" onclick="sendCommand('mute', {})">
🔇 Mute
</button>
<button class="btn btn-success" onclick="sendCommand('unmute', {})">
🔊 Unmute
</button>
</div>
</div>
<!-- RMS Audio Level -->
<div class="card">
<h2>📊 RMS Audio Level</h2>
<div class="rms-meter">
<div class="rms-bar">
<div class="rms-fill" id="rmsFill" style="width: 0%;"></div>
</div>
<div class="rms-value" id="rmsValue">0.00</div>
<div class="rms-label">Real-time microphone level</div>
</div>
<div class="btn-group">
<button class="btn btn-warning" onclick="sendCommand('play_sound', {path: '/home/sirmrak/projects/satellite-dev/sounds/awake.wav'})">
🔔 Test Sound
</button>
</div>
</div>
<!-- LED Control -->
<div class="card">
<h2>💡 LED Control</h2>
<div class="control-group">
<label>Brightness</label>
<input type="range" class="volume-slider" id="ledBrightness"
min="0" max="255" value="128" oninput="updateLedBrightness(this.value)">
<div class="volume-value" id="ledBrightnessValue">128</div>
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="sendCommand('set_led_color', {r: 0, g: 188, b: 212})">
🔵 Cyan
</button>
<button class="btn btn-danger" onclick="sendCommand('set_led_color', {r: 255, g: 0, b: 0})">
🔴 Red
</button>
<button class="btn btn-success" onclick="sendCommand('set_led_color', {r: 0, g: 255, b: 0})">
🟢 Green
</button>
</div>
</div>
</div>
<!-- Event Log -->
<div class="card">
<h2>📋 Event Log</h2>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<button class="btn btn-primary" onclick="clearLog()">
🗑️ Clear Log
</button>
<button class="btn btn-primary" onclick="toggleSettings()">
⚙️ Settings
</button>
<label class="checkbox-label" style="margin-left: auto;">
<input type="checkbox" id="autoScroll" checked>
Auto-scroll
</label>
</div>
<div class="log-container" id="log"></div>
</div>
</div>
<div id="settings" style="display:none; background:#0f0f1a; padding:15px; border-radius:4px; margin-bottom:15px;">
<label for="wsUrl" style="color:#00d9ff; font-weight:bold;">WebSocket URL:</label>
<input type="text" id="wsUrl" value="ws://localhost:8675"
style="width:100%; padding:8px; margin:5px 0; background:#1a1a2e; color:#eee; border:1px solid #333; border-radius:4px;">
<button class="clear-btn" onclick="connect()">Connect</button>
<label style="margin-left:10px; color:#aaa;">
<input type="checkbox" id="autoReconnect" checked> Auto-reconnect
</label>
</div>
<div id="log"></div>
<script>
// State
let ws = null;
let eventCount = 0;
let currentRms = 0;
let rmsTimeout = null;
// Elements
const log = document.getElementById('log');
const status = document.getElementById('status');
const settingsPanel = document.getElementById('settings');
const settingsPanel = document.getElementById('settingsPanel');
const wsUrlInput = document.getElementById('wsUrl');
const autoReconnectCheckbox = document.getElementById('autoReconnect');
let eventCount = 0;
let ws = null;
let autoReconnect = true;
const rmsFill = document.getElementById('rmsFill');
const rmsValue = document.getElementById('rmsValue');
function toggleSettings() {
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
}
// Auto-detect WebSocket URL
window.addEventListener('load', () => {
const detectedUrl = `ws://${window.location.hostname}:8675`;
wsUrlInput.value = detectedUrl;
connect();
});
// Connect to WebSocket
function connect() {
const wsUrl = wsUrlInput.value.trim();
if (!wsUrl) {
addEvent('SYSTEM', { error: 'Please enter a WebSocket URL' });
return;
@@ -108,26 +496,32 @@
ws.close();
}
setStatus('connecting', 'Connecting...');
addEvent('SYSTEM', { status: `Connecting to ${wsUrl}...` });
ws = new WebSocket(wsUrl);
ws.onopen = () => {
status.textContent = 'Connected to ' + wsUrl;
status.className = 'connected';
addEvent('SYSTEM', { status: 'Connected to ' + wsUrl });
setStatus('connected', `Connected`);
addEvent('SYSTEM', { status: `Connected to ${wsUrl}` });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
addEvent(data.type, data.data);
// Handle audio-level events
if (data.type === 'audio-level' && data.data && data.data.level !== undefined) {
updateRmsMeter(data.data.level);
}
} catch (e) {
addEvent('RAW', { raw: event.data });
}
};
ws.onclose = () => {
status.textContent = 'Disconnected';
status.className = 'disconnected';
setStatus('disconnected', 'Disconnected');
addEvent('SYSTEM', { status: 'Connection closed' });
// Auto-reconnect
@@ -140,39 +534,117 @@
};
ws.onerror = (err) => {
status.textContent = 'Error';
status.className = 'disconnected';
setStatus('disconnected', 'Error');
addEvent('SYSTEM', { error: 'WebSocket error', details: err.toString() });
};
}
// Auto-detect WebSocket URL from current page location on first load
window.addEventListener('load', () => {
const detectedUrl = `ws://${window.location.hostname}:8675`;
wsUrlInput.value = detectedUrl;
connect();
// Set status
function setStatus(state, text) {
status.className = `status ${state}`;
status.textContent = text;
}
// Send command
function sendCommand(command, data) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addEvent('SYSTEM', { error: 'Not connected to WebSocket server' });
return;
}
const message = { command, data };
ws.send(JSON.stringify(message));
addEvent('COMMAND', { command, data });
}
// Update volume
function updateVolume(value) {
document.getElementById('volumeValue').textContent = `${value}%`;
}
// Send volume command
document.getElementById('volumeSlider').addEventListener('change', function() {
const level = parseInt(this.value) / 100;
sendCommand('set_volume', { level });
});
// Update LED brightness
function updateLedBrightness(value) {
document.getElementById('ledBrightnessValue').textContent = value;
}
// Send LED brightness command
document.getElementById('ledBrightness').addEventListener('change', function() {
const brightness = parseInt(this.value);
sendCommand('set_led_brightness', { brightness });
});
// Update RMS meter
function updateRmsMeter(level) {
currentRms = level;
const percent = Math.min(100, Math.max(0, level * 100));
rmsFill.style.width = `${percent}%`;
rmsValue.textContent = level.toFixed(2);
// Reset after 1 second of no audio
clearTimeout(rmsTimeout);
rmsTimeout = setTimeout(() => {
currentRms = 0;
rmsFill.style.width = '0%';
rmsValue.textContent = '0.00';
}, 1000);
}
// Toggle settings
function toggleSettings() {
settingsPanel.classList.toggle('show');
}
// Add event to log
function addEvent(type, data) {
eventCount++;
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'event';
div.innerHTML = `<span class="event-type">[${time}] #${eventCount} ${escapeHtml(type)}</span>\n<span class="event-data">${escapeHtml(JSON.stringify(data, null, 2))}</span>`;
div.className = `log-entry ${type.toLowerCase().replace('_', '-')}`;
let dataStr = '';
if (data.error) {
dataStr = `<span style="color: #ff4757;">${escapeHtml(data.error)}</span>`;
} else if (data.status) {
dataStr = `<span style="color: #00ff88;">${escapeHtml(data.status)}</span>`;
} else if (data.command) {
dataStr = `<span style="color: #ffc107;">→ ${escapeHtml(data.command)}</span> ${escapeHtml(JSON.stringify(data.data))}`;
} else {
dataStr = escapeHtml(JSON.stringify(data, null, 2));
}
div.innerHTML = `
<span class="log-time">[${time}]</span>
<span class="log-type">#${eventCount} ${escapeHtml(type)}</span>
<span class="log-data">${dataStr}</span>
`;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
// Auto-scroll
if (document.getElementById('autoScroll').checked) {
log.scrollTop = log.scrollHeight;
}
}
// Clear log
function clearLog() {
log.innerHTML = '';
eventCount = 0;
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function clearLog() {
log.innerHTML = '';
eventCount = 0;
}
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Simple HTTP server for WebSocket client testing.
Usage:
python3 start_web_server.py [--port PORT]
Open in browser:
http://localhost:PORT/client.html
"""
import argparse
import http.server
import socketserver
import os
from pathlib import Path
PORT = 8080
SCRIPT_DIR = Path(__file__).parent.resolve()
class CustomHandler(http.server.SimpleHTTPRequestHandler):
"""Custom handler with better logging."""
def do_GET(self):
super().do_GET()
self.log_message(f"✅ Served: {self.path}")
def log_message(self, format, *args):
print(f"[WebServer] {args[0]}")
def main():
parser = argparse.ArgumentParser(description='WebSocket Client Web Server')
parser.add_argument('--port', type=int, default=PORT, help=f'Port (default: {PORT})')
parser.add_argument('--host', default='0.0.0.0', help='Host (default: 0.0.0.0)')
args = parser.parse_args()
# Change to websocket-service directory
os.chdir(SCRIPT_DIR)
print(f"""
╔══════════════════════════════════════════════════════════╗
║ 🛰️ Wyoming Satellite WebSocket Client Server ║
╠══════════════════════════════════════════════════════════╣
║ Serving: {str(SCRIPT_DIR):<45} ║
║ URL: http://localhost:{args.port:<36} ║
║ ║
║ Press Ctrl+C to stop ║
╚══════════════════════════════════════════════════════════╝
""")
with socketserver.TCPServer((args.host, args.port), CustomHandler) as httpd:
try:
print(f"🚀 Server started on http://{args.host}:{args.port}")
httpd.serve_forever()
except KeyboardInterrupt:
print("\n👋 Shutting down...")
httpd.shutdown()
if __name__ == "__main__":
main()