Add web toggle for Bluetooth discoverable window
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>
This commit is contained in:
@@ -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 설정에 `<AirPlay 이름> 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
|
||||
|
||||
+5
-2
@@ -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`을 확인하세요. 이미 신뢰 등록한 장비는
|
||||
검색 모드를 계속 켜 두지 않아도 다시 연결할 수 있습니다.
|
||||
|
||||
헤드폰을 출력으로 연결하려면 헤드폰 자체의 페어링 모드를 켠 뒤
|
||||
|
||||
@@ -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}
|
||||
|
||||
+85
-1
@@ -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:
|
||||
|
||||
+5
-3
@@ -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);
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
<main><header><div><span class="eyebrow">WIRELESS AUDIO</span><h1>Radxa Mixer<span class="dot"></span></h1><p id="deviceName">연결 중…</p></div><button id="refresh" class="quiet">새로고침 ↻</button></header>
|
||||
<div id="notice" class="notice" role="status" aria-live="polite">믹서에 연결하고 있습니다.</div>
|
||||
<section><div class="sectionTitle"><h2>입력 믹서</h2><span>입력별 볼륨 · 음소거</span></div><div id="inputs" class="inputs"></div><p class="hint">AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.</p></section>
|
||||
<section><div class="sectionTitle"><h2>Bluetooth 입력 등록</h2><span id="pairState">확인 중</span></div><div class="choose"><select id="pairTime" aria-label="검색 허용 시간"><option value="120">2분</option><option value="300" selected>5분</option><option value="900">15분</option></select><button id="pairToggle">검색 허용 켜기</button></div><p class="hint">켜 두는 동안 휴대폰 Bluetooth 목록에 <b id="pairAlias">Radxa Audio Bluetooth</b>가 표시됩니다. 이 시간에 연결하는 기기는 자동으로 승인·신뢰 등록되므로 등록이 끝나면 꺼 주세요. 이미 등록한 기기는 꺼져 있어도 연결됩니다.</p></section>
|
||||
<section><div class="sectionTitle"><h2>출력 장비</h2><span id="outputState">확인 중</span></div><div class="tabs"><button id="tabDac">DAC</button><button id="tabBt">Bluetooth</button></div><div id="dacPanel"><label for="dacSelect">오디오 장치</label><div class="choose"><select id="dacSelect"></select><button id="useDac">출력 선택</button></div></div><div id="btPanel" hidden><label for="btSelect">등록한 헤드폰·스피커</label><div class="choose"><select id="btSelect"></select><button id="useBt">출력 선택</button></div><div class="scanRow"><p>새 출력 장비는 페어링 모드로 켜 주세요.</p><button id="scan" class="quiet">장비 검색</button></div><div id="discovered"></div></div><p class="hint">출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.</p></section>
|
||||
<footer>RADXA MIXER <span>공통 감쇠 −3 dB · 스테레오</span></footer></main><script src="/app.js"></script></body></html>
|
||||
|
||||
@@ -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('장비 주소가 잘못됐습니다.')
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user