Add Network + Bluetooth config in Web UI

- src/system/network.py: WiFi scan/connect/hotspot via nmcli + iwlist fallback
- src/system/bluetooth.py: BT scan/pair/connect/MIDI via bluetoothctl
- REST endpoints: /api/wifi/* and /api/bluetooth/* (16 endpoints)
- Web UI: tabbed settings with Network + Bluetooth panels
- network.js: WiFi scan, connect modal, saved nets, hotspot
- bluetooth.js: BT scan, pair/unpair, connect, MIDI service
- style.css: tabs, network list, BT device list, signal indicators
- scripts/setup-bt-midi.sh: BT MIDI systemd bridge service
- tests/test_network.py: 22 tests (nmcli/iwlist, fallback, hotspot)
- tests/test_bt.py: 24 tests (status, scan, pair, MIDI, edge cases)
- _gather_state includes wifi + bluetooth sub-objects
- All 93 tests pass
This commit is contained in:
2026-06-09 01:14:41 -04:00
parent c73b1ffa6c
commit b4237f2f1d
10 changed files with 3305 additions and 186 deletions
+214
View File
@@ -540,6 +540,177 @@ class WebServer:
logger.info("Uploaded IR file: %s", dest.name)
return {"ok": True, "filename": dest.name}
# ── WiFi endpoints ──────────────────────────────────────
@app.get("/api/wifi/status")
async def wifi_get_status():
"""Get current WiFi client connection status."""
from ..system.network import wifi_status
return wifi_status()
@app.get("/api/wifi/scan")
async def wifi_scan():
"""Scan for available WiFi networks."""
from ..system.network import wifi_scan
return {"networks": wifi_scan()}
@app.get("/api/wifi/saved")
async def wifi_saved():
"""List saved/known WiFi networks."""
from ..system.network import wifi_saved_networks
return {"networks": wifi_saved_networks()}
@app.post("/api/wifi/connect")
async def wifi_connect(data: dict):
"""Connect to a WiFi network.
Body: {ssid: str, password: str}
"""
from ..system.network import wifi_connect
return wifi_connect(
ssid=data.get("ssid", ""),
password=data.get("password", ""),
)
@app.post("/api/wifi/disconnect")
async def wifi_disconnect():
"""Disconnect from the current WiFi network."""
from ..system.network import wifi_disconnect
return wifi_disconnect()
@app.post("/api/wifi/forget")
async def wifi_forget(data: dict):
"""Forget a saved WiFi network.
Body: {ssid: str}
"""
from ..system.network import wifi_forget
return wifi_forget(ssid=data.get("ssid", ""))
@app.get("/api/wifi/hotspot")
async def wifi_hotspot_status():
"""Get hotspot status."""
from ..system.network import hotspot_status
return hotspot_status()
@app.post("/api/wifi/hotspot/enable")
async def wifi_hotspot_enable(data: dict):
"""Enable WiFi hotspot.
Body: {ssid: str (optional), password: str (optional)}
"""
from ..system.network import hotspot_enable
return hotspot_enable(
ssid=data.get("ssid", "Pi-Pedal"),
password=data.get("password", "pedal1234"),
)
@app.post("/api/wifi/hotspot/disable")
async def wifi_hotspot_disable():
"""Disable WiFi hotspot."""
from ..system.network import hotspot_disable
return hotspot_disable()
# ── Bluetooth endpoints ──────────────────────────────────
@app.get("/api/bluetooth/status")
async def bluetooth_status():
"""Get Bluetooth adapter status."""
from ..system.bluetooth import bt_status
return bt_status()
@app.post("/api/bluetooth/power")
async def bluetooth_power(data: dict):
"""Power Bluetooth on or off.
Body: {on: bool}
"""
from ..system.bluetooth import bt_power
return bt_power(on=data.get("on", True))
@app.post("/api/bluetooth/discoverable")
async def bluetooth_discoverable(data: dict):
"""Set Bluetooth discoverable.
Body: {on: bool}
"""
from ..system.bluetooth import bt_discoverable
return bt_discoverable(on=data.get("on", True))
@app.post("/api/bluetooth/scan")
async def bluetooth_scan():
"""Scan for discoverable Bluetooth devices."""
from ..system.bluetooth import bt_scan
return bt_scan(timeout=12)
@app.get("/api/bluetooth/paired")
async def bluetooth_paired():
"""List paired Bluetooth devices."""
from ..system.bluetooth import bt_paired_devices
return {"devices": bt_paired_devices()}
@app.post("/api/bluetooth/pair")
async def bluetooth_pair(data: dict):
"""Pair with a Bluetooth device.
Body: {address: str}
"""
from ..system.bluetooth import bt_pair
return bt_pair(address=data.get("address", ""))
@app.post("/api/bluetooth/unpair")
async def bluetooth_unpair(data: dict):
"""Unpair a Bluetooth device.
Body: {address: str}
"""
from ..system.bluetooth import bt_unpair
return bt_unpair(address=data.get("address", ""))
@app.post("/api/bluetooth/connect")
async def bluetooth_connect(data: dict):
"""Connect to a paired Bluetooth device.
Body: {address: str}
"""
from ..system.bluetooth import bt_connect
return bt_connect(address=data.get("address", ""))
@app.post("/api/bluetooth/disconnect")
async def bluetooth_disconnect(data: dict):
"""Disconnect a Bluetooth device.
Body: {address: str}
"""
from ..system.bluetooth import bt_disconnect
return bt_disconnect(address=data.get("address", ""))
@app.get("/api/bluetooth/midi-status")
async def bluetooth_midi_status():
"""Get Bluetooth MIDI service status."""
from ..system.bluetooth import bt_midi_status
return bt_midi_status()
@app.post("/api/bluetooth/midi-enable")
async def bluetooth_midi_enable():
"""Enable and start the Bluetooth MIDI service."""
from ..system.bluetooth import bt_midi_enable
return bt_midi_enable()
@app.post("/api/bluetooth/midi-disable")
async def bluetooth_midi_disable():
"""Stop and disable the Bluetooth MIDI service."""
from ..system.bluetooth import bt_midi_disable
return bt_midi_disable()
@app.get("/api/bluetooth/midi-devices")
async def bluetooth_midi_devices():
"""List connected Bluetooth MIDI devices."""
from ..system.bluetooth import bt_midi_connected_devices
return {"devices": bt_midi_connected_devices()}
# ── FX block param schemas ───────────────────────────────
@app.get("/api/block-params/{fx_type}")
async def get_block_params(fx_type: str):
"""Return editable parameters for a given FX block type.
@@ -581,6 +752,47 @@ class WebServer:
return app
# ── Network/BT state gathering ──────────────────────────────────
def _gather_wifi_state(self) -> dict:
"""Try to gather WiFi status without hard-failing."""
try:
from ..system.network import wifi_status, hotspot_status
ws = wifi_status()
hs = hotspot_status()
return {
"connected": ws.get("connected", False),
"ssid": ws.get("ssid"),
"ip": ws.get("ip"),
"signal": ws.get("signal", 0),
"hotspot_active": hs.get("active", False),
"hotspot_ssid": hs.get("ssid"),
"hotspot_clients": hs.get("clients", 0),
}
except Exception:
return {"connected": False, "ssid": None, "ip": None, "signal": 0,
"hotspot_active": False, "hotspot_ssid": None, "hotspot_clients": 0}
def _gather_bt_state(self) -> dict:
"""Try to gather Bluetooth status without hard-failing."""
try:
from ..system.bluetooth import bt_status, bt_midi_status
bs = bt_status()
ms = bt_midi_status()
return {
"available": bs.get("available", False),
"powered": bs.get("powered", False),
"name": bs.get("name"),
"address": bs.get("address"),
"discoverable": bs.get("discoverable", False),
"midi_enabled": ms.get("enabled", False),
"midi_running": ms.get("running", False),
}
except Exception:
return {"available": False, "powered": False, "name": None,
"address": None, "discoverable": False,
"midi_enabled": False, "midi_running": False}
# ── State gathering ──────────────────────────────────────────────
def _gather_state(self) -> dict[str, Any]:
@@ -631,6 +843,8 @@ class WebServer:
"nam_model": current_model_name,
"ir_loaded": bool(ir and ir.is_loaded) if ir else False,
"ir_name": current_ir_name,
"wifi": self._gather_wifi_state(),
"bluetooth": self._gather_bt_state(),
}
# ── Lifecycle ────────────────────────────────────────────────────
+294
View File
@@ -0,0 +1,294 @@
/**
* bluetooth.js — Bluetooth scan, pair, MIDI management for Pi Multi-FX Pedal.
*
* Relies on apiGet, apiPost from app.js and the pedalWS singleton.
*/
/* ── BT Status ─────────────────────────────────────────────────── */
async function refreshBTStatus() {
try {
const status = await apiGet('/bluetooth/status');
const powerBtn = document.getElementById('bt-power-btn');
const discBtn = document.getElementById('bt-discoverable-btn');
const nameEl = document.getElementById('bt-adapter-name');
const addrEl = document.getElementById('bt-address');
if (powerBtn) {
powerBtn.textContent = status.powered ? 'ON' : 'OFF';
powerBtn.classList.toggle('active', status.powered);
}
if (discBtn) {
discBtn.textContent = status.discoverable ? 'ON' : 'OFF';
discBtn.classList.toggle('active', status.discoverable);
}
if (nameEl) nameEl.textContent = status.name || '—';
if (addrEl) addrEl.textContent = status.address || '—';
} catch (e) {
console.warn('Failed to get BT status:', e);
}
}
/* ── BT Power ──────────────────────────────────────────────────── */
async function toggleBTPower() {
const btn = document.getElementById('bt-power-btn');
const current = btn.classList.contains('active');
const newState = !current;
btn.disabled = true;
try {
await apiPost('/bluetooth/power', { on: newState });
refreshBTStatus();
} catch (e) {
console.warn('BT power toggle failed:', e);
} finally {
btn.disabled = false;
}
}
/* ── BT Discoverable ───────────────────────────────────────────── */
async function toggleBTDiscoverable() {
const btn = document.getElementById('bt-discoverable-btn');
const current = btn.classList.contains('active');
const newState = !current;
btn.disabled = true;
try {
await apiPost('/bluetooth/discoverable', { on: newState });
refreshBTStatus();
} catch (e) {
console.warn('BT discoverable toggle failed:', e);
} finally {
btn.disabled = false;
}
}
/* ── BT Scan ───────────────────────────────────────────────────── */
async function scanBT() {
const btn = document.getElementById('bt-scan-btn');
const results = document.getElementById('bt-scan-results');
btn.disabled = true;
btn.textContent = 'Scanning (12s)...';
results.innerHTML = '<div class="loading">Scanning for Bluetooth devices... (12 seconds)</div>';
try {
const data = await apiPost('/bluetooth/scan');
const devices = data.devices || [];
if (devices.length === 0) {
results.innerHTML = '<div class="hint">No devices found. Make sure devices are discoverable.</div>';
return;
}
let html = '<div class="bt-device-list">';
for (const dev of devices) {
const rssiStr = dev.rssi !== null ? `${dev.rssi} dBm` : '—';
const strengthClass = dev.rssi !== null && dev.rssi > -60 ? 'signal-strong'
: dev.rssi !== null && dev.rssi > -80 ? 'signal-medium'
: 'signal-weak';
html += `
<div class="bt-device-item">
<div class="bt-device-icon">🔵</div>
<div class="bt-device-info">
<div class="bt-device-name">${escapeHtml(dev.name)}</div>
<div class="bt-device-meta">
<code>${dev.address}</code>
<span class="${strengthClass}">${rssiStr}</span>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="pairBT('${dev.address}')">Pair</button>
</div>`;
}
html += '</div>';
results.innerHTML = html;
} catch (e) {
console.warn('BT scan failed:', e);
results.innerHTML = '<div class="network-error">Scan failed.</div>';
} finally {
btn.disabled = false;
btn.textContent = 'Scan';
}
}
/* ── Pair / Unpair ─────────────────────────────────────────────── */
async function pairBT(address) {
try {
const result = await apiPost('/bluetooth/pair', { address });
if (result.ok) {
loadPairedDevices();
// Refresh scan results to show paired
// alert('Paired successfully!');
} else {
alert('Pairing failed: ' + (result.error || 'Unknown error'));
}
} catch (e) {
alert('Network error: ' + e.message);
}
}
async function unpairBT(address) {
if (!confirm(`Unpair ${address}?`)) return;
try {
await apiPost('/bluetooth/unpair', { address });
loadPairedDevices();
} catch (e) {
console.warn('Unpair failed:', e);
}
}
async function connectBT(address) {
try {
const result = await apiPost('/bluetooth/connect', { address });
if (result.ok) {
loadPairedDevices();
} else {
alert('Connection failed: ' + (result.error || 'Unknown error'));
}
} catch (e) {
console.warn('BT connect failed:', e);
}
}
async function disconnectBT(address) {
try {
await apiPost('/bluetooth/disconnect', { address });
loadPairedDevices();
} catch (e) {
console.warn('BT disconnect failed:', e);
}
}
/* ── Paired Devices ────────────────────────────────────────────── */
async function loadPairedDevices() {
const list = document.getElementById('bt-paired-list');
try {
const data = await apiGet('/bluetooth/paired');
const devices = data.devices || [];
if (devices.length === 0) {
list.innerHTML = '<div class="hint">No paired devices.</div>';
return;
}
let html = '<div class="bt-device-list">';
for (const dev of devices) {
const statusClass = dev.connected ? 'connected' : 'disconnected';
const statusText = dev.connected ? 'Connected' : 'Disconnected';
html += `
<div class="bt-device-item bt-paired ${statusClass}">
<div class="bt-device-icon">${dev.connected ? '🔗' : '🔘'}</div>
<div class="bt-device-info">
<div class="bt-device-name">${escapeHtml(dev.name)}</div>
<div class="bt-device-meta">
<code>${dev.address}</code>
<span class="bt-status-${statusClass}">${statusText}</span>
</div>
</div>
<div class="bt-device-actions">
${dev.connected
? `<button class="btn btn-secondary btn-sm" onclick="disconnectBT('${dev.address}')">Disconnect</button>`
: `<button class="btn btn-primary btn-sm" onclick="connectBT('${dev.address}')">Connect</button>`
}
<button class="btn btn-danger btn-sm" onclick="unpairBT('${dev.address}')">Forget</button>
</div>
</div>`;
}
html += '</div>';
list.innerHTML = html;
} catch (e) {
console.warn('Failed to load paired devices:', e);
list.innerHTML = '<div class="network-error">Failed to load.</div>';
}
}
/* ── BT MIDI Service ───────────────────────────────────────────── */
async function refreshBTMIDIStatus() {
try {
const status = await apiGet('/bluetooth/midi-status');
const indicator = document.getElementById('bt-midi-indicator');
const enableBtn = document.getElementById('bt-midi-enable-btn');
const disableBtn = document.getElementById('bt-midi-disable-btn');
if (indicator) {
if (status.running) {
indicator.textContent = 'Running';
indicator.className = 'bt-midi-indicator bt-midi-on';
} else if (status.enabled) {
indicator.textContent = 'Enabled (not running)';
indicator.className = 'bt-midi-indicator bt-midi-warn';
} else {
indicator.textContent = 'Off';
indicator.className = 'bt-midi-indicator bt-midi-off';
}
}
// Load MIDI devices
try {
const devData = await apiGet('/bluetooth/midi-devices');
const devEl = document.getElementById('bt-midi-devices');
if (devEl) {
const devs = devData.devices || [];
if (devs.length > 0) {
devEl.innerHTML = devs.map(d =>
`<div class="bt-midi-device">${escapeHtml(d.name)} <code>${d.address}</code></div>`
).join('');
} else {
devEl.innerHTML = '<span class="hint">None connected</span>';
}
}
} catch (e) {
// MIDI devices are optional
}
} catch (e) {
console.warn('Failed to get BT MIDI status:', e);
}
}
async function enableBTMIDI() {
const btn = document.getElementById('bt-midi-enable-btn');
btn.disabled = true;
btn.textContent = 'Enabling...';
try {
await apiPost('/bluetooth/midi-enable');
refreshBTMIDIStatus();
} catch (e) {
alert('Failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Enable';
}
}
async function disableBTMIDI() {
const btn = document.getElementById('bt-midi-disable-btn');
btn.disabled = true;
btn.textContent = 'Disabling...';
try {
await apiPost('/bluetooth/midi-disable');
refreshBTMIDIStatus();
} catch (e) {
alert('Failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Disable';
}
}
/* ── Helpers ────────────────────────────────────────────────────── */
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/* ── WebSocket listeners ────────────────────────────────────────── */
pedalWS.on('bluetooth_changed', (msg) => {
refreshBTStatus();
refreshBTMIDIStatus();
});
+259
View File
@@ -0,0 +1,259 @@
/**
* network.js — WiFi scan, connect, hotspot management for Pi Multi-FX Pedal.
*
* Relies on apiGet, apiPost from app.js and the pedalWS singleton.
*/
/* ── WiFi Status ────────────────────────────────────────────────── */
async function refreshWiFiStatus() {
try {
const status = await apiGet('/wifi/status');
const ssidEl = document.getElementById('wifi-ssid');
const ipEl = document.getElementById('wifi-ip');
const sigEl = document.getElementById('wifi-signal-text');
const iconEl = document.getElementById('wifi-icon');
if (status.connected && status.ssid) {
ssidEl.textContent = status.ssid;
ipEl.textContent = status.ip || 'No IP';
sigEl.textContent = `${status.signal || 0}%`;
// Signal bars
const sig = status.signal || 0;
if (sig > 70) iconEl.textContent = '📶';
else if (sig > 40) iconEl.textContent = '📶';
else if (sig > 15) iconEl.textContent = '📶';
else iconEl.textContent = '📶';
} else {
ssidEl.textContent = 'Not connected';
ipEl.textContent = '—';
sigEl.textContent = '—';
iconEl.textContent = '📡';
}
} catch (e) {
console.warn('Failed to get WiFi status:', e);
document.getElementById('wifi-ssid').textContent = 'Error';
}
}
/* ── WiFi Scan ──────────────────────────────────────────────────── */
async function scanWiFi() {
const btn = document.getElementById('wifi-scan-btn');
const results = document.getElementById('wifi-scan-results');
btn.disabled = true;
btn.textContent = 'Scanning...';
results.innerHTML = '<div class="loading">Scanning for networks...</div>';
try {
const data = await apiGet('/wifi/scan');
const networks = data.networks || [];
if (networks.length === 0) {
results.innerHTML = '<div class="hint">No networks found. Make sure WiFi is enabled.</div>';
return;
}
let html = '<div class="network-list">';
for (const net of networks) {
const bars = signalBars(net.signal);
const encClass = net.encryption === 'Open' ? 'enc-open' : 'enc-secure';
const displayEnc = net.encryption === 'Open' ? 'Open' : 'Secured';
html += `
<div class="network-item">
<div class="network-item-icon">${bars}</div>
<div class="network-item-info">
<div class="network-item-ssid">${escapeHtml(net.ssid)}</div>
<div class="network-item-meta"><span class="${encClass}">${displayEnc}</span> · ${net.signal}%</div>
</div>
<button class="btn btn-primary" onclick="openWiFiConnect('${escapeHtml(net.ssid)}')">Connect</button>
</div>`;
}
html += '</div>';
results.innerHTML = html;
} catch (e) {
console.warn('WiFi scan failed:', e);
results.innerHTML = '<div class="network-error">Scan failed. Check WiFi hardware.</div>';
} finally {
btn.disabled = false;
btn.textContent = 'Scan';
}
}
function signalBars(signal) {
if (signal > 70) return '📶';
if (signal > 40) return '📶';
if (signal > 15) return '📶';
return '📶';
}
/* ── WiFi Connect Modal ─────────────────────────────────────────── */
let pendingSSID = '';
function openWiFiConnect(ssid) {
pendingSSID = ssid;
document.getElementById('wifi-modal-ssid').textContent = ssid;
document.getElementById('wifi-modal-password').value = '';
document.getElementById('wifi-modal-error').textContent = '';
document.getElementById('wifi-connect-modal').style.display = 'flex';
}
function closeWiFiModal() {
document.getElementById('wifi-connect-modal').style.display = 'none';
pendingSSID = '';
}
async function connectWiFi() {
const password = document.getElementById('wifi-modal-password').value;
const errorEl = document.getElementById('wifi-modal-error');
if (!password) {
errorEl.textContent = 'Please enter a password.';
return;
}
errorEl.textContent = 'Connecting...';
try {
const result = await apiPost('/wifi/connect', { ssid: pendingSSID, password });
if (result.ok) {
errorEl.textContent = 'Connected!';
setTimeout(() => {
closeWiFiModal();
refreshWiFiStatus();
}, 1500);
} else {
errorEl.textContent = result.error || 'Connection failed.';
}
} catch (e) {
errorEl.textContent = 'Network error: ' + (e.message || 'Unknown');
}
}
/* ── Saved Networks ─────────────────────────────────────────────── */
async function loadSavedNetworks() {
const list = document.getElementById('wifi-saved-list');
try {
const data = await apiGet('/wifi/saved');
const networks = data.networks || [];
if (networks.length === 0) {
list.innerHTML = '<div class="hint">No saved networks.</div>';
return;
}
let html = '<div class="network-list">';
for (const net of networks) {
html += `
<div class="network-item">
<div class="network-item-info">
<div class="network-item-ssid">${escapeHtml(net.ssid)}</div>
<div class="network-item-meta">Saved</div>
</div>
<button class="btn btn-danger btn-sm" onclick="forgetNetwork('${escapeHtml(net.ssid)}')">Forget</button>
</div>`;
}
html += '</div>';
list.innerHTML = html;
} catch (e) {
console.warn('Failed to load saved networks:', e);
list.innerHTML = '<div class="network-error">Failed to load.</div>';
}
}
async function forgetNetwork(ssid) {
if (!confirm(`Forget "${ssid}"?`)) return;
try {
await apiPost('/wifi/forget', { ssid });
loadSavedNetworks();
refreshWiFiStatus();
} catch (e) {
console.warn('Failed to forget network:', e);
}
}
/* ── Hotspot ────────────────────────────────────────────────────── */
async function enableHotspot() {
const ssid = document.getElementById('hotspot-ssid').value.trim();
const psk = document.getElementById('hotspot-psk').value.trim();
const errorEl = document.getElementById('wifi-modal-error');
if (!ssid) { alert('SSID is required.'); return; }
if (psk.length < 8) { alert('Password must be at least 8 characters.'); return; }
const btn = document.getElementById('hotspot-enable-btn');
btn.disabled = true;
btn.textContent = 'Enabling...';
try {
const result = await apiPost('/wifi/hotspot/enable', { ssid, password: psk });
if (result.ok) {
refreshWiFiStatus();
updateHotspotUI(true, ssid);
} else {
alert('Failed: ' + (result.error || 'Unknown error'));
}
} catch (e) {
alert('Network error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Enable Hotspot';
}
}
async function disableHotspot() {
const btn = document.getElementById('hotspot-disable-btn');
btn.disabled = true;
btn.textContent = 'Disabling...';
try {
const result = await apiPost('/wifi/hotspot/disable');
if (result.ok) {
refreshWiFiStatus();
updateHotspotUI(false);
}
} catch (e) {
alert('Network error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Disable Hotspot';
}
}
function updateHotspotUI(active, ssid) {
const indicator = document.getElementById('hotspot-status-indicator');
const clients = document.getElementById('hotspot-clients');
if (active) {
indicator.textContent = 'Active';
indicator.className = 'hotspot-indicator hotspot-on';
document.getElementById('hotspot-ssid-row').querySelector('input').value = ssid || 'Pi-Pedal';
} else {
indicator.textContent = 'Off';
indicator.className = 'hotspot-indicator hotspot-off';
clients.textContent = '0';
}
}
/* ── Helpers ────────────────────────────────────────────────────── */
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/* ── WebSocket listeners ────────────────────────────────────────── */
pedalWS.on('network_changed', (msg) => {
refreshWiFiStatus();
});
/* ── Initial load ───────────────────────────────────────────────── */
// Auto-load when the network tab is first activated (handled by switchTab)
// But also check if we're already on the settings page
if (document.getElementById('tab-network')) {
// Defer to DOMContentLoaded in settings.html → switchTab call
}
+300
View File
@@ -694,4 +694,304 @@ body {
font-size: 0.78rem;
color: var(--text-secondary);
line-height: 1.4;
}
/* ── Settings tabs ────────────────────────────────────────────────── */
.settings-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
margin-bottom: 12px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.settings-tabs::-webkit-scrollbar {
display: none;
}
.settings-tab {
padding: 8px 14px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
touch-action: manipulation;
}
.settings-tab:hover {
color: var(--text-primary);
}
.settings-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.settings-panel {
display: none;
}
.settings-panel.active {
display: block;
}
/* ── Network UI ──────────────────────────────────────────────────── */
.network-status-row {
display: flex;
align-items: center;
gap: 12px;
}
.network-status-icon {
font-size: 1.8rem;
width: 48px;
text-align: center;
}
.network-status-info {
flex: 1;
}
.network-ssid {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 2px;
}
.network-ip {
font-size: 0.78rem;
color: var(--text-muted);
}
.network-signal {
font-size: 0.78rem;
color: var(--text-secondary);
}
.network-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.network-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.network-item-icon {
font-size: 1.2rem;
width: 28px;
text-align: center;
}
.network-item-info {
flex: 1;
min-width: 0;
}
.network-item-ssid {
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.network-item-meta {
font-size: 0.72rem;
color: var(--text-muted);
display: flex;
gap: 6px;
}
.enc-open {
color: var(--success);
}
.enc-secure {
color: var(--warning);
}
.network-input {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 6px 10px;
font-size: 0.85rem;
width: 100%;
max-width: 240px;
outline: none;
}
.network-input:focus {
border-color: var(--accent);
}
.network-error {
color: var(--danger);
font-size: 0.82rem;
}
.btn-sm {
padding: 4px 10px;
font-size: 0.75rem;
}
/* ── Hotspot ──────────────────────────────────────────────────────── */
.hotspot-controls .setting-row:last-child {
border-bottom: 1px solid var(--border);
}
.hotspot-indicator {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 0.78rem;
font-weight: 600;
}
.hotspot-off {
background: var(--text-muted);
color: var(--bg-primary);
}
.hotspot-on {
background: var(--warning);
color: #1a1a1a;
}
.hotspot-actions {
display: flex;
gap: 8px;
padding-top: 10px;
}
/* ── Bluetooth UI ────────────────────────────────────────────────── */
.bt-device-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.bt-device-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.bt-device-item.bt-paired {
border-left: 3px solid var(--text-muted);
}
.bt-device-item.bt-paired.connected {
border-left-color: var(--success);
}
.bt-device-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
}
.bt-device-info {
flex: 1;
min-width: 0;
}
.bt-device-name {
font-size: 0.85rem;
font-weight: 500;
}
.bt-device-meta {
font-size: 0.72rem;
color: var(--text-muted);
display: flex;
gap: 6px;
align-items: center;
}
.bt-device-meta code {
font-size: 0.68rem;
background: var(--bg-card);
padding: 1px 4px;
border-radius: 3px;
}
.bt-device-actions {
display: flex;
gap: 4px;
}
.bt-status-connected {
color: var(--success);
font-weight: 500;
}
.bt-status-disconnected {
color: var(--text-muted);
}
.signal-strong { color: var(--success); }
.signal-medium { color: var(--warning); }
.signal-weak { color: var(--text-muted); }
.bt-midi-indicator {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 0.78rem;
font-weight: 600;
}
.bt-midi-on {
background: var(--success);
color: white;
}
.bt-midi-warn {
background: var(--warning);
color: #1a1a1a;
}
.bt-midi-off {
background: var(--text-muted);
color: var(--bg-primary);
}
.bt-midi-actions {
display: flex;
gap: 8px;
padding: 8px 0;
}
.bt-midi-device {
font-size: 0.82rem;
padding: 2px 0;
}
.bt-midi-device code {
font-size: 0.72rem;
}
+295 -80
View File
@@ -6,103 +6,318 @@
<h1>Settings</h1>
</div>
<div class="card">
<div class="card-header">
<h2>Connection</h2>
</div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">Hostname</div>
<div class="setting-value"><code id="hostname">pedal.local</code></div>
</div>
<div class="setting-row">
<div class="setting-label">Web Server</div>
<div class="setting-value"><code id="web-url">http://pedal.local:8080</code></div>
</div>
<div class="setting-row">
<div class="setting-label">Connected Clients</div>
<div class="setting-value"><span id="ws-count">0</span></div>
</div>
</div>
<!-- Tab navigation -->
<div class="settings-tabs">
<button class="settings-tab active" data-tab="connection" onclick="switchTab('connection')">Connection</button>
<button class="settings-tab" data-tab="audio" onclick="switchTab('audio')">Audio</button>
<button class="settings-tab" data-tab="routing" onclick="switchTab('routing')">4CM Routing</button>
<button class="settings-tab" data-tab="network" onclick="switchTab('network')">Network</button>
<button class="settings-tab" data-tab="bluetooth" onclick="switchTab('bluetooth')">Bluetooth</button>
<button class="settings-tab" data-tab="about" onclick="switchTab('about')">About</button>
</div>
<div class="card">
<div class="card-header">
<h2>Audio</h2>
</div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">Master Volume</div>
<div class="setting-value">
<input type="range" class="slider" id="settings-volume"
min="0" max="100" value="{{ (master_volume * 100)|int }}"
oninput="setVolume(this.value)">
<span id="settings-volume-text">{{ (master_volume * 100)|int }}%</span>
<!-- Tab: Connection -->
<div class="settings-panel active" id="tab-connection">
<div class="card">
<div class="card-header"><h2>Connection</h2></div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">Hostname</div>
<div class="setting-value"><code id="hostname">pedal.local</code></div>
</div>
</div>
<div class="setting-row">
<div class="setting-label">Tuner</div>
<div class="setting-value">
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}"
id="settings-tuner-btn" onclick="toggleTuner()">
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
</button>
<div class="setting-row">
<div class="setting-label">Web Server</div>
<div class="setting-value"><code id="web-url">http://pedal.local:8080</code></div>
</div>
<div class="setting-row">
<div class="setting-label">Connected Clients</div>
<div class="setting-value"><span id="ws-count">0</span></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>4-Cable Method (4CM)</h2>
</div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">4CM Mode</div>
<div class="setting-value">
<button class="btn toggle-btn {% if routing_mode == '4cm' %}active{% endif %}"
id="4cm-toggle-btn" onclick="toggle4cm()">
{% if routing_mode == '4cm' %}ON{% else %}OFF{% endif %}
</button>
<!-- Tab: Audio -->
<div class="settings-panel" id="tab-audio">
<div class="card">
<div class="card-header"><h2>Audio</h2></div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">Master Volume</div>
<div class="setting-value">
<input type="range" class="slider" id="settings-volume"
min="0" max="100" value="{{ (master_volume * 100)|int }}"
oninput="setVolume(this.value)">
<span id="settings-volume-text">{{ (master_volume * 100)|int }}%</span>
</div>
</div>
</div>
<div class="setting-row" id="breakpoint-row"
{% if routing_mode != '4cm' %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
<div class="setting-label">Breakpoint</div>
<div class="setting-value">
<input type="range" class="slider" id="4cm-breakpoint"
min="0" max="16" value="{{ routing_breakpoint }}"
oninput="set4cmBreakpoint(this.value)">
<span id="4cm-breakpoint-text">{{ routing_breakpoint }}</span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">Routing</div>
<div class="setting-value">
<span id="4cm-routing-desc" class="routing-desc">
{% if routing_mode == '4cm' %}
Input 1 (Guitar) → Pre blocks [0..{{ routing_breakpoint }}) → Send
&nbsp;|&nbsp; Input 2 (Return) → Post blocks [{{ routing_breakpoint }}..] → Output
{% else %}
Mono: Guitar → Full chain → Output
{% endif %}
</span>
<div class="setting-row">
<div class="setting-label">Tuner</div>
<div class="setting-value">
<button class="btn toggle-btn {% if tuner_enabled %}active{% endif %}"
id="settings-tuner-btn" onclick="toggleTuner()">
{% if tuner_enabled %}ON{% else %}OFF{% endif %}
</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>About</h2>
<!-- Tab: 4CM Routing -->
<div class="settings-panel" id="tab-routing">
<div class="card">
<div class="card-header"><h2>4-Cable Method (4CM)</h2></div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">4CM Mode</div>
<div class="setting-value">
<button class="btn toggle-btn {% if routing_mode == '4cm' %}active{% endif %}"
id="4cm-toggle-btn" onclick="toggle4cm()">
{% if routing_mode == '4cm' %}ON{% else %}OFF{% endif %}
</button>
</div>
</div>
<div class="setting-row" id="breakpoint-row"
{% if routing_mode != '4cm' %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
<div class="setting-label">Breakpoint</div>
<div class="setting-value">
<input type="range" class="slider" id="4cm-breakpoint"
min="0" max="16" value="{{ routing_breakpoint }}"
oninput="set4cmBreakpoint(this.value)">
<span id="4cm-breakpoint-text">{{ routing_breakpoint }}</span>
</div>
</div>
<div class="setting-row">
<div class="setting-label">Routing</div>
<div class="setting-value">
<span id="4cm-routing-desc" class="routing-desc">
{% if routing_mode == '4cm' %}
Input 1 (Guitar) → Pre blocks [0..{{ routing_breakpoint }}) → Send
&nbsp;|&nbsp; Input 2 (Return) → Post blocks [{{ routing_breakpoint }}..] → Output
{% else %}
Mono: Guitar → Full chain → Output
{% endif %}
</span>
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="about-info">
<p><strong>Pi Multi-FX Pedal</strong> v0.1.0</p>
<p>Raspberry Pi 4B — Python 3.11 — JACK Audio</p>
<p><a href="https://gitea.ourpad.casa/shawn/pi-multifx-pedal" target="_blank">Gitea Repository</a></p>
</div>
<!-- Tab: Network -->
<div class="settings-panel" id="tab-network">
<!-- WiFi Client Status -->
<div class="card" id="wifi-status-card">
<div class="card-header"><h2>WiFi Status</h2></div>
<div class="card-body">
<div class="network-status-row">
<div class="network-status-icon" id="wifi-icon">📶</div>
<div class="network-status-info">
<div class="network-ssid" id="wifi-ssid"></div>
<div class="network-ip" id="wifi-ip"></div>
<div class="network-signal"><span id="wifi-signal-text"></span></div>
</div>
<button class="btn btn-secondary" onclick="refreshWiFiStatus()">Refresh</button>
</div>
</div>
</div>
<!-- WiFi Scan & Connect -->
<div class="card">
<div class="card-header">
<h2>WiFi Networks</h2>
<button class="btn btn-secondary" onclick="scanWiFi()" id="wifi-scan-btn">Scan</button>
</div>
<div class="card-body" id="wifi-scan-results">
<div class="hint">Click Scan to find available WiFi networks.</div>
</div>
</div>
<!-- Known Networks -->
<div class="card" id="wifi-saved-card">
<div class="card-header"><h2>Known Networks</h2></div>
<div class="card-body" id="wifi-saved-list">
<div class="loading">Loading...</div>
</div>
</div>
<!-- Hotspot -->
<div class="card">
<div class="card-header"><h2>WiFi Hotspot</h2></div>
<div class="card-body">
<div class="hotspot-controls">
<div class="setting-row">
<div class="setting-label">Hotspot Status</div>
<div class="setting-value">
<span id="hotspot-status-indicator" class="hotspot-indicator hotspot-off">Off</span>
</div>
</div>
<div class="setting-row" id="hotspot-ssid-row">
<div class="setting-label">SSID</div>
<div class="setting-value">
<input type="text" class="network-input" id="hotspot-ssid" value="Pi-Pedal" maxlength="32">
</div>
</div>
<div class="setting-row" id="hotspot-psk-row">
<div class="setting-label">Password</div>
<div class="setting-value">
<input type="password" class="network-input" id="hotspot-psk" value="pedal1234" maxlength="63">
</div>
</div>
<div class="setting-row">
<div class="setting-label">Connected Clients</div>
<div class="setting-value"><span id="hotspot-clients">0</span></div>
</div>
<div class="hotspot-actions">
<button class="btn btn-primary" id="hotspot-enable-btn" onclick="enableHotspot()">Enable Hotspot</button>
<button class="btn btn-danger" id="hotspot-disable-btn" onclick="disableHotspot()">Disable Hotspot</button>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Bluetooth -->
<div class="settings-panel" id="tab-bluetooth">
<!-- BT Adapter Status -->
<div class="card">
<div class="card-header"><h2>Bluetooth</h2></div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">Bluetooth</div>
<div class="setting-value">
<button class="btn toggle-btn" id="bt-power-btn" onclick="toggleBTPower()">OFF</button>
</div>
</div>
<div class="setting-row">
<div class="setting-label">Discoverable</div>
<div class="setting-value">
<button class="btn toggle-btn" id="bt-discoverable-btn" onclick="toggleBTDiscoverable()">OFF</button>
</div>
</div>
<div class="setting-row">
<div class="setting-label">Adapter Name</div>
<div class="setting-value"><span id="bt-adapter-name"></span></div>
</div>
<div class="setting-row">
<div class="setting-label">Address</div>
<div class="setting-value"><code id="bt-address"></code></div>
</div>
</div>
</div>
<!-- BT Scan -->
<div class="card">
<div class="card-header">
<h2>Discover Devices</h2>
<button class="btn btn-secondary" onclick="scanBT()" id="bt-scan-btn">Scan</button>
</div>
<div class="card-body" id="bt-scan-results">
<div class="hint">Click Scan to find discoverable Bluetooth devices.</div>
</div>
</div>
<!-- Paired Devices -->
<div class="card">
<div class="card-header"><h2>Paired Devices</h2></div>
<div class="card-body" id="bt-paired-list">
<div class="loading">Loading...</div>
</div>
</div>
<!-- BT MIDI Service -->
<div class="card">
<div class="card-header"><h2>Bluetooth MIDI</h2></div>
<div class="card-body">
<div class="setting-row">
<div class="setting-label">BT MIDI Service</div>
<div class="setting-value">
<span class="bt-midi-indicator" id="bt-midi-indicator">Off</span>
</div>
</div>
<div class="bt-midi-actions">
<button class="btn btn-primary" id="bt-midi-enable-btn" onclick="enableBTMIDI()">Enable</button>
<button class="btn btn-danger" id="bt-midi-disable-btn" onclick="disableBTMIDI()">Disable</button>
</div>
<div class="setting-row mt-8">
<div class="setting-label">Connected MIDI Devices</div>
<div class="setting-value" id="bt-midi-devices">
<span class="hint">None connected</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: About -->
<div class="settings-panel" id="tab-about">
<div class="card">
<div class="card-header"><h2>About</h2></div>
<div class="card-body">
<div class="about-info">
<p><strong>Pi Multi-FX Pedal</strong> v0.1.0</p>
<p>Raspberry Pi 4B — Python 3.11 — JACK Audio</p>
<p><a href="https://gitea.ourpad.casa/shawn/pi-multifx-pedal" target="_blank">Gitea Repository</a></p>
</div>
</div>
</div>
</div>
<!-- WiFi connect modal -->
<div class="modal-overlay" id="wifi-connect-modal" style="display:none;" onclick="if(event.target===this)closeWiFiModal()">
<div class="modal">
<div class="modal-header">
<h2>Connect to <span id="wifi-modal-ssid"></span></h2>
<button class="btn-close" onclick="closeWiFiModal()">&times;</button>
</div>
<div class="modal-body">
<div class="setting-row">
<div class="setting-label">Password</div>
<div class="setting-value">
<input type="password" class="network-input" id="wifi-modal-password" placeholder="Enter WiFi password">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="connectWiFi()">Connect</button>
<button class="btn btn-secondary" onclick="closeWiFiModal()">Cancel</button>
</div>
<div id="wifi-modal-error" class="network-error mt-8"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/network.js"></script>
<script src="/static/bluetooth.js"></script>
<script>
/* ── Tab switching ────────────────────────────────────────────────── */
function switchTab(tabId) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
document.querySelector(`.settings-tab[data-tab="${tabId}"]`).classList.add('active');
document.getElementById(`tab-${tabId}`).classList.add('active');
// Refresh tab data when switching to it
if (tabId === 'network') {
refreshWiFiStatus();
loadSavedNetworks();
} else if (tabId === 'bluetooth') {
refreshBTStatus();
refreshBTMIDIStatus();
loadPairedDevices();
}
}
/* ── Initial load on settings page ───────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
// Check if we should auto-switch based on URL hash
const hash = window.location.hash.replace('#', '');
if (['network', 'bluetooth', 'audio', 'routing', 'about'].includes(hash)) {
switchTab(hash);
}
});
</script>
{% endblock %}