diff --git a/README.md b/README.md index 19f8062..eb1728b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐ - LAN용 화면입니다. 인터넷 포트 개방은 하지 않습니다. 같은 네트워크의 접속자는 화면에서 제어할 수 있으며 별도 로그인은 없습니다. 상태 변경에는 요청 토큰과 동일 출처 검증을 사용합니다. -- 새 휴대폰을 Bluetooth 입력으로 페어링하는 절차는 아래의 기존 CLI를 사용합니다. +- **Bluetooth 입력 등록**: 2/5/15분 동안 보드를 휴대폰 검색 목록에 표시하고, + 남은 시간을 보여 주며 언제든 끌 수 있습니다. 이 시간 안에 연결한 기기는 자동으로 + 승인·신뢰 등록됩니다. 근처의 다른 사람도 연결할 수 있으니 등록 후 꺼 주세요. - 웹 서버는 `radxa-mixer-web.service`로 자동 시작합니다. 명령어도 계속 사용할 수 있습니다: @@ -119,7 +121,19 @@ Mac과 Radxa는 Bonjour/mDNS가 통하는 같은 네트워크에 있어야 합 ## 3. 휴대폰을 블루투스 입력으로 연결 -보드의 SSH 터미널에서: +가장 쉬운 방법은 웹 믹서의 **Bluetooth 입력 등록 → 검색 허용 켜기**입니다. +켜 둔 동안 휴대폰 Bluetooth 설정에 ` Bluetooth`가 나타나며, +선택하면 자동으로 승인·신뢰 등록됩니다. 같은 기능의 명령: + +```bash +sudo radxa-audio discoverable on --seconds 300 +sudo radxa-audio discoverable off +``` + +검색 허용은 임시 서비스 `radxa-bt-pairing.service`로 동작하며 시간이 지나면 +자동으로 꺼집니다. 기본 상태는 검색 숨김이지만, 이미 등록한 기기는 연결할 수 있습니다. + +서비스 승인을 직접 확인하려면 보드의 SSH 터미널에서: ```bash sudo radxa-audio pair-input --seconds 120 diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 462c9f2..c5c0134 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -2,8 +2,11 @@ ## Bluetooth 이름이 안 보임 -새 휴대폰을 입력으로 등록하려면 `sudo radxa-audio pair-input`을 실행해 둡니다. -검색 창은 기본 120초이며, 종료되면 다시 숨겨집니다. 이미 신뢰 등록한 장비는 +보드는 평소 검색 숨김 상태입니다. 새 휴대폰을 입력으로 등록하려면 웹 믹서의 +**Bluetooth 입력 등록 → 검색 허용 켜기**를 누르거나 +`sudo radxa-audio discoverable on --seconds 300`을 실행합니다. +정한 시간이 지나면 다시 숨겨집니다. 켜졌는데도 안 보이면 +`bluetoothctl show`의 `Discoverable: yes`와 `journalctl -u radxa-bt-pairing`을 확인하세요. 이미 신뢰 등록한 장비는 검색 모드를 계속 켜 두지 않아도 다시 연결할 수 있습니다. 헤드폰을 출력으로 연결하려면 헤드폰 자체의 페어링 모드를 켠 뒤 diff --git a/scripts/input_controls.py b/scripts/input_controls.py index 0ae4547..d8de1aa 100644 --- a/scripts/input_controls.py +++ b/scripts/input_controls.py @@ -6,9 +6,11 @@ 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') @@ -148,6 +150,16 @@ def devices(): 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(): @@ -164,4 +176,6 @@ def state(): except Exception as e: bluetooth=[]; issues.append(str(e)) try: dac = dacs() except Exception as e: dac=[]; issues.append(str(e)) - return {'config':config,'inputs':inputs(),'bluetooth':bluetooth,'dacs':dac,'issues':issues} + 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} diff --git a/scripts/radxa-audio.py b/scripts/radxa-audio.py index cff9300..ba6ca7a 100755 --- a/scripts/radxa-audio.py +++ b/scripts/radxa-audio.py @@ -13,6 +13,8 @@ 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') @@ -318,6 +320,81 @@ def agent_pair(addr, scan_seconds=12, pair_seconds=20): 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) @@ -326,6 +403,8 @@ def main(): 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') @@ -362,6 +441,11 @@ def main(): 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초') @@ -428,7 +512,7 @@ def interactive_pair(seconds): if __name__ == '__main__': try: import fcntl - if len(sys.argv) > 1 and sys.argv[1] not in ('status', 'devices', 'inputs', '-h', '--help'): + 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: diff --git a/scripts/web/app.js b/scripts/web/app.js index ae4840e..cf78668 100644 --- a/scripts/web/app.js +++ b/scripts/web/app.js @@ -1,11 +1,13 @@ -let state, token, pending=false, tab='dac', refreshTimer; +let state, token, pending=false, tab='dac', refreshTimer, pairEnd=0; const $=id=>document.getElementById(id); function notice(message,error=false){$('notice').textContent=message;$('notice').classList.toggle('error',error)} function node(tag,text,cls){const el=document.createElement(tag);if(text!==undefined)el.textContent=text;if(cls)el.className=cls;return el} function selectOptions(el,items,value){const old=el.value;el.replaceChildren();for(const item of items){const op=node('option',item.name);op.value=item.value;el.append(op)}el.value=items.some(x=>x.value===old)?old:value;if(!el.value&&items.length)el.value=items[0].value} function showTab(next){tab=next;$('dacPanel').hidden=tab!=='dac';$('btPanel').hidden=tab!=='bluetooth';$('tabDac').classList.toggle('selected',tab==='dac');$('tabBt').classList.toggle('selected',tab==='bluetooth')} function renderInputs(items){const active=document.activeElement;if(active&&active.type==='range'&&!pending)return;$('inputs').replaceChildren();for(const item of items){const card=node('div',undefined,'input'),top=node('div',undefined,'inputTop'),title=node('div');title.append(node('h3',item.name),node('span',item.kind==='airplay'?'AIRPLAY 2':'BLUETOOTH','kind'));top.append(title,node('span',!item.available?'제어 준비 중':item.playing?'재생 중':'대기 중','playState'));const value=node('div',String(item.volume),'volumeValue');value.append(node('small','%'));const range=node('input');range.type='range';range.min=0;range.max=100;range.step=1;range.value=item.volume;range.disabled=!item.available||pending;range.setAttribute('aria-label',item.name+' 입력 볼륨');range.oninput=()=>{value.firstChild.textContent=range.value};range.onchange=()=>action({action:'volume',input:item.id,volume:Number(range.value)},'입력 볼륨을 변경했습니다.');const marks=node('div',undefined,'rangeLabels');marks.append(node('span','0'),node('span','100'));const mute=node('button',item.muted?'음소거 해제':'음소거',item.muted?'mute on':'mute');mute.disabled=!item.available||pending;mute.onclick=()=>action({action:'mute',input:item.id,mute:!item.muted},item.muted?'음소거를 해제했습니다.':'입력을 음소거했습니다.');card.append(top,value,range,marks,mute);$('inputs').append(card)}} -async function refresh(initial=false){try{const r=await fetch('/api/state');const data=await r.json();if(!r.ok)throw Error(data.error);state=data;token=data.csrf;$('deviceName').textContent=data.config.name;renderInputs(data.inputs);selectOptions($('dacSelect'),data.dacs.map(d=>({name:d.name,value:d.pcm})),data.config.dac_pcm);selectOptions($('btSelect'),data.bluetooth.filter(d=>d.output&&(d.paired||d.address===data.config.bluetooth_device)).map(d=>({name:d.name+(!d.bonded?' · 등록 필요':d.connected?' · 연결됨':''),value:d.address})),data.config.bluetooth_device);$('outputState').textContent=data.config.output==='dac'?'현재 DAC 출력':'현재 Bluetooth 출력';if(initial)showTab(data.config.output);$('useDac').disabled=pending||!data.dacs.length;$('useBt').disabled=pending||!$('btSelect').options.length;$('scan').disabled=pending;if(initial)notice('믹서에 연결됐습니다. 입력별 볼륨을 조절해 보세요.')}catch(e){notice(e.message,true)}} +async function refresh(initial=false){try{const r=await fetch('/api/state');const data=await r.json();if(!r.ok)throw Error(data.error);state=data;token=data.csrf;$('deviceName').textContent=data.config.name;renderInputs(data.inputs);selectOptions($('dacSelect'),data.dacs.map(d=>({name:d.name,value:d.pcm})),data.config.dac_pcm);selectOptions($('btSelect'),data.bluetooth.filter(d=>d.output&&(d.paired||d.address===data.config.bluetooth_device)).map(d=>({name:d.name+(!d.bonded?' · 등록 필요':d.connected?' · 연결됨':''),value:d.address})),data.config.bluetooth_device);$('outputState').textContent=data.config.output==='dac'?'현재 DAC 출력':'현재 Bluetooth 출력';if(initial)showTab(data.config.output);$('useDac').disabled=pending||!data.dacs.length;$('useBt').disabled=pending||!$('btSelect').options.length;$('scan').disabled=pending;renderPairing(data.pairing);if(initial)notice('믹서에 연결됐습니다. 입력별 볼륨을 조절해 보세요.')}catch(e){notice(e.message,true)}} async function action(payload,success){if(pending)return;pending=true;notice(payload.action==='scan'?'주변 장비를 검색하고 있습니다…':'설정을 적용하고 있습니다…');document.querySelectorAll('button,input,select').forEach(el=>el.disabled=true);try{const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','X-Mixer-Token':token},body:JSON.stringify(payload)});const data=await r.json();if(!r.ok)throw Error(data.error);for(;;){await new Promise(r=>setTimeout(r,500));const response=await fetch('/api/jobs/'+data.job);const job=await response.json();if(!response.ok)throw Error(job.error||'작업 상태를 읽을 수 없습니다.');if(job.done){if(!job.ok)throw Error(job.message);break}}notice(success);await refresh();if(payload.action==='scan')renderDiscovered()}catch(e){notice(e.message,true)}finally{pending=false;document.querySelectorAll('button,input,select').forEach(el=>el.disabled=false);await refresh()}} +function renderPairing(p){if(!p)return;pairEnd=p.discoverable?Date.now()+p.remaining*1000:0;if(p.alias)$('pairAlias').textContent=p.alias;$('pairToggle').textContent=p.discoverable?'검색 허용 끄기':'검색 허용 켜기';$('pairToggle').classList.toggle('quiet',p.discoverable);$('pairToggle').disabled=pending;$('pairTime').disabled=pending||p.discoverable;tickPairing()} +function tickPairing(){if(!state)return;const on=state.pairing&&state.pairing.discoverable;if(!on){$('pairState').textContent='검색 숨김';return}const left=Math.max(0,Math.round((pairEnd-Date.now())/1000));$('pairState').textContent=pairEnd&&left?`검색 허용 중 · ${Math.floor(left/60)}:${String(left%60).padStart(2,'0')} 남음`:'검색 허용 중'} function renderDiscovered(){$('discovered').replaceChildren();for(const dev of state.bluetooth){const row=node('div',undefined,'discoveredRow'),name=node('div');name.append(node('span',dev.name),node('small',dev.address));const btn=node('button','연결하여 출력','quiet');btn.onclick=()=>action({action:'pair-output',address:dev.address},'Bluetooth 출력 장비를 연결했습니다.');row.append(name,btn);$('discovered').append(row)}} -$('refresh').onclick=()=>refresh();$('tabDac').onclick=()=>showTab('dac');$('tabBt').onclick=()=>showTab('bluetooth');$('useDac').onclick=()=>action({action:'output',mode:'dac',pcm:$('dacSelect').value},'DAC 출력을 선택했습니다.');$('useBt').onclick=()=>{const addr=$('btSelect').value,dev=state.bluetooth.find(d=>d.address===addr);action(dev&&!dev.bonded?{action:'pair-output',address:addr}:{action:'output',mode:'bluetooth',address:addr},'Bluetooth 출력을 선택했습니다.')};$('scan').onclick=()=>action({action:'scan'},'검색 완료. 새 장비를 연결해 주세요.');refresh(true);refreshTimer=setInterval(()=>{if(!pending)refresh()},4000); +$('refresh').onclick=()=>refresh();$('tabDac').onclick=()=>showTab('dac');$('tabBt').onclick=()=>showTab('bluetooth');$('useDac').onclick=()=>action({action:'output',mode:'dac',pcm:$('dacSelect').value},'DAC 출력을 선택했습니다.');$('useBt').onclick=()=>{const addr=$('btSelect').value,dev=state.bluetooth.find(d=>d.address===addr);action(dev&&!dev.bonded?{action:'pair-output',address:addr}:{action:'output',mode:'bluetooth',address:addr},'Bluetooth 출력을 선택했습니다.')};$('pairToggle').onclick=()=>{const on=!(state&&state.pairing&&state.pairing.discoverable);action(on?{action:'discoverable',on:true,seconds:Number($('pairTime').value)}:{action:'discoverable',on:false},on?'검색을 허용했습니다. 휴대폰에서 연결해 주세요.':'Bluetooth 검색을 숨겼습니다.')};setInterval(tickPairing,1000);$('scan').onclick=()=>action({action:'scan'},'검색 완료. 새 장비를 연결해 주세요.');refresh(true);refreshTimer=setInterval(()=>{if(!pending)refresh()},4000); diff --git a/scripts/web/index.html b/scripts/web/index.html index 0658f0d..2d5a989 100644 --- a/scripts/web/index.html +++ b/scripts/web/index.html @@ -2,5 +2,6 @@
WIRELESS AUDIO

Radxa Mixer

연결 중…

믹서에 연결하고 있습니다.

입력 믹서

입력별 볼륨 · 음소거

AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.

+

Bluetooth 입력 등록

확인 중

켜 두는 동안 휴대폰 Bluetooth 목록에 Radxa Audio Bluetooth가 표시됩니다. 이 시간에 연결하는 기기는 자동으로 승인·신뢰 등록되므로 등록이 끝나면 꺼 주세요. 이미 등록한 기기는 꺼져 있어도 연결됩니다.

출력 장비

확인 중

출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.

RADXA MIXER 공통 감쇠 −3 dB · 스테레오
diff --git a/scripts/web_server.py b/scripts/web_server.py index cf1962b..78c7ed0 100755 --- a/scripts/web_server.py +++ b/scripts/web_server.py @@ -48,6 +48,12 @@ def validate_action(data): return ['output','bluetooth',addr.upper()] raise ValueError('출력 종류가 잘못됐습니다.') if action=='scan': return ['scan','--seconds','15'] + if action=='discoverable': + on=data.get('on'); seconds=data.get('seconds') + if type(on) is not bool: raise ValueError('검색 허용 값이 잘못됐습니다.') + if not on: return ['discoverable','off'] + if seconds not in (120,300,900) or type(seconds) is not int: raise ValueError('검색 허용 시간: 2, 5, 15분') + return ['discoverable','on','--seconds',str(seconds)] if action=='pair-output': addr=data.get('address') if not isinstance(addr,str) or not audio.MAC.fullmatch(addr.upper()): raise ValueError('장비 주소가 잘못됐습니다.') diff --git a/tests/test_web.py b/tests/test_web.py index a01c608..174e062 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -50,6 +50,23 @@ class InputTests(unittest.TestCase): class WebTests(unittest.TestCase): + def test_discoverable_toggle_is_allowlisted(self): + self.assertEqual(w.validate_action({'action':'discoverable','on':True,'seconds':300}), + ['discoverable','on','--seconds','300']) + self.assertEqual(w.validate_action({'action':'discoverable','on':False}),['discoverable','off']) + for data in [{'action':'discoverable','on':True,'seconds':3600},{'action':'discoverable','on':True,'seconds':'300'}, + {'action':'discoverable','on':'yes','seconds':300},{'action':'discoverable','on':True,'seconds':True}]: + with self.subTest(data=data), self.assertRaises(ValueError): w.validate_action(data) + + def test_pairing_state_reports_remaining_only_when_discoverable(self): + adapter={'/org/bluez/hci0':{'org.bluez.Adapter1':{'Discoverable':True,'Alias':'Radxa Audio Bluetooth'}}} + with patch.object(a,'objects',return_value=adapter), patch.object(a,'PAIRING_UNTIL') as until, \ + patch.object(a.time,'time',return_value=1000.0): + until.read_text.return_value='1120.0' + self.assertEqual(a.pairing(),{'discoverable':True,'remaining':120,'alias':'Radxa Audio Bluetooth'}) + adapter['/org/bluez/hci0']['org.bluez.Adapter1']['Discoverable']=False + self.assertEqual(a.pairing()['remaining'],0) + def test_dac_selection_is_one_atomic_cli_command(self): self.assertEqual(w.validate_action({'action':'output','mode':'dac','pcm':'hw:CARD=Audio,DEV=0'}), ['output','dac','--pcm','hw:CARD=Audio,DEV=0'])