#!/usr/bin/env python3 """Radxa audio mixer manager. Debian 13 / systemd; Python standard library only.""" import argparse import datetime import json import os from pathlib import Path import re import shutil import subprocess import sys import tempfile CONFIG = Path('/etc/radxa-audio/mixer.json') BT_SERVICE = 'radxa-bt-output.service' PAIRING_UNIT = 'radxa-bt-pairing.service' PAIRING_UNTIL = Path('/run/radxa-audio/pairing-until') MAC_RE = re.compile(r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\Z') PCM_RE = re.compile(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+(?:,SUBDEV=[0-9]+)?\Z') def run(*args, check=True, capture=False, timeout=40): return subprocess.run(args, check=check, text=True, stdout=subprocess.PIPE if capture else None, stderr=subprocess.STDOUT if capture else None, timeout=timeout) def root(): if os.geteuid() != 0: raise ValueError('sudo로 실행해 주세요.') def validate(c): if not isinstance(c.get('name'), str) or not c['name'].strip() or len(c['name'].encode()) > 80: raise ValueError('AirPlay 이름은 1~80바이트여야 합니다.') if any(ord(ch) < 32 for ch in c['name']): raise ValueError('이름에는 제어 문자를 사용할 수 없습니다.') if not PCM_RE.fullmatch(c.get('dac_pcm', '')): raise ValueError('DAC 형식 예: hw:CARD=Audio,DEV=0') if c.get('output') not in ('dac', 'bluetooth'): raise ValueError('output은 dac 또는 bluetooth여야 합니다.') addr = c.get('bluetooth_device', '') if addr and not MAC_RE.fullmatch(addr): raise ValueError('블루투스 주소 형식이 잘못됐습니다.') if c['output'] == 'bluetooth' and not addr: raise ValueError('먼저 블루투스 출력 장비 주소를 설정해 주세요.') return c def load(): if not CONFIG.exists(): raise ValueError('설치되지 않았습니다. install.sh를 먼저 실행해 주세요.') return validate(json.loads(CONFIG.read_text())) def save(path, text, mode=0o644): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) fd, temp = tempfile.mkstemp(dir=path.parent, prefix='.radxa-') try: with os.fdopen(fd, 'w') as f: f.write(text) os.chmod(temp, mode) os.replace(temp, path) finally: if os.path.exists(temp): os.unlink(temp) def mixer(pcm, rate): return f'''# Managed by radxa-audio; inputs are mixed at 0 dB (no attenuation). pcm.radxa_dmix {{ type dmix ipc_key 553001 ipc_key_add_uid false ipc_perm 0660 ipc_gid audio slave {{ pcm "{pcm}" format S16_LE rate {rate} channels 2 period_time 0 period_size {rate // 100} buffer_size {rate // 5} }} bindings {{ 0 0 1 1 }} }} pcm.radxa_mix {{ type plug slave.pcm "radxa_dmix" ttable {{ 0.0 1.0 1.1 1.0 }} }} ''' def templates(c): validate(c) dac = mixer(c['dac_pcm'], 44100) bt = mixer('hw:CARD=Loopback,DEV=0,SUBDEV=0', 48000) files = { '/etc/radxa-audio/asound-dac.conf': dac, '/etc/radxa-audio/asound-bluetooth.conf': bt, '/etc/asound.conf': dac if c['output'] == 'dac' else bt, str(CONFIG): json.dumps(c, ensure_ascii=False, indent=2) + '\n', '/etc/shairport-sync.conf': f'''general = {{ name = {json.dumps(c['name'], ensure_ascii=False)}; service_type = "airplay2"; output_backend = "alsa"; audio_backend_buffer_desired_length_in_seconds = 0.15; }}; alsa = {{ output_device = "radxa_mix"; output_format = "S16_LE"; }}; diagnostics = {{ statistics = "yes"; log_verbosity = 1; }}; ''', '/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf': '''[Service] ExecStart= ExecStart=/usr/bin/bluealsa -S -p a2dp-sink -p a2dp-source --initial-volume=50 --sbc-quality=medium ''', '/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit] Requires=bluealsa.service After=bluealsa.service sound.target [Service] User=shairport-sync Group=audio PrivateUsers=false RemoveIPC=false ExecStart= ExecStart=/usr/bin/bluealsa-aplay -S --pcm=radxa_mix --volume=software --pcm-period-time=10000 --pcm-buffer-time=200000 ''', '/etc/modules-load.d/radxa-audio-loopback.conf': 'snd-aloop\n', '/etc/modprobe.d/radxa-audio-loopback.conf': 'options snd-aloop pcm_substreams=1\n', } if c.get('bluetooth_device'): addr = c['bluetooth_device'].upper() files['/etc/systemd/system/' + BT_SERVICE] = f'''[Unit] Description=Radxa mixed audio to Bluetooth headphones or speaker Requires=bluealsa.service After=bluealsa.service sound.target systemd-modules-load.service PartOf=bluealsa.service StartLimitIntervalSec=0 [Service] User=shairport-sync Group=audio ExecStartPre=-/usr/bin/bluetoothctl --timeout 8 connect {addr} a2dp-sink ExecStart=/usr/bin/alsaloop -C hw:Loopback,1,0 -P bluealsa:DEV={addr},PROFILE=a2dp -c 2 -r 48000 -f S16_LE -t 200000 -S 4 -A 2 Restart=always RestartSec=5 TimeoutStartSec=30 [Install] WantedBy=multi-user.target ''' return files def snapshot(paths): stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f') directory = Path('/var/backups/radxa-audio') / stamp directory.mkdir(parents=True, mode=0o700) records = {} for name in paths: path = Path(name) records[name] = os.path.lexists(path) if records[name]: dest = directory / name.lstrip('/') dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, dest, follow_symlinks=False) save(directory / 'files.json', json.dumps(records, indent=2) + '\n', 0o600) return directory def restore_files(directory): records = json.loads((directory / 'files.json').read_text()) for name, existed in records.items(): path = Path(name) if existed: path.parent.mkdir(parents=True, exist_ok=True) path.unlink(missing_ok=True) shutil.copy2(directory / name.lstrip('/'), path, follow_symlinks=False) else: path.unlink(missing_ok=True) def active(unit): return run('systemctl', 'is-active', '--quiet', unit, check=False, capture=True).returncode == 0 def enabled(unit): return run('systemctl', 'is-enabled', '--quiet', unit, check=False, capture=True).returncode == 0 def restart_inputs(): run('systemctl', 'restart', 'shairport-sync', 'bluealsa-aplay') def check_dac(pcm): parts = dict(item.split("=", 1) for item in pcm.removeprefix("hw:").split(",")) if not (Path("/proc/asound") / parts["CARD"] / ("pcm" + parts["DEV"] + "p")).exists(): raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.") def apply(c, initial=False, reconfigure=False): root() files = templates(c) if reconfigure: previous = load() if any(c.get(k) != previous.get(k) for k in ('output', 'dac_pcm', 'bluetooth_device')): raise ValueError('현재 출력 경로를 바꾸는 작업은 연결 확인이 필요합니다.') if c["output"] == "dac" and not reconfigure: check_dac(c["dac_pcm"]) if c['output'] == 'bluetooth' and not reconfigure: # Preflight before changing a working DAC route. run('modprobe', 'snd-aloop') result = run('bluetoothctl', '--timeout', '10', 'connect', c['bluetooth_device'], 'a2dp-sink', check=False, capture=True) pcm_list = run('bluealsa-aplay', '-l', capture=True).stdout playback = pcm_list.split('**** List of CAPTURE')[0] if c['bluetooth_device'].upper() not in playback.upper(): raise ValueError('출력 장비의 A2DP 연결이 없습니다. 전원을 켜고 pair 명령을 먼저 실행해 주세요.\n' + result.stdout) units = ['shairport-sync', 'bluealsa-aplay', BT_SERVICE, 'radxa-bose-output.service'] states = {u: (active(u), enabled(u)) for u in units} backup = snapshot(files) print('설정 백업:', backup, flush=True) try: stopping = [u for u in units if states[u][0]] if stopping: run('systemctl', 'stop', *stopping) for path, text in files.items(): save(path, text) run('systemctl', 'daemon-reload') run('systemctl', 'disable', 'radxa-bose-output.service', check=False, capture=True) if initial: run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp', 'shairport-sync', 'bluealsa', 'bluealsa-aplay') run('systemctl', 'restart', 'bluetooth') if run('systemctl', 'cat', 'aic-bluetooth.service', check=False, capture=True).returncode == 0: run('systemctl', 'restart', 'aic-bluetooth') run('systemctl', 'start', 'avahi-daemon', 'nqptp') run('systemctl', 'restart', 'bluealsa') if c['output'] == 'bluetooth': run('systemctl', 'enable', BT_SERVICE) run('systemctl', 'start', BT_SERVICE) else: run('systemctl', 'disable', BT_SERVICE, check=False, capture=True) run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay') required = ['shairport-sync', 'bluealsa-aplay'] if c['output'] == 'bluetooth' and not reconfigure: required.append(BT_SERVICE) if not all(active(u) for u in required): raise ValueError('서비스 시작에 실패했습니다.') alias = c['name'] + ' Bluetooth' run('bluetoothctl', 'system-alias', alias, check=not reconfigure, capture=reconfigure) print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm']) except Exception: run('systemctl', 'stop', *units, check=False) restore_files(backup) run('systemctl', 'daemon-reload', check=False) for u, (was_active, was_enabled) in states.items(): run('systemctl', 'enable' if was_enabled else 'disable', u, check=False, capture=True) if was_active: run('systemctl', 'start', u, check=False) print('설정을 이전 상태로 복구했습니다.', file=sys.stderr) raise def pair(addr, output=False, bond=False): root() if not MAC_RE.fullmatch(addr): raise ValueError('장비 주소 형식이 잘못됐습니다.') info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info): controller = run('bluetoothctl', 'show', capture=True).stdout was_pairable = 'Pairable: yes' in controller if 'Paired: yes' in info: # A temporary (unbonded) pairing blocks a new link key; start clean. run('bluetoothctl', 'remove', addr, check=False, capture=True) try: agent_pair(addr) finally: if not was_pairable: run('bluetoothctl', 'pairable', 'off', check=False) info = run('bluetoothctl', 'info', addr, capture=True).stdout if 'Paired: yes' not in info: raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.') if bond and 'Bonded: yes' not in info: raise ValueError('영구 등록되지 않았습니다. 장비를 페어링 모드로 다시 켠 뒤 재시도해 주세요.') run('bluetoothctl', 'trust', addr) run('bluetoothctl', '--timeout', '10', 'connect', addr, 'a2dp-sink' if output else 'a2dp-source', check=False) if output: c = load() c['bluetooth_device'] = addr.upper() c['output'] = 'bluetooth' apply(c) print('페어링/신뢰 등록 완료:', addr) def agent_pair(addr, scan_seconds=12, pair_seconds=20): """Pair inside one bluetoothctl session so the agent stays registered. A one-shot `bluetoothctl --agent ... pair` can exit before Secure Simple Pairing finishes, which leaves the device paired but not bonded. """ import time proc = subprocess.Popen(['bluetoothctl'], stdin=subprocess.PIPE, text=True) steps = [('agent NoInputNoOutput', 1), ('default-agent', 1), ('pairable on', 1), ('scan on', scan_seconds), ('scan off', 1), ('pair ' + addr, pair_seconds)] try: for command, wait in steps: proc.stdin.write(command + '\n') proc.stdin.flush() time.sleep(wait) proc.stdin.write('quit\n') proc.stdin.flush() proc.wait(timeout=10) except (OSError, subprocess.TimeoutExpired): proc.kill() proc.wait() def discoverable(on, seconds=300): """Show/hide the board to phones; the agent runs as a transient unit.""" import time root() run('systemctl', 'stop', PAIRING_UNIT, check=False, capture=True) if not on: run('bluetoothctl', 'discoverable', 'off', check=False, capture=True) run('bluetoothctl', 'pairable', 'off', check=False, capture=True) PAIRING_UNTIL.unlink(missing_ok=True) print('Bluetooth 검색을 숨겼습니다.') return if not 30 <= seconds <= 1800: raise ValueError('검색 허용 시간: 30~1800초') run('systemd-run', '--unit', PAIRING_UNIT, '--collect', '--property', f'RuntimeMaxSec={seconds + 15}', # Fallback if the agent is killed before its own cleanup runs. '--property', 'ExecStopPost=/usr/bin/bluetoothctl discoverable off', '--property', 'ExecStopPost=/usr/bin/bluetoothctl pairable off', str(Path(__file__).resolve()), 'pairing-agent', '--seconds', str(seconds), capture=True) for _ in range(20): time.sleep(0.5) if 'Discoverable: yes' in run('bluetoothctl', 'show', check=False, capture=True).stdout: print(f'{seconds}초 동안 Bluetooth 검색을 허용합니다.') return run('systemctl', 'stop', PAIRING_UNIT, check=False, capture=True) raise ValueError('Bluetooth 검색 허용을 켜지 못했습니다.') def pairing_agent(seconds): """Accept phones that pair while the window is open and trust them.""" import pty import select import signal import time root() signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) end = time.monotonic() + seconds PAIRING_UNTIL.parent.mkdir(parents=True, exist_ok=True) PAIRING_UNTIL.write_text(str(time.time() + seconds)) pid, fd = pty.fork() if pid == 0: os.execvp('bluetoothctl', ['bluetoothctl', '--agent', 'NoInputNoOutput']) queue = ['default-agent', f'discoverable-timeout {seconds}', 'pairable on', 'discoverable on'] next_command = time.monotonic() + 1.5 buf, trusted = '', set() try: while time.monotonic() < end: ready, _, _ = select.select([fd], [], [], 0.25) if ready: try: data = os.read(fd, 8192) except OSError: break if not data: break buf += re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', data.decode(errors='replace')) # Pairing confirmation and A2DP service authorization prompts. while '(yes/no)' in buf: head, buf = buf.split('(yes/no)', 1) print('승인:', (head.strip().splitlines() or [''])[-1], flush=True) os.write(fd, b'yes\n') for addr in re.findall(r'Device ((?:[0-9A-F]{2}:){5}[0-9A-F]{2}) (?:Paired|Bonded): yes', buf): if addr not in trusted: trusted.add(addr) print('신뢰 등록:', addr, flush=True) os.write(fd, f'trust {addr}\n'.encode()) buf = buf[-4096:] if queue and time.monotonic() >= next_command: os.write(fd, (queue.pop(0) + '\n').encode()) next_command = time.monotonic() + 1.5 finally: for command in (b'discoverable off\n', b'pairable off\n', b'quit\n'): try: os.write(fd, command); time.sleep(0.3) except OSError: pass os.close(fd) os.waitpid(pid, 0) PAIRING_UNTIL.unlink(missing_ok=True) def main(): p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리') sub = p.add_subparsers(dest='cmd', required=True) sub.add_parser('status') sub.add_parser('devices') q = sub.add_parser('scan'); q.add_argument('--seconds', type=int, default=30) q = sub.add_parser('pair'); q.add_argument('address'); q.add_argument('--output', action='store_true'); q.add_argument('--bond', action='store_true') q = sub.add_parser('pair-input'); q.add_argument('--seconds', type=int, default=120) q = sub.add_parser('discoverable'); q.add_argument('state', choices=['on', 'off']); q.add_argument('--seconds', type=int, default=300) q = sub.add_parser('pairing-agent'); q.add_argument('--seconds', type=int, required=True) q = sub.add_parser('output'); q.add_argument('mode', choices=['dac', 'bluetooth', 'bose']); q.add_argument('address', nargs='?'); q.add_argument('--pcm') q = sub.add_parser('name'); q.add_argument('name') q = sub.add_parser('dac'); q.add_argument('pcm') sub.add_parser('apply') q = sub.add_parser('init'); q.add_argument('--name', required=True); q.add_argument('--dac', default='hw:CARD=Audio,DEV=0') q.add_argument('--adopt', action='store_true'); q.add_argument('--address', default='') q.add_argument('--output', choices=['dac', 'bluetooth'], default='dac') sub.add_parser('inputs') q = sub.add_parser('volume'); q.add_argument('input'); q.add_argument('percent', type=int) q = sub.add_parser('mute'); q.add_argument('input'); q.add_argument('state', choices=['on', 'off']) a = p.parse_args() if a.cmd in ('inputs', 'volume', 'mute'): sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, '/usr/local/lib/radxa-mixer') import input_controls if a.cmd == 'inputs': print(json.dumps(input_controls.inputs(), ensure_ascii=False, indent=2)) elif a.cmd == 'volume': root(); input_controls.set_input(a.input, volume=a.percent) else: root(); input_controls.set_input(a.input, mute=a.state == 'on') return if a.cmd == 'devices': run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l') elif a.cmd == 'status': print(json.dumps(load(), ensure_ascii=False, indent=2), flush=True) run('systemctl', 'is-active', 'shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay', BT_SERVICE, check=False) run('bluealsa-aplay', '-l') elif a.cmd == 'scan': root() if not 5 <= a.seconds <= 120: raise ValueError('검색 시간: 5~120초') # Keep the discovery client alive; stop discovery on timeout/interrupt. try: run('bluetoothctl', '--timeout', str(a.seconds), 'scan', 'bredr', check=False, timeout=a.seconds + 10) finally: run('bluetoothctl', 'scan', 'off', check=False) run('bluetoothctl', 'devices') elif a.cmd == 'pair': pair(a.address, a.output, a.bond) elif a.cmd == 'discoverable': discoverable(a.state == 'on', a.seconds) elif a.cmd == 'pairing-agent': if not 30 <= a.seconds <= 1800: raise ValueError('검색 허용 시간: 30~1800초') pairing_agent(a.seconds) elif a.cmd == 'pair-input': root() if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초') run('bluetoothctl', 'discoverable-timeout', str(a.seconds)) # Interactive agent: approve the intended phone's service with "yes". print('휴대폰에서 연결하세요. 서비스 승인 질문에는 yes를 입력하고, 완료 후 quit을 입력하세요.', flush=True) try: # Relay a PTY so service approval answers still come from the operator. interactive_pair(a.seconds) finally: run('bluetoothctl', 'discoverable', 'off', check=False) run('bluetoothctl', 'pairable', 'off', check=False) print('서비스를 승인한 휴대폰은 sudo bluetoothctl trust 주소 로 신뢰 등록해 주세요.') elif a.cmd == 'init': apply(dict(name=a.name, dac_pcm=a.dac, output=a.output, bluetooth_device=a.address), initial=not a.adopt) else: c = load() if a.cmd == 'output': c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac' if a.pcm: if c['output'] != 'dac': raise ValueError('--pcm은 DAC 출력에서만 지정합니다.') c['dac_pcm'] = a.pcm if a.address: if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.') c['bluetooth_device'] = a.address.upper() elif a.cmd == 'name': c['name'] = a.name elif a.cmd == 'dac': c['dac_pcm'] = a.pcm apply(c, reconfigure=a.cmd == 'apply') def interactive_pair(seconds): import pty import select import time pid, fd = pty.fork() if pid == 0: os.execvp('bluetoothctl', ['bluetoothctl', '--agent', 'NoInputNoOutput']) end = time.monotonic() + seconds # Do not send asynchronous agent registration commands in a single batch. queue = ['default-agent', 'pairable on', 'discoverable on'] next_command = time.monotonic() + 1.5 try: while time.monotonic() < end: ready, _, _ = select.select([fd, sys.stdin], [], [], 0.25) if fd in ready: try: data = os.read(fd, 8192) except OSError: break if not data: break os.write(sys.stdout.fileno(), data) if sys.stdin in ready: data = os.read(sys.stdin.fileno(), 8192) if not data: break os.write(fd, data) if queue and time.monotonic() >= next_command: os.write(fd, (queue.pop(0) + '\n').encode()) next_command = time.monotonic() + 1.5 finally: try: os.write(fd, b'quit\n') except OSError: pass os.close(fd) os.waitpid(pid, 0) if __name__ == '__main__': try: import fcntl if len(sys.argv) > 1 and sys.argv[1] not in ('status', 'devices', 'inputs', 'pairing-agent', '-h', '--help'): root() Path('/run/lock').mkdir(parents=True, exist_ok=True) with open('/run/lock/radxa-audio.lock', 'w') as lock: fcntl.flock(lock, fcntl.LOCK_EX) main() else: main() except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: print('오류:', e, file=sys.stderr) sys.exit(1) except KeyboardInterrupt: print('\n중단했습니다.', file=sys.stderr) sys.exit(130)