import re
import subprocess
import threading
import time
from flask import Blueprint, jsonify, request, abort
from auth import login_required
bp = Blueprint('bluetooth', __name__, url_prefix='/api/bluetooth')
_BT_ADDR_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
_bt_scanning = False
_bt_scan_lock = threading.Lock()
_bt_scan_proc = None
def _bt_run(*args, timeout=15):
cmd = ['bluetoothctl'] + list(args)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout.strip()
except subprocess.TimeoutExpired:
return 1, 'timeout'
except Exception as e:
return 1, str(e)
def _bt_status():
rc, out = _bt_run('show')
powered = False
name = ''
addr = ''
discovering = False
for line in out.splitlines():
s = line.strip()
if s.startswith('Powered:'): powered = 'yes' in s
elif s.startswith('Name:'): name = s.split(':', 1)[1].strip()
elif s.startswith('Discovering:'): discovering = 'yes' in s
m = re.search(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', out.split('\n')[0] if out else '')
if m:
addr = m.group(0)
return {
'powered': powered,
'name': name,
'address': addr,
'discovering': discovering or _bt_scanning,
'available': rc == 0,
}
def _bt_device_info(addr):
rc, info = _bt_run('info', addr)
connected = paired = trusted = False
dev_name = ''
dev_type = ''
for line in info.splitlines():
s = line.strip()
if s.startswith('Name:'): dev_name = s.split(':', 1)[1].strip()
elif s.startswith('Connected:'): connected = 'yes' in s
elif s.startswith('Paired:'): paired = 'yes' in s
elif s.startswith('Trusted:'): trusted = 'yes' in s
elif s.startswith('Icon:'):
icon = s.split(':', 1)[1].strip()
if any(k in icon for k in ('audio', 'headphone', 'headset', 'speaker')):
dev_type = 'audio'
elif 'phone' in icon:
dev_type = 'phone'
elif 'input' in icon:
dev_type = 'input'
else:
dev_type = icon
return {
'address': addr, 'name': dev_name,
'connected': connected, 'paired': paired, 'trusted': trusted,
'type': dev_type,
}
def _bt_devices():
rc, out = _bt_run('devices')
result = []
for line in out.splitlines():
m = re.match(r'Device\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+(.*)', line)
if m:
addr = m.group(1)
name = m.group(2).strip()
info = _bt_device_info(addr)
if not info['name']:
info['name'] = name
result.append(info)
return result
def _bt_scan_worker(duration=15):
global _bt_scanning, _bt_scan_proc
try:
_bt_scan_proc = subprocess.Popen(
['bluetoothctl', 'scan', 'on'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(duration)
finally:
try:
if _bt_scan_proc:
_bt_scan_proc.terminate()
_bt_scan_proc = None
except Exception:
pass
_bt_run('scan', 'off', timeout=5)
with _bt_scan_lock:
_bt_scanning = False
@bp.route('/status')
@login_required
def api_bt_status():
return jsonify(_bt_status())
@bp.route('/devices')
@login_required
def api_bt_devices():
return jsonify({'devices': _bt_devices(), 'scanning': _bt_scanning})
@bp.route('/scan', methods=['POST'])
@login_required
def api_bt_scan():
global _bt_scanning
with _bt_scan_lock:
if _bt_scanning:
return jsonify({'ok': True, 'scanning': True})
_bt_scanning = True
t = threading.Thread(target=_bt_scan_worker, args=(15,), daemon=True)
t.start()
return jsonify({'ok': True, 'scanning': True})
@bp.route('/pair', methods=['POST'])
@login_required
def api_bt_pair():
data = request.get_json()
if not data or not data.get('address'):
abort(400)
addr = str(data['address'])
if not _BT_ADDR_RE.match(addr):
abort(400)
_bt_run('trust', addr, timeout=5)
rc, out = _bt_run('pair', addr, timeout=30)
ok = rc == 0 or 'already paired' in out.lower() or 'already exists' in out.lower()
if ok:
_bt_run('connect', addr, timeout=15)
return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
@bp.route('/connect', methods=['POST'])
@login_required
def api_bt_connect():
data = request.get_json()
if not data or not data.get('address'):
abort(400)
addr = str(data['address'])
if not _BT_ADDR_RE.match(addr):
abort(400)
rc, out = _bt_run('connect', addr, timeout=15)
ok = rc == 0 or 'connected' in out.lower()
return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
@bp.route('/disconnect', methods=['POST'])
@login_required
def api_bt_disconnect():
data = request.get_json()
if not data or not data.get('address'):
abort(400)
addr = str(data['address'])
if not _BT_ADDR_RE.match(addr):
abort(400)
_bt_run('disconnect', addr, timeout=10)
return '', 204
@bp.route('/device/<path:address>', methods=['DELETE'])
@login_required
def api_bt_remove(address):
if not _BT_ADDR_RE.match(address):
abort(400)
_bt_run('remove', address, timeout=10)
return '', 204
@bp.route('/power', methods=['POST'])
@login_required
def api_bt_power():
data = request.get_json() or {}
on = bool(data.get('on', True))
_bt_run('power', 'on' if on else 'off', timeout=5)
return jsonify(_bt_status())