Files
shawn b4237f2f1d 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
2026-06-09 01:14:41 -04:00

383 lines
13 KiB
Python

"""Tests for the Bluetooth management module (src.system.bluetooth).
Uses mocking to avoid requiring actual Bluetooth hardware.
Run with:
pytest tests/test_bt.py -v
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure src is on sys.path
SRC = Path(__file__).resolve().parent.parent / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from src.system.bluetooth import (
bt_status,
bt_power,
bt_discoverable,
bt_scan,
bt_paired_devices,
bt_pair,
bt_unpair,
bt_connect,
bt_disconnect,
bt_midi_status,
bt_midi_enable,
bt_midi_disable,
bt_midi_connected_devices,
)
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def mock_subprocess():
"""Patch subprocess.run globally for all BT tests."""
with patch("src.system.bluetooth.subprocess.run") as mock:
yield mock
# ── bt_status() tests ─────────────────────────────────────────────────────────
def test_bt_status_powered_on(mock_subprocess):
"""bt_status returns powered-on state."""
mock_subprocess.return_value = MagicMock(
stdout="Controller AA:BB:CC:DD:EE:FF\n"
"\tName: pi-pedal\n"
"\tPowered: yes\n"
"\tDiscoverable: yes\n"
"\tPairable: yes\n",
returncode=0,
)
status = bt_status()
assert status["available"] is True
assert status["powered"] is True
assert status["name"] == "pi-pedal"
assert status["discoverable"] is True
assert status["pairable"] is True
assert status["address"] == "AA:BB:CC:DD:EE:FF"
def test_bt_status_powered_off(mock_subprocess):
"""bt_status returns powered-off state."""
mock_subprocess.return_value = MagicMock(
stdout="Controller AA:BB:CC:DD:EE:FF\n"
"\tName: pi-pedal\n"
"\tPowered: no\n"
"\tDiscoverable: no\n"
"\tPairable: no\n",
returncode=0,
)
status = bt_status()
assert status["powered"] is False
assert status["discoverable"] is False
def test_bt_status_not_available(mock_subprocess):
"""bt_status returns not available when bluetoothctl fails."""
mock_subprocess.side_effect = FileNotFoundError("no bluetoothctl")
status = bt_status()
assert status["available"] is False
assert status["powered"] is False
# ── bt_power() tests ──────────────────────────────────────────────────────────
def test_bt_power_on(mock_subprocess):
"""bt_power(on=True) powers Bluetooth on."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # bluetoothctl power on
MagicMock( # bt_status call
stdout="Controller A\n\tPowered: yes\n",
returncode=0,
),
]
result = bt_power(on=True)
assert result["ok"] is True
def test_bt_power_off(mock_subprocess):
"""bt_power(on=False) powers Bluetooth off."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # bluetoothctl power off
MagicMock( # bt_status call
stdout="Controller A\n\tPowered: no\n",
returncode=0,
),
]
result = bt_power(on=False)
assert result["ok"] is True
# ── bt_discoverable() tests ────────────────────────────────────────────────────
def test_bt_discoverable_on(mock_subprocess):
"""bt_discoverable(on=True) makes adapter discoverable."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # discoverable on
MagicMock( # bt_status
stdout="Controller A\n\tDiscoverable: yes\n",
returncode=0,
),
]
result = bt_discoverable(on=True)
assert result["ok"] is True
def test_bt_discoverable_off(mock_subprocess):
"""bt_discoverable(on=False) hides adapter."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0),
MagicMock(
stdout="Controller A\n\tDiscoverable: no\n",
returncode=0,
),
]
result = bt_discoverable(on=False)
assert result["ok"] is True
# ── bt_scan() tests ───────────────────────────────────────────────────────────
def test_bt_scan_finds_devices(mock_subprocess):
"""bt_scan returns discovered devices."""
# Simulate the three bluetoothctl calls
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # scan on
MagicMock(stdout="", returncode=0), # scan off
MagicMock( # devices
stdout="Device AA:BB:CC:DD:EE:FF My Guitar\n"
"Device 11:22:33:44:55:66 Expression Pedal\n",
returncode=0,
),
]
# bt_scan uses time.sleep which we don't want to actually wait
with patch("src.system.bluetooth.time.sleep", return_value=None):
result = bt_scan(timeout=1)
assert result["ok"] is True
assert len(result["devices"]) == 2
assert result["devices"][0]["name"] == "My Guitar"
assert result["devices"][0]["address"] == "AA:BB:CC:DD:EE:FF"
assert result["devices"][1]["name"] == "Expression Pedal"
def test_bt_scan_no_devices(mock_subprocess):
"""bt_scan returns empty list when no devices found."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # scan on
MagicMock(stdout="", returncode=0), # scan off
MagicMock(stdout="", returncode=0), # devices - empty
]
with patch("src.system.bluetooth.time.sleep", return_value=None):
result = bt_scan(timeout=1)
assert result["ok"] is True
assert result["devices"] == []
# ── bt_paired_devices() tests ──────────────────────────────────────────────────
def test_bt_paired_devices(mock_subprocess):
"""bt_paired_devices returns paired device list."""
mock_subprocess.side_effect = [
MagicMock( # paired-devices
stdout="Device AA:BB:CC:DD:EE:FF MIDI Controller\n"
"Device 11:22:33:44:55:66 Footswitch\n",
returncode=0,
),
MagicMock( # info
stdout="",
returncode=0,
),
]
devices = bt_paired_devices()
assert len(devices) == 2
assert devices[0]["name"] == "MIDI Controller"
assert devices[0]["address"] == "AA:BB:CC:DD:EE:FF"
def test_bt_paired_devices_empty(mock_subprocess):
"""bt_paired_devices returns empty list."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # paired-devices - empty
MagicMock(stdout="", returncode=0),
]
devices = bt_paired_devices()
assert devices == []
# ── bt_pair() tests ───────────────────────────────────────────────────────────
def test_bt_pair_success(mock_subprocess):
"""bt_pair successfully pairs with a device."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # trust
MagicMock(stdout="Pairing successful\n", returncode=0), # pair
MagicMock(stdout="", returncode=0), # connect
]
with patch("src.system.bluetooth.time.sleep", return_value=None):
result = bt_pair("AA:BB:CC:DD:EE:FF")
assert result["ok"] is True
def test_bt_pair_invalid_address():
"""bt_pair returns error for invalid MAC address."""
result = bt_pair("invalid")
assert result["ok"] is False
assert "Invalid" in result["error"]
def test_bt_pair_failure(mock_subprocess):
"""bt_pair returns error when pairing fails."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # trust
MagicMock(stdout="Failed to pair\n", returncode=0), # pair
]
with patch("src.system.bluetooth.time.sleep", return_value=None):
result = bt_pair("AA:BB:CC:DD:EE:FF")
assert result["ok"] is False
assert "failed" in result["error"].lower()
# ── bt_unpair() tests ──────────────────────────────────────────────────────────
def test_bt_unpair_success(mock_subprocess):
"""bt_unpair removes a device."""
mock_subprocess.side_effect = [
MagicMock(stdout="", returncode=0), # disconnect
MagicMock(stdout="Device has been removed\n", returncode=0), # remove
]
with patch("src.system.bluetooth.time.sleep", return_value=None):
result = bt_unpair("AA:BB:CC:DD:EE:FF")
assert result["ok"] is True
def test_bt_unpair_invalid():
"""bt_unpair returns error for invalid address."""
result = bt_unpair("invalid")
assert result["ok"] is False
# ── bt_connect() / bt_disconnect() tests ───────────────────────────────────────
def test_bt_connect_success(mock_subprocess):
"""bt_connect connects to a paired device."""
mock_subprocess.return_value = MagicMock(
stdout="Connection successful\n",
returncode=0,
)
result = bt_connect("AA:BB:CC:DD:EE:FF")
assert result["ok"] is True
def test_bt_connect_invalid():
"""bt_connect returns error for invalid address."""
result = bt_connect("bad")
assert result["ok"] is False
def test_bt_disconnect_success(mock_subprocess):
"""bt_disconnect disconnects a device."""
mock_subprocess.return_value = MagicMock(stdout="", returncode=0)
result = bt_disconnect("AA:BB:CC:DD:EE:FF")
assert result["ok"] is True
def test_bt_disconnect_invalid():
"""bt_disconnect returns error for invalid address."""
result = bt_disconnect("bad")
assert result["ok"] is False
# ── bt_midi_status() tests ─────────────────────────────────────────────────────
def test_bt_midi_status_enabled_running(mock_subprocess):
"""bt_midi_status shows enabled and running."""
mock_subprocess.side_effect = [
MagicMock(stdout="enabled\n", returncode=0), # is-enabled
MagicMock(stdout="active\n", returncode=0), # is-active
]
status = bt_midi_status()
assert status["enabled"] is True
assert status["running"] is True
def test_bt_midi_status_disabled(mock_subprocess):
"""bt_midi_status shows disabled."""
mock_subprocess.side_effect = [
MagicMock(stdout="disabled\n", returncode=0),
MagicMock(stdout="inactive\n", returncode=0),
]
status = bt_midi_status()
assert status["enabled"] is False
assert status["running"] is False
# ── bt_midi_enable() / bt_midi_disable() tests ─────────────────────────────────
def test_bt_midi_enable_success(mock_subprocess):
"""bt_midi_enable starts the MIDI service."""
mock_subprocess.return_value = MagicMock(returncode=0)
result = bt_midi_enable()
assert result["ok"] is True
def test_bt_midi_enable_failure(mock_subprocess):
"""bt_midi_enable returns error on failure."""
mock_subprocess.side_effect = subprocess.CalledProcessError(1, "systemctl")
result = bt_midi_enable()
assert result["ok"] is False
def test_bt_midi_disable_success(mock_subprocess):
"""bt_midi_disable stops the MIDI service."""
mock_subprocess.return_value = MagicMock(returncode=0)
result = bt_midi_disable()
assert result["ok"] is True
# ── bt_midi_connected_devices() tests ──────────────────────────────────────────
def test_bt_midi_connected_devices(mock_subprocess):
"""bt_midi_connected_devices returns connected devices."""
mock_subprocess.side_effect = [
MagicMock( # paired-devices
stdout="Device AA:BB:CC:DD:EE:FF MIDI Keys\n"
"Device 11:22:33:44:55:66 Pedal\n",
returncode=0,
),
MagicMock(stdout="", returncode=0), # info - none connected
]
devices = bt_midi_connected_devices()
assert devices == [] # none connected
def test_bt_midi_connected_devices_with_connected(mock_subprocess):
"""bt_midi_connected_devices returns only connected devices."""
with patch("src.system.bluetooth.bt_paired_devices", return_value=[
{"address": "AA:BB:CC:DD:EE:FF", "name": "MIDI Keys", "connected": True},
{"address": "11:22:33:44:55:66", "name": "Pedal", "connected": False},
]):
devices = bt_midi_connected_devices()
assert len(devices) == 1
assert devices[0]["name"] == "MIDI Keys"