Files
Radxa_MIXER/scripts/web_server.py
T
jsahnandClaude Opus 5.5 655fbc1d29 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>
2026-10-12 20:20:06 +09:00

151 lines
7.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""LAN mixer UI; commands are allowlisted and volume endpoints are input-only."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
from pathlib import Path
import secrets
import socket
import subprocess
import threading
import time
from urllib.parse import urlsplit
import input_controls as audio
ROOT = Path(__file__).resolve().parent / 'web'
TOKEN = secrets.token_urlsafe(32)
JOBS = {}
JOB_LOCK = threading.Lock()
BUSY = False
def validate_action(data):
if not isinstance(data, dict): raise ValueError('요청 형식이 잘못됐습니다.')
action=data.get('action')
if action == 'volume':
key=data.get('input'); level=data.get('volume')
if key!='airplay' and (not isinstance(key,str) or not audio.MAC.fullmatch(key.upper())):
raise ValueError('입력 장비 주소가 잘못됐습니다.')
if type(level) is not int or not 0<=level<=100: raise ValueError('볼륨 범위: 0~100')
return ['volume',key,str(level)]
if action == 'mute':
key=data.get('input'); mute=data.get('mute')
if key!='airplay' and (not isinstance(key,str) or not audio.MAC.fullmatch(key.upper())):
raise ValueError('입력 장비 주소가 잘못됐습니다.')
if type(mute) is not bool: raise ValueError('음소거 값이 잘못됐습니다.')
return ['mute',key,'on' if mute else 'off']
if action == 'output':
mode=data.get('mode')
if mode=='dac':
pcm=data.get('pcm')
if not isinstance(pcm,str) or not __import__('re').fullmatch(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+',pcm):
raise ValueError('DAC 주소가 잘못됐습니다.')
return ['output','dac','--pcm',pcm]
if mode=='bluetooth':
addr=data.get('address')
if not isinstance(addr,str) or not audio.MAC.fullmatch(addr.upper()): raise ValueError('장비 주소가 잘못됐습니다.')
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('장비 주소가 잘못됐습니다.')
return ['pair',addr.upper(),'--output','--bond']
raise ValueError('지원하지 않는 작업입니다.')
def launch(args):
global BUSY
with JOB_LOCK:
if BUSY: raise ValueError('진행 중인 작업이 있습니다. 완료 후 다시 시도해 주세요.')
BUSY=True; key=secrets.token_hex(12)
JOBS[key]={'done':False,'ok':False,'message':'처리 중입니다.'}
if len(JOBS)>100:
for old in list(JOBS)[:-50]: JOBS.pop(old,None)
def work():
global BUSY
try:
# DAC selection must switch to DAC mode in the same serialized command.
p=subprocess.run(['/usr/local/bin/radxa-audio',*args],capture_output=True,text=True,timeout=180)
result={'done':True,'ok':p.returncode==0,'message':p.stdout[-2000:] if p.returncode==0 else (p.stderr+p.stdout)[-2000:]}
except Exception as e: result={'done':True,'ok':False,'message':str(e)}
with JOB_LOCK: JOBS[key]=result; BUSY=False
threading.Thread(target=work,daemon=True).start()
return key
def allowed_host(host):
try:
name=urlsplit('//'+host).hostname
if not name: return False
if name in ('localhost',socket.gethostname(),socket.gethostname()+'.local'): return True
addr=ipaddress.ip_address(name)
ips={'127.0.0.1','::1'}
data=json.loads(subprocess.check_output(['ip','-j','address'],text=True,timeout=3))
ips.update(a['local'] for link in data for a in link.get('addr_info',[]))
return str(addr) in ips
except (ValueError, OSError, subprocess.SubprocessError): return False
class Handler(BaseHTTPRequestHandler):
def reply(self,status,data,ctype='application/json; charset=utf-8'):
body=json.dumps(data,ensure_ascii=False).encode() if isinstance(data,(dict,list)) else data
self.send_response(status)
self.send_header('Content-Type',ctype);self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff')
self.send_header('Content-Security-Policy',"default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'")
self.end_headers(); self.wfile.write(body)
def do_GET(self):
if not allowed_host(self.headers.get('Host','')): return self.reply(403,{'error':'접속 주소가 허용되지 않습니다.'})
path=urlsplit(self.path).path
try:
if path=='/api/state':
state=audio.state();state['csrf']=TOKEN;state['busy']=BUSY
return self.reply(200,state)
if path.startswith('/api/jobs/'):
with JOB_LOCK: job=JOBS.get(path.rsplit('/',1)[-1])
return self.reply(200 if job else 404,job or {'error':'작업을 찾을 수 없습니다.'})
files={'/':'index.html','/app.js':'app.js','/style.css':'style.css'}
if path not in files: return self.reply(404,{'error':'페이지가 없습니다.'})
mime={'/':'text/html; charset=utf-8','/app.js':'text/javascript; charset=utf-8','/style.css':'text/css; charset=utf-8'}[path]
self.reply(200,(ROOT/files[path]).read_bytes(),mime)
except Exception as e: self.reply(503,{'error':str(e)})
def do_POST(self):
host=self.headers.get('Host','')
origin=self.headers.get('Origin')
if not allowed_host(host) or (origin and origin!='http://'+host):
return self.reply(403,{'error':'동일한 접속 화면에서 요청해 주세요.'})
if not secrets.compare_digest(self.headers.get('X-Mixer-Token',''),TOKEN):
return self.reply(403,{'error':'화면을 새로고침해 주세요.'})
if urlsplit(self.path).path!='/api/action': return self.reply(404,{'error':'없는 API입니다.'})
try:
if not self.headers.get('Content-Type','').startswith('application/json'): raise ValueError('JSON 요청이 필요합니다.')
size=int(self.headers.get('Content-Length','0'))
if not 0<size<=4096: raise ValueError('요청 크기가 잘못됐습니다.')
args=validate_action(json.loads(self.rfile.read(size)))
self.reply(202,{'job':launch(args)})
except (ValueError,TypeError,json.JSONDecodeError) as e: self.reply(400,{'error':str(e)})
def log_message(self,format,*args): pass
def main():
parser=argparse.ArgumentParser();parser.add_argument('--host',default='0.0.0.0');parser.add_argument('--port',type=int,default=8088)
args=parser.parse_args()
server=ThreadingHTTPServer((args.host,args.port),Handler)
server.daemon_threads=True
print(f'Radxa Mixer listening on {args.host}:{args.port}',flush=True)
server.serve_forever()
if __name__=='__main__':main()