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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user