The board stays hidden by default, so new phones could only pair via an SSH session. Add 'radxa-audio discoverable on|off' backed by a transient radxa-bt-pairing unit that auto-approves and trusts devices pairing in the window, expires on its own, and expose it in the web mixer with a countdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
7.5 KiB
Python
182 lines
7.5 KiB
Python
"""Native input volume controls. Never writes a DAC/headphone output volume."""
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import os
|
|
import time
|
|
|
|
CONFIG = Path('/etc/radxa-audio/mixer.json')
|
|
MEMORY = Path('/etc/radxa-audio/input-memory.json')
|
|
PAIRING_UNTIL = Path('/run/radxa-audio/pairing-until')
|
|
SPS = 'org.gnome.ShairportSync'
|
|
SPS_PATH = '/org/gnome/ShairportSync'
|
|
MAC = re.compile(r'(?:[0-9A-F]{2}:){5}[0-9A-F]{2}\Z')
|
|
|
|
|
|
def command(*args):
|
|
p = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=12)
|
|
if p.returncode:
|
|
raise ValueError(p.stderr.strip() or p.stdout.strip() or '장치 제어에 실패했습니다.')
|
|
return p.stdout
|
|
|
|
|
|
def bus(*args):
|
|
result = json.loads(command('busctl', '--system', '--json=short', *args))
|
|
# Method replies contain a tuple; properties contain their direct value.
|
|
return result['data'][0] if args[0] == 'call' else result['data']
|
|
|
|
|
|
def objects(service, path):
|
|
raw = bus('call', service, path, 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects')
|
|
return {p: {i: {k: v['data'] for k, v in props.items()} for i, props in interfaces.items()}
|
|
for p, interfaces in raw.items()}
|
|
|
|
|
|
def pcms():
|
|
return {p: interfaces['org.bluealsa.PCM1'] for p, interfaces in objects('org.bluealsa', '/org/bluealsa').items()
|
|
if 'org.bluealsa.PCM1' in interfaces}
|
|
|
|
|
|
def is_input(props):
|
|
return props.get('Mode') == 'source' and props.get('Transport') == 'A2DP-sink'
|
|
|
|
|
|
def memory():
|
|
try: return json.loads(MEMORY.read_text())
|
|
except FileNotFoundError: return {}
|
|
|
|
|
|
def remember(key, percent):
|
|
data = memory(); data[key] = percent
|
|
MEMORY.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, name = tempfile.mkstemp(dir=MEMORY.parent)
|
|
try:
|
|
with os.fdopen(fd, 'w') as f: json.dump(data, f)
|
|
os.chmod(name, 0o644); os.replace(name, MEMORY)
|
|
finally:
|
|
if os.path.exists(name): os.unlink(name)
|
|
|
|
|
|
def address(device_path):
|
|
match = re.search(r'/dev_([0-9A-F_]{17})$', device_path)
|
|
if not match: raise ValueError('장비 주소를 읽을 수 없습니다.')
|
|
return match[1].replace('_', ':')
|
|
|
|
|
|
def airplay():
|
|
props = bus('call', SPS, SPS_PATH, 'org.freedesktop.DBus.Properties', 'GetAll', 's', SPS)
|
|
props = {k: v['data'] for k,v in props.items()}
|
|
value = props['Volume']
|
|
if props.get('Active'):
|
|
try:
|
|
value = bus('get-property', SPS, SPS_PATH, SPS+'.RemoteControl', 'AirplayVolume')
|
|
except (ValueError, subprocess.TimeoutExpired):
|
|
pass
|
|
muted = value <= -100
|
|
percent = round((value + 30) / 30 * 100) if not muted else memory().get('airplay', 75)
|
|
return {'id':'airplay', 'name':'AirPlay', 'kind':'airplay', 'volume':max(0,min(100,percent)),
|
|
'muted':muted, 'playing':bool(props.get('Active')), 'available':True}
|
|
|
|
|
|
def decode_volume(value):
|
|
levels = [(value >> 8) & 127, value & 127]
|
|
return round(sum(levels) / 254 * 100), bool(value & 0x8080)
|
|
|
|
|
|
def encode_volume(percent, muted):
|
|
byte = round(percent * 127 / 100) | (128 if muted else 0)
|
|
return (byte << 8) | byte
|
|
|
|
|
|
def inputs():
|
|
result = []
|
|
try: result.append(airplay())
|
|
except (ValueError, KeyError, subprocess.TimeoutExpired):
|
|
result.append({'id':'airplay','name':'AirPlay','kind':'airplay','volume':0,
|
|
'muted':False,'playing':False,'available':False})
|
|
try:
|
|
devices = objects('org.bluez', '/')
|
|
for path, props in pcms().items():
|
|
if not is_input(props): continue
|
|
dev = props['Device']; key = address(dev)
|
|
d = devices.get(dev, {}).get('org.bluez.Device1', {})
|
|
volume, muted = decode_volume(int(props['Volume']))
|
|
result.append({'id':key,'name':d.get('Alias', key),'kind':'bluetooth','volume':volume,
|
|
'muted':muted,'playing':bool(props.get('Running')),'available':True})
|
|
except (ValueError, KeyError, subprocess.TimeoutExpired): pass
|
|
return result
|
|
|
|
|
|
def set_input(key, volume=None, mute=None):
|
|
if volume is not None and (type(volume) is not int or not 0 <= volume <= 100):
|
|
raise ValueError('입력 볼륨은 0~100의 정수여야 합니다.')
|
|
if mute is not None and type(mute) is not bool: raise ValueError('음소거 값이 잘못됐습니다.')
|
|
if key == 'airplay':
|
|
current = airplay()
|
|
percent = current['volume'] if volume is None else volume
|
|
muted = current['muted'] if mute is None else mute
|
|
if volume is not None: muted = percent == 0
|
|
if mute is True: remember(key, current['volume'])
|
|
if not muted and percent: remember(key, percent)
|
|
value = -144.0 if muted or percent == 0 else -30.0 + percent * 0.3
|
|
command('busctl','--system','--','set-property',SPS,SPS_PATH,SPS,'Volume','d',str(value))
|
|
return
|
|
key = str(key).upper()
|
|
if not MAC.fullmatch(key): raise ValueError('입력 장비 주소가 잘못됐습니다.')
|
|
matches = [(p, v) for p,v in pcms().items() if is_input(v) and address(v['Device']) == key]
|
|
if not matches: raise ValueError('이 장비는 연결된 Bluetooth 입력이 아닙니다.')
|
|
for path, props in matches:
|
|
old, old_mute = decode_volume(int(props['Volume']))
|
|
percent = old if volume is None else volume
|
|
muted = old_mute if mute is None else mute
|
|
if volume is not None: muted = percent == 0
|
|
# Only the validated source PCM is written; output PCMs cannot pass this guard.
|
|
command('busctl','--system','--','set-property','org.bluealsa',path,'org.bluealsa.PCM1','SoftVolume','b','true')
|
|
command('busctl','--system','--','set-property','org.bluealsa',path,'org.bluealsa.PCM1','Volume','q',
|
|
str(encode_volume(percent, muted)))
|
|
|
|
|
|
def devices():
|
|
result = []
|
|
for path, interfaces in objects('org.bluez', '/').items():
|
|
props = interfaces.get('org.bluez.Device1')
|
|
if not props: continue
|
|
result.append({'address': props['Address'],'name': props.get('Alias',props['Address']),
|
|
'bonded': bool(props.get('Bonded')), 'paired': bool(props.get('Paired')),'connected':bool(props.get('Connected')),
|
|
'output': '0000110b-0000-1000-8000-00805f9b34fb' in props.get('UUIDs',[])})
|
|
return result
|
|
|
|
|
|
def pairing():
|
|
adapter = next((i['org.bluez.Adapter1'] for i in objects('org.bluez', '/').values()
|
|
if 'org.bluez.Adapter1' in i), {})
|
|
on = bool(adapter.get('Discoverable'))
|
|
remaining = 0
|
|
try: remaining = max(0, round(float(PAIRING_UNTIL.read_text()) - time.time()))
|
|
except (FileNotFoundError, ValueError): pass
|
|
return {'discoverable': on, 'remaining': remaining if on else 0, 'alias': adapter.get('Alias', '')}
|
|
|
|
|
|
def dacs():
|
|
result = []
|
|
for line in command('aplay','-l').splitlines():
|
|
match = re.match(r'card \d+: ([\w-]+) \[([^]]+)\], device (\d+): ([^[]+)',line)
|
|
if match and match[1] != 'Loopback':
|
|
result.append({'pcm':f'hw:CARD={match[1]},DEV={match[3]}','name':match[2]+' · '+match[4].strip()})
|
|
return result
|
|
|
|
|
|
def state():
|
|
config = json.loads(CONFIG.read_text())
|
|
issues = []
|
|
try: bluetooth = devices()
|
|
except Exception as e: bluetooth=[]; issues.append(str(e))
|
|
try: dac = dacs()
|
|
except Exception as e: dac=[]; issues.append(str(e))
|
|
try: pair = pairing()
|
|
except Exception as e: pair={'discoverable':False,'remaining':0,'alias':''}; issues.append(str(e))
|
|
return {'config':config,'inputs':inputs(),'bluetooth':bluetooth,'dacs':dac,'pairing':pair,'issues':issues}
|