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:
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
| 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
|
||||
| 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()">×</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 %}
|
||||
Reference in New Issue
Block a user