b4237f2f1d
- 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
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
/**
|
|
* 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();
|
|
}); |