Files
jsahnandClaude Opus 5.5 1cd00ae21b Show Bluetooth inputs when BlueZ objects can't be read as JSON
inputs() still used GetManagedObjects on BlueZ to look up names and
silently dropped every Bluetooth input when an Apple device's
advertisement data broke the JSON conversion. Read only Alias per
device and fall back to the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:01:54 +09:00

203 lines
8.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 properties(service, path, interface, names):
"""Read selected properties only.
GetManagedObjects cannot be used for BlueZ: busctl fails to convert some
advertisement data (e.g. Apple ManufacturerData a{qv}) to JSON, which
would hide every device.
"""
out = command('busctl', '--system', '--json=short', 'get-property', service, path, interface, *names)
return {name: json.loads(line)['data'] for name, line in zip(names, out.splitlines())}
def bluez_paths(pattern):
return [p for p in command('busctl', '--system', 'tree', '--list', 'org.bluez').split()
if re.fullmatch(pattern, p)]
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:
for path, props in pcms().items():
if not is_input(props): continue
dev = props['Device']; key = address(dev)
# A missing name must not hide a working input.
try: name = properties('org.bluez', dev, 'org.bluez.Device1', ['Alias'])['Alias']
except (ValueError, KeyError, subprocess.TimeoutExpired): name = key
volume, muted = decode_volume(int(props['Volume']))
result.append({'id':key,'name':name,'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 = []
base = ['Address', 'Alias', 'Paired', 'Bonded', 'Connected']
for path in bluez_paths(r'/org/bluez/hci\d+/dev_[0-9A-F_]{17}'):
try:
try: props = properties('org.bluez', path, 'org.bluez.Device1', base + ['UUIDs'])
# UUIDs is absent until services are known; devices may also vanish mid-read.
except ValueError: props = properties('org.bluez', path, 'org.bluez.Device1', base)
except ValueError: 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():
paths = bluez_paths(r'/org/bluez/hci\d+')
adapter = properties('org.bluez', paths[0], 'org.bluez.Adapter1', ['Discoverable', 'Alias']) if paths else {}
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}