Add web mixer with native input volume and output selection
This commit is contained in:
@@ -17,15 +17,58 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐
|
|||||||
Loopback → Bluetooth 헤드폰/스피커
|
Loopback → Bluetooth 헤드폰/스피커
|
||||||
```
|
```
|
||||||
|
|
||||||
- AirPlay 2: Shairport Sync **5.5.2**, NQPTP **1.2.8**, 공유 메모리 규격 smi10.
|
- AirPlay 2: Shairport Sync **5.5.2**, NQPTP **1.2.8**, 공유 메모리 규격 smi10. 웹 제어용 D-Bus 지원과 네이티브 볼륨 패치를 포함합니다.
|
||||||
- Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**.
|
- Bluetooth: Debian의 BlueALSA 패키지. 기준 보드 버전 **4.3.1**.
|
||||||
- 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다.
|
- 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다.
|
||||||
|
- SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨
|
||||||
|
전송량을 줄였습니다. 송신 버퍼는 500 ms이며 안정성은 청취 검증이 필요합니다.
|
||||||
- DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다.
|
- DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다.
|
||||||
- 각 입력을 6 dB 낮춰 두 입력을 합칠 여유를 둡니다. 세 입력 이상은 각 기기 볼륨을
|
- 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
|
||||||
추가로 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다.
|
|
||||||
- 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다.
|
- 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다.
|
||||||
- 출력은 DAC 또는 블루투스 중 하나를 선택합니다. 양쪽 동시 출력은 구현하지 않았습니다.
|
- 출력은 DAC 또는 블루투스 중 하나를 선택합니다. 양쪽 동시 출력은 구현하지 않았습니다.
|
||||||
|
|
||||||
|
## 웹 믹서
|
||||||
|
|
||||||
|
설치 후 브라우저에서 **`http://보드IP:8088`**을 여세요.
|
||||||
|
현재 보드는 `http://192.168.10.83:8088`이며, USB 관리 연결에서는
|
||||||
|
주소가 유지돼 있다면 `http://10.55.0.1:8088`을 사용합니다.
|
||||||
|
|
||||||
|
- AirPlay 및 연결된 Bluetooth 입력별 볼륨·음소거.
|
||||||
|
- AirPlay는 Shairport Sync의 **기존 네이티브 볼륨**을 사용합니다.
|
||||||
|
별도 ALSA softvol이나 추가 감쇠 단계는 만들지 않았습니다.
|
||||||
|
- Bluetooth 볼륨 API는 A2DP 입력 PCM만 허용하며 DAC/헤드폰 출력 볼륨을 거부합니다.
|
||||||
|
- DAC 선택, 등록한 Bluetooth 헤드폰·스피커 선택, 새 출력 장비 검색·페어링.
|
||||||
|
- 출력 전환은 비동기로 실행하고 결과를 화면에 표시합니다.
|
||||||
|
- LAN용 화면입니다. 인터넷 포트 개방은 하지 않습니다.
|
||||||
|
같은 네트워크의 접속자는 화면에서 제어할 수 있으며 별도 로그인은 없습니다.
|
||||||
|
상태 변경에는 요청 토큰과 동일 출처 검증을 사용합니다.
|
||||||
|
- 새 휴대폰을 Bluetooth 입력으로 페어링하는 절차는 아래의 기존 CLI를 사용합니다.
|
||||||
|
- 웹 서버는 `radxa-mixer-web.service`로 자동 시작합니다.
|
||||||
|
|
||||||
|
명령어도 계속 사용할 수 있습니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo radxa-audio inputs
|
||||||
|
sudo radxa-audio volume airplay 60
|
||||||
|
sudo radxa-audio mute airplay on
|
||||||
|
sudo radxa-audio mute airplay off
|
||||||
|
sudo radxa-audio volume AA:BB:CC:DD:EE:FF 70
|
||||||
|
sudo radxa-audio mute AA:BB:CC:DD:EE:FF on
|
||||||
|
sudo radxa-audio output dac --pcm hw:CARD=Audio,DEV=0
|
||||||
|
```
|
||||||
|
|
||||||
|
AirPlay의 볼륨은 원래 프로토콜 범위 −30~0 및 음소거 −144를 0~100으로 표시합니다.
|
||||||
|
Bluetooth는 원래 입력 PCM의 볼륨을 사용합니다. 송신 기기의 볼륨 변경도 같은 입력에
|
||||||
|
적용됩니다. 출력 장비 볼륨과 이퀄라이저는 웹 화면에 제공하지 않습니다.
|
||||||
|
|
||||||
|
`sudo radxa-audio apply`는 출력 장비가 꺼져 있어도 **이미 저장한 출력 경로**를
|
||||||
|
다시 적용할 수 있습니다. 새 출력 장비 선택에는 여전히 연결 확인이 필요합니다.
|
||||||
|
|
||||||
|
기존 보드에서 웹 기능을 추가하려면 전체 `install.sh`로 D-Bus 빌드를 설치하거나,
|
||||||
|
이미 D-Bus 지원 바이너리가 설치돼 있다면 `sudo bash scripts/install-web.sh` 후
|
||||||
|
`sudo radxa-audio apply`를 실행합니다. 전체 설치는 기본 DAC 출력으로 설정하므로
|
||||||
|
이후 원하는 Bluetooth 출력을 다시 선택하세요.
|
||||||
|
|
||||||
## 1. 새 보드 준비
|
## 1. 새 보드 준비
|
||||||
|
|
||||||
1. Debian 13 기반 Armbian을 설치하고 인터넷에 연결합니다.
|
1. Debian 13 기반 Armbian을 설치하고 인터넷에 연결합니다.
|
||||||
@@ -110,6 +153,12 @@ sudo radxa-audio pair AA:BB:CC:DD:EE:FF --output
|
|||||||
이 명령은 페어링 → 신뢰 등록 → 음악 프로필 연결 → 출력 전환을 진행합니다.
|
이 명령은 페어링 → 신뢰 등록 → 음악 프로필 연결 → 출력 전환을 진행합니다.
|
||||||
이후 다른 헤드폰으로 바꾸려면 새 헤드폰을 검색하고 같은 명령을 실행하면 됩니다.
|
이후 다른 헤드폰으로 바꾸려면 새 헤드폰을 검색하고 같은 명령을 실행하면 됩니다.
|
||||||
|
|
||||||
|
`bluetoothctl info 주소`에서 `Paired: yes`여도 `Bonded: no`이면 임시 연결입니다.
|
||||||
|
재부팅 후 등록을 유지하려면 장비를 페어링 모드로 켠 뒤
|
||||||
|
`sudo radxa-audio pair 주소 --output --bond`를 사용하세요. 웹의 새 장비 등록도
|
||||||
|
영구 등록 성공을 확인하며, 인증 실패는 성공으로 표시하지 않습니다.
|
||||||
|
이미 영구 등록된 장비는 다시 페어링하지 않습니다.
|
||||||
|
|
||||||
이미 등록한 장비로 전환:
|
이미 등록한 장비로 전환:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -175,6 +224,9 @@ apt로 추가한 의존 패키지와 블루투스 페어링 기록은 유지합
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 |
|
| `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 |
|
||||||
| `scripts/radxa-audio.py` | 페어링/출력/이름/DAC 관리 |
|
| `scripts/radxa-audio.py` | 페어링/출력/이름/DAC 관리 |
|
||||||
|
| `scripts/web_server.py`, `scripts/input_controls.py`, `scripts/web/` | 웹 화면/API 및 입력 전용 볼륨 제어 |
|
||||||
|
| `scripts/install-web.sh` | 웹 서비스 설치/갱신 |
|
||||||
|
| `sources/shairport-native-volume.patch` | 기존 AirPlay 볼륨을 현재 세션에 적용 |
|
||||||
| `scripts/backup.py` | 설치 전 백업/복원 |
|
| `scripts/backup.py` | 설치 전 백업/복원 |
|
||||||
| `scripts/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 |
|
| `scripts/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 |
|
||||||
| `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 |
|
| `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 |
|
||||||
|
|||||||
+32
-1
@@ -69,7 +69,7 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on
|
|||||||
|
|
||||||
## 3개 이상 입력을 더하면 찢어짐
|
## 3개 이상 입력을 더하면 찢어짐
|
||||||
|
|
||||||
각 입력을 6 dB 낮춘 것은 두 입력 합산 기준입니다. 입력 수가 늘어나면
|
각 입력의 공통 감쇠는 3 dB입니다. 입력 수가 늘어나면
|
||||||
각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인
|
각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인
|
||||||
블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다.
|
블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다.
|
||||||
|
|
||||||
@@ -87,3 +87,34 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on
|
|||||||
```bash
|
```bash
|
||||||
bash scripts/check-build.sh
|
bash scripts/check-build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 웹 화면에 접속되지 않음
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status radxa-mixer-web --no-pager
|
||||||
|
sudo journalctl -u radxa-mixer-web -n 40 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
주소는 HTTP의 8088 포트입니다. 출력 장비가 꺼져 있어도 화면은 접속 가능해야 합니다.
|
||||||
|
브라우저를 새로고침하면 재시작 후 바뀐 요청 토큰이 갱신됩니다.
|
||||||
|
|
||||||
|
## AirPlay 입력이 ‘제어 준비 중’으로 표시됨
|
||||||
|
|
||||||
|
`shairport-sync -V`에 `dbus`가 있는지 확인합니다. D-Bus 기능과 이 패키지의
|
||||||
|
네이티브 볼륨 패치가 없는 기존 바이너리는 웹 볼륨 제어를 지원하지 않습니다.
|
||||||
|
|
||||||
|
## Bluetooth 입력을 헤드폰으로 다시 보낼 때 끊김
|
||||||
|
|
||||||
|
`journalctl -u bluealsa -u bluealsa-aplay -u radxa-bt-output --since "5 min ago"`
|
||||||
|
에서 `Missing RTP packets`는 입력 수신 패킷 손실, `underrun`/`overrun`은
|
||||||
|
오디오 버퍼 오류를 뜻합니다. 수신 손실은 버퍼만 늘려 복구되지 않습니다.
|
||||||
|
같은 입력을 DAC로 보낼 때와 Bluetooth 출력으로 보낼 때를 비교하세요.
|
||||||
|
현재 송신 버퍼는 500 ms이며 지연이 추가됩니다. 같은 컨트롤러의 동시
|
||||||
|
수신·송신 안정성은 실제 청취 검증이 필요합니다.
|
||||||
|
|
||||||
|
## Bluetooth 컨트롤러 점검
|
||||||
|
|
||||||
|
기준 AIC 보드에서 데몬/하드웨어 초기화 중 UART 컨트롤러가 오류 상태에 들어가
|
||||||
|
`/sys/class/bluetooth/hci0`는 있어도 `bluetoothctl list`가 비는 경우를 확인했습니다.
|
||||||
|
`hciconfig -a`, `btmgmt info`, `dmesg`로 확인하고, Bluetooth 초기화 서비스로
|
||||||
|
풀리지 않으면 재부팅 후 다시 확인합니다. 페어링 기록을 먼저 삭제하지 마세요.
|
||||||
|
|||||||
+29
-2
@@ -3,12 +3,16 @@
|
|||||||
## 완료
|
## 완료
|
||||||
|
|
||||||
- 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구,
|
- 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구,
|
||||||
설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지 등 **자동 검사 11개 통과**.
|
설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지,
|
||||||
|
입력 PCM 제한, 출력 볼륨 변경 거부, HTTP 토큰/동일 출처 검사,
|
||||||
|
활성 AirPlay 속성 파싱, 영구 등록 실패 처리 등 **자동 검사 23개 통과**.
|
||||||
- Bash 문법 검사와 ShellCheck 통과.
|
- Bash 문법 검사와 ShellCheck 통과.
|
||||||
- Python 문법 확인: macOS Python 3.14, 보드의 Debian Python에서 확인.
|
- Python 문법 확인: macOS Python 3.14, 보드의 Debian Python에서 확인.
|
||||||
- systemd의 새 `radxa-bt-output.service` 검증 통과.
|
- systemd의 새 `radxa-bt-output.service` 검증 통과.
|
||||||
- 배포할 Git bundle에서 **새 작업 디렉토리로 복제하고 실제 보드에서 컴파일 성공**.
|
- 배포할 Git bundle에서 **새 작업 디렉토리로 복제하고 실제 보드에서 컴파일 성공**.
|
||||||
- Shairport Sync: `5.5.2-AirPlay2-smi10-OpenSSL-Avahi-ALSA-soxr-sysconfdir:/etc`
|
- Shairport Sync 웹 버전: `5.5.2-dirty-AirPlay2-smi10-OpenSSL-Avahi-ALSA-soxr-metadata-dbus-sysconfdir:/etc`
|
||||||
|
- 포함된 네이티브 볼륨 패치를 적용한 실제 보드 빌드 및 설치 성공.
|
||||||
|
`dirty`는 해당 패치를 적용했기 때문에 표시됩니다.
|
||||||
- NQPTP: 1.2.8 / 공유 메모리 규격 10.
|
- NQPTP: 1.2.8 / 공유 메모리 규격 10.
|
||||||
- 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고,
|
- 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고,
|
||||||
완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록.
|
완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록.
|
||||||
@@ -17,6 +21,29 @@
|
|||||||
- 최종 보드 설정: Bluetooth 출력, Bose QC Headphones.
|
- 최종 보드 설정: Bluetooth 출력, Bose QC Headphones.
|
||||||
- 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인.
|
- 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인.
|
||||||
- 기존 Wi-Fi 안정화 옵션 `he_on=N`, `ps_on=N` 유지 확인.
|
- 기존 Wi-Fi 안정화 옵션 `he_on=N`, `ps_on=N` 유지 확인.
|
||||||
|
- 웹 서비스 자동 시작, 브라우저의 AirPlay/휴대폰 입력 볼륨과 음소거,
|
||||||
|
DAC 선택을 실제 API와 장치 속성으로 확인.
|
||||||
|
- 공통 감쇠 −3 dB, 명령행 제어 유지, JavaScript/Bash 문법 검사 통과.
|
||||||
|
|
||||||
|
## Bluetooth 끊김 조사
|
||||||
|
|
||||||
|
사용자가 iPhone Bluetooth → 보스 헤드폰 경로의 끊김을 보고했습니다.
|
||||||
|
iPhone 수신 RTP 패킷 손실과 입력 재생 underrun이 반복됐고,
|
||||||
|
헤드폰 출력 alsaloop에서 capture overrun도 한 번 확인됐습니다.
|
||||||
|
Wi-Fi는 5 GHz, CPU 부하는 낮았으며 원인은 아직 확정하지 않았습니다.
|
||||||
|
출력 버퍼를 200 ms에서 500 ms로 늘려도 사용자가 끊김을 확인했습니다.
|
||||||
|
송신을 멈춘 약 18초 동안에는 입력 패킷 손실이 없었고, 송신 재개 후 다시 발생했습니다.
|
||||||
|
SBC 송신 품질을 high에서 medium으로 줄인 뒤 사용자는 정상 청취라고 답했지만,
|
||||||
|
같은 시점에 Radxa의 보스 출력 PCM이 없고 연결 재시도 중인 것을 발견했습니다.
|
||||||
|
직접 연결 경로의 소리일 수 있어 이 응답만으로 해결됐다고 판단하지 않습니다.
|
||||||
|
medium과 500 ms 버퍼를 저장했고 출력 등록 복구 후 다시 검증해야 합니다.
|
||||||
|
이후 컨트롤러에서 HCI 명령 `0x0c1a`/`0x0408` 시간 초과와 BlueZ `NoReply`를
|
||||||
|
확인했습니다. Bluetooth 서비스 재시작만으로 복구되지 않아 보드를 재부팅했습니다.
|
||||||
|
SBC 품질 변경으로 컨트롤러 오류가 해결됐다고 보지는 않습니다.
|
||||||
|
|
||||||
|
보스는 현재 `Paired: yes / Bonded: no`인 임시 연결입니다. 영구 등록 시도가
|
||||||
|
인증 실패하여 재부팅 후 보스 등록 유지가 검증됐다고 주장하지 않습니다.
|
||||||
|
이미 영구 등록된 휴대폰 등록은 재부팅 후 유지됐습니다.
|
||||||
|
|
||||||
## 실제 청취로 확인한 범위
|
## 실제 청취로 확인한 범위
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -42,7 +42,7 @@ spec=importlib.util.spec_from_file_location('manager',base/'scripts/radxa-audio.
|
|||||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||||
m.validate(dict(name=sys.argv[2],dac_pcm=sys.argv[3],output='dac',bluetooth_device=''))
|
m.validate(dict(name=sys.argv[2],dac_pcm=sys.argv[3],output='dac',bluetooth_device=''))
|
||||||
manifest=json.loads((base/'sources/manifest.json').read_text())
|
manifest=json.loads((base/'sources/manifest.json').read_text())
|
||||||
for source in manifest['sources']:
|
for source in manifest['sources'] + manifest.get('patches', []):
|
||||||
p=base/'sources'/source['file']
|
p=base/'sources'/source['file']
|
||||||
assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name
|
assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name
|
||||||
PY
|
PY
|
||||||
@@ -72,7 +72,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
|||||||
build-essential git autoconf automake libtool pkg-config xmltoman \
|
build-essential git autoconf automake libtool pkg-config xmltoman \
|
||||||
libpopt-dev libconfig-dev libssl-dev libsoxr-dev libasound2-dev \
|
libpopt-dev libconfig-dev libssl-dev libsoxr-dev libasound2-dev \
|
||||||
libavahi-client-dev libavcodec-dev libavformat-dev libavutil-dev libswresample-dev uuid-dev \
|
libavahi-client-dev libavcodec-dev libavformat-dev libavutil-dev libswresample-dev uuid-dev \
|
||||||
libplist-dev libsodium-dev libgcrypt20-dev avahi-daemon \
|
libglib2.0-dev libplist-dev libsodium-dev libgcrypt20-dev avahi-daemon \
|
||||||
bluez bluez-alsa-utils libasound2-plugin-bluez alsa-utils python3
|
bluez bluez-alsa-utils libasound2-plugin-bluez alsa-utils python3
|
||||||
# Verify hardware and installed-package capabilities before switching outputs.
|
# Verify hardware and installed-package capabilities before switching outputs.
|
||||||
[[ -d /sys/class/bluetooth/hci0 ]] || { echo '블루투스 컨트롤러가 없습니다.' >&2; exit 1; }
|
[[ -d /sys/class/bluetooth/hci0 ]] || { echo '블루투스 컨트롤러가 없습니다.' >&2; exit 1; }
|
||||||
@@ -95,10 +95,11 @@ git clone --quiet --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bund
|
|||||||
)
|
)
|
||||||
(
|
(
|
||||||
cd "$BUILD_DIR/shairport-sync"
|
cd "$BUILD_DIR/shairport-sync"
|
||||||
|
git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch"
|
||||||
autoreconf -fi
|
autoreconf -fi
|
||||||
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
|
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
|
||||||
--with-avahi --with-ssl=openssl --with-systemd-startup \
|
--with-avahi --with-ssl=openssl --with-systemd-startup \
|
||||||
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2
|
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-dbus-interface
|
||||||
make -j "$JOBS"
|
make -j "$JOBS"
|
||||||
)
|
)
|
||||||
# Finish both builds before stopping the existing receiver.
|
# Finish both builds before stopping the existing receiver.
|
||||||
@@ -148,10 +149,11 @@ if (( WIFI5 )); then
|
|||||||
'options aic8800_fdrv ps_on=0 he_on=0' > /etc/modprobe.d/90-radxa-airplay-wifi.conf
|
'options aic8800_fdrv ps_on=0 he_on=0' > /etc/modprobe.d/90-radxa-airplay-wifi.conf
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
/usr/local/bin/shairport-sync -V | grep -q '5.5.2-AirPlay2.*smi10'
|
/usr/local/bin/shairport-sync -V | grep -q '5.5.2.*AirPlay2.*smi10.*dbus'
|
||||||
/usr/local/bin/nqptp -V | grep -q '1.2.8.*10'
|
/usr/local/bin/nqptp -V | grep -q '1.2.8.*10'
|
||||||
radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM"
|
radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM"
|
||||||
radxa-audio status
|
radxa-audio status
|
||||||
|
bash "$PACKAGE_DIR/scripts/install-web.sh"
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR"
|
echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR"
|
||||||
if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi
|
if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
- USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`.
|
- USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`.
|
||||||
- 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC).
|
- 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC).
|
||||||
- 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님).
|
- 출력 장비 주소: `E4:58:BC:C3:3B:F8` (기존 장비 참고만; 새 설치 기본값이 아님).
|
||||||
|
- 웹 믹서: `http://192.168.10.83:8088`, `radxa-mixer-web.service` 자동 시작.
|
||||||
|
- 공통 감쇠 −3 dB. AirPlay는 기존 네이티브 볼륨만 제어.
|
||||||
|
- 보스는 현재 임시 등록(`Bonded: no`)이며 영구 등록은 추가 확인 필요.
|
||||||
- Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함.
|
- Wi-Fi 드라이버: aic8800_fdrv; `ps_on=0 he_on=0` 설정을 재부팅 후 확인함.
|
||||||
- USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며,
|
- USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며,
|
||||||
이 오디오 설치 자료가 생성하거나 변경하지 않음.
|
이 오디오 설치 자료가 생성하거나 변경하지 않음.
|
||||||
@@ -14,7 +17,9 @@
|
|||||||
최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은
|
최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은
|
||||||
사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및
|
사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및
|
||||||
A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다.
|
A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다.
|
||||||
헤드폰에서 실제 두 소리를 청취했다는 확인은 아직 받지 못했습니다.
|
사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과
|
||||||
|
버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용했으나,
|
||||||
|
보스 출력 연결이 실패하여 경로 복구와 청취 검증이 필요합니다.
|
||||||
|
|
||||||
기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와
|
기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와
|
||||||
`/root/radxa-airplay2-backup-20261011-193842/`에 있습니다.
|
`/root/radxa-airplay2-backup-20261011-193842/`에 있습니다.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
+14
-5
@@ -8,10 +8,18 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay',
|
UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay',
|
||||||
'radxa-bt-output', 'radxa-bose-output', 'avahi-daemon', 'bluetooth']
|
'radxa-bt-output', 'radxa-bose-output', 'radxa-mixer-web', 'avahi-daemon', 'bluetooth']
|
||||||
PATHS = [
|
PATHS = [
|
||||||
|
'/etc/systemd/system/radxa-mixer-web.service',
|
||||||
|
'/etc/dbus-1/system.d/shairport-sync-dbus.conf',
|
||||||
|
'/usr/local/lib/radxa-mixer/input_controls.py',
|
||||||
|
'/usr/local/lib/radxa-mixer/web_server.py',
|
||||||
|
'/usr/local/lib/radxa-mixer/web/index.html',
|
||||||
|
'/usr/local/lib/radxa-mixer/web/app.js',
|
||||||
|
'/usr/local/lib/radxa-mixer/web/style.css',
|
||||||
'/etc/asound.conf', '/etc/shairport-sync.conf',
|
'/etc/asound.conf', '/etc/shairport-sync.conf',
|
||||||
'/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf',
|
'/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf',
|
||||||
|
'/etc/radxa-audio/input-memory.json',
|
||||||
'/etc/radxa-audio/asound-bluetooth.conf',
|
'/etc/radxa-audio/asound-bluetooth.conf',
|
||||||
'/etc/systemd/system/shairport-sync.service', '/etc/systemd/system/nqptp.service',
|
'/etc/systemd/system/shairport-sync.service', '/etc/systemd/system/nqptp.service',
|
||||||
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf',
|
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf',
|
||||||
@@ -48,9 +56,9 @@ def snapshot(directory):
|
|||||||
def restore(directory):
|
def restore(directory):
|
||||||
manifest = json.loads((directory / 'manifest.json').read_text())
|
manifest = json.loads((directory / 'manifest.json').read_text())
|
||||||
# Only restore allowlisted paths from our own snapshot.
|
# Only restore allowlisted paths from our own snapshot.
|
||||||
if set(manifest['files']) != set(PATHS) or set(manifest['units']) != set(UNITS):
|
if not set(manifest['files']).issubset(PATHS) or not set(manifest['units']).issubset(UNITS):
|
||||||
raise ValueError('알 수 없는 백업 형식입니다.')
|
raise ValueError('알 수 없는 백업 형식입니다.')
|
||||||
for unit in UNITS[:6]: ctl('stop', unit)
|
for unit in UNITS[:7]: ctl('stop', unit)
|
||||||
for name, existed in manifest['files'].items():
|
for name, existed in manifest['files'].items():
|
||||||
target = Path(name)
|
target = Path(name)
|
||||||
if existed:
|
if existed:
|
||||||
@@ -60,11 +68,12 @@ def restore(directory):
|
|||||||
else:
|
else:
|
||||||
target.unlink(missing_ok=True)
|
target.unlink(missing_ok=True)
|
||||||
ctl('daemon-reload')
|
ctl('daemon-reload')
|
||||||
for unit, state in manifest['units'].items():
|
for unit in UNITS:
|
||||||
|
state = manifest['units'].get(unit, {'active': False, 'enabled': False})
|
||||||
ctl('enable' if state['enabled'] else 'disable', unit)
|
ctl('enable' if state['enabled'] else 'disable', unit)
|
||||||
# Bluetooth and Avahi may have been started by apt during installation.
|
# Bluetooth and Avahi may have been started by apt during installation.
|
||||||
for unit in reversed(UNITS):
|
for unit in reversed(UNITS):
|
||||||
state = manifest['units'][unit]
|
state = manifest['units'].get(unit, {'active': False, 'enabled': False})
|
||||||
result = ctl('restart' if state['active'] else 'stop', unit)
|
result = ctl('restart' if state['active'] else 'stop', unit)
|
||||||
if state['active'] and result.returncode:
|
if state['active'] and result.returncode:
|
||||||
print(result.stdout, file=sys.stderr)
|
print(result.stdout, file=sys.stderr)
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ git clone -q --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bundle" "
|
|||||||
)
|
)
|
||||||
(
|
(
|
||||||
cd "$BUILD_DIR/shairport-sync"
|
cd "$BUILD_DIR/shairport-sync"
|
||||||
|
git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch"
|
||||||
autoreconf -fi
|
autoreconf -fi
|
||||||
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
|
./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \
|
||||||
--with-avahi --with-ssl=openssl --with-systemd-startup \
|
--with-avahi --with-ssl=openssl --with-systemd-startup \
|
||||||
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2
|
--with-systemdsystemunitdir=/etc/systemd/system --with-airplay-2 --with-dbus-interface
|
||||||
make -j 1
|
make -j 1
|
||||||
./shairport-sync -V
|
./shairport-sync -V
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
CONFIG = Path('/etc/radxa-audio/mixer.json')
|
||||||
|
MEMORY = Path('/etc/radxa-audio/input-memory.json')
|
||||||
|
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 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:
|
||||||
|
devices = objects('org.bluez', '/')
|
||||||
|
for path, props in pcms().items():
|
||||||
|
if not is_input(props): continue
|
||||||
|
dev = props['Device']; key = address(dev)
|
||||||
|
d = devices.get(dev, {}).get('org.bluez.Device1', {})
|
||||||
|
volume, muted = decode_volume(int(props['Volume']))
|
||||||
|
result.append({'id':key,'name':d.get('Alias', key),'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 = []
|
||||||
|
for path, interfaces in objects('org.bluez', '/').items():
|
||||||
|
props = interfaces.get('org.bluez.Device1')
|
||||||
|
if not props: 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 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))
|
||||||
|
return {'config':config,'inputs':inputs(),'bluetooth':bluetooth,'dacs':dac,'issues':issues}
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install/update web tools on a board that already has Shairport Sync with D-Bus.
|
||||||
|
set -euo pipefail
|
||||||
|
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
[[ $EUID == 0 ]] || { echo 'sudo로 실행해 주세요.' >&2; exit 1; }
|
||||||
|
/usr/local/bin/shairport-sync -V | grep -qi dbus || { echo '먼저 AirPlay D-Bus 지원 빌드를 설치해 주세요.' >&2; exit 1; }
|
||||||
|
install -d -m 755 /usr/local/lib/radxa-mixer/web
|
||||||
|
install -m 755 "$PACKAGE_DIR/scripts/radxa-audio.py" /usr/local/bin/radxa-audio
|
||||||
|
install -m 755 "$PACKAGE_DIR/scripts/web_server.py" /usr/local/lib/radxa-mixer/web_server.py
|
||||||
|
install -m 644 "$PACKAGE_DIR/scripts/input_controls.py" /usr/local/lib/radxa-mixer/input_controls.py
|
||||||
|
install -m 644 "$PACKAGE_DIR/scripts/web/"* /usr/local/lib/radxa-mixer/web/
|
||||||
|
cat > /etc/systemd/system/radxa-mixer-web.service <<'UNIT'
|
||||||
|
[Unit]
|
||||||
|
Description=Radxa wireless audio mixer web interface
|
||||||
|
After=network.target bluetooth.service shairport-sync.service bluealsa.service
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/lib/radxa-mixer/web_server.py --port 8088
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
UMask=0022
|
||||||
|
NoNewPrivileges=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now radxa-mixer-web
|
||||||
|
systemctl restart radxa-mixer-web
|
||||||
|
echo '웹 믹서: http://보드IP:8088'
|
||||||
+46
-16
@@ -66,7 +66,7 @@ def save(path, text, mode=0o644):
|
|||||||
|
|
||||||
|
|
||||||
def mixer(pcm, rate):
|
def mixer(pcm, rate):
|
||||||
return f'''# Managed by radxa-audio; each input has 6 dB of headroom.
|
return f'''# Managed by radxa-audio; each input is attenuated by 3 dB.
|
||||||
pcm.radxa_dmix {{
|
pcm.radxa_dmix {{
|
||||||
type dmix
|
type dmix
|
||||||
ipc_key 553001
|
ipc_key 553001
|
||||||
@@ -87,7 +87,7 @@ pcm.radxa_dmix {{
|
|||||||
pcm.radxa_mix {{
|
pcm.radxa_mix {{
|
||||||
type plug
|
type plug
|
||||||
slave.pcm "radxa_dmix"
|
slave.pcm "radxa_dmix"
|
||||||
ttable {{ 0.0 0.5 1.1 0.5 }}
|
ttable {{ 0.0 0.707945784 1.1 0.707945784 }}
|
||||||
}}
|
}}
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ diagnostics = {{ statistics = "yes"; log_verbosity = 1; }};
|
|||||||
''',
|
''',
|
||||||
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf': '''[Service]
|
'/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf': '''[Service]
|
||||||
ExecStart=
|
ExecStart=
|
||||||
ExecStart=/usr/bin/bluealsa -S -p a2dp-sink -p a2dp-source --initial-volume=50
|
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]
|
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit]
|
||||||
Requires=bluealsa.service
|
Requires=bluealsa.service
|
||||||
@@ -143,7 +143,7 @@ StartLimitIntervalSec=0
|
|||||||
User=shairport-sync
|
User=shairport-sync
|
||||||
Group=audio
|
Group=audio
|
||||||
ExecStartPre=-/usr/bin/bluetoothctl --timeout 8 connect {addr} a2dp-sink
|
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
|
ExecStart=/usr/bin/alsaloop -C hw:Loopback,1,0 -P bluealsa:DEV={addr},PROFILE=a2dp -c 2 -r 48000 -f S16_LE -t 500000 -S 4 -A 2
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
TimeoutStartSec=30
|
TimeoutStartSec=30
|
||||||
@@ -199,12 +199,16 @@ def check_dac(pcm):
|
|||||||
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
|
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
|
||||||
|
|
||||||
|
|
||||||
def apply(c, initial=False):
|
def apply(c, initial=False, reconfigure=False):
|
||||||
root()
|
root()
|
||||||
files = templates(c)
|
files = templates(c)
|
||||||
if c["output"] == "dac":
|
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"])
|
check_dac(c["dac_pcm"])
|
||||||
if c['output'] == 'bluetooth':
|
if c['output'] == 'bluetooth' and not reconfigure:
|
||||||
# Preflight before changing a working DAC route.
|
# Preflight before changing a working DAC route.
|
||||||
run('modprobe', 'snd-aloop')
|
run('modprobe', 'snd-aloop')
|
||||||
result = run('bluetoothctl', '--timeout', '10', 'connect',
|
result = run('bluetoothctl', '--timeout', '10', 'connect',
|
||||||
@@ -229,6 +233,8 @@ def apply(c, initial=False):
|
|||||||
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
|
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
|
||||||
'shairport-sync', 'bluealsa', 'bluealsa-aplay')
|
'shairport-sync', 'bluealsa', 'bluealsa-aplay')
|
||||||
run('systemctl', 'restart', 'bluetooth')
|
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', 'start', 'avahi-daemon', 'nqptp')
|
||||||
run('systemctl', 'restart', 'bluealsa')
|
run('systemctl', 'restart', 'bluealsa')
|
||||||
if c['output'] == 'bluetooth':
|
if c['output'] == 'bluetooth':
|
||||||
@@ -238,12 +244,12 @@ def apply(c, initial=False):
|
|||||||
run('systemctl', 'disable', BT_SERVICE, check=False, capture=True)
|
run('systemctl', 'disable', BT_SERVICE, check=False, capture=True)
|
||||||
run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay')
|
run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay')
|
||||||
required = ['shairport-sync', 'bluealsa-aplay']
|
required = ['shairport-sync', 'bluealsa-aplay']
|
||||||
if c['output'] == 'bluetooth':
|
if c['output'] == 'bluetooth' and not reconfigure:
|
||||||
required.append(BT_SERVICE)
|
required.append(BT_SERVICE)
|
||||||
if not all(active(u) for u in required):
|
if not all(active(u) for u in required):
|
||||||
raise ValueError('서비스 시작에 실패했습니다.')
|
raise ValueError('서비스 시작에 실패했습니다.')
|
||||||
alias = c['name'] + ' Bluetooth'
|
alias = c['name'] + ' Bluetooth'
|
||||||
run('bluetoothctl', 'system-alias', alias)
|
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'])
|
print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm'])
|
||||||
except Exception:
|
except Exception:
|
||||||
run('systemctl', 'stop', *units, check=False)
|
run('systemctl', 'stop', *units, check=False)
|
||||||
@@ -257,12 +263,12 @@ def apply(c, initial=False):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def pair(addr, output=False):
|
def pair(addr, output=False, bond=False):
|
||||||
root()
|
root()
|
||||||
if not MAC_RE.fullmatch(addr):
|
if not MAC_RE.fullmatch(addr):
|
||||||
raise ValueError('장비 주소 형식이 잘못됐습니다.')
|
raise ValueError('장비 주소 형식이 잘못됐습니다.')
|
||||||
info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout
|
info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout
|
||||||
if 'Paired: yes' not in info:
|
if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info):
|
||||||
# Outgoing pairing agent lives only for this operation.
|
# Outgoing pairing agent lives only for this operation.
|
||||||
controller = run('bluetoothctl', 'show', capture=True).stdout
|
controller = run('bluetoothctl', 'show', capture=True).stdout
|
||||||
was_pairable = 'Pairable: yes' in controller
|
was_pairable = 'Pairable: yes' in controller
|
||||||
@@ -275,6 +281,8 @@ def pair(addr, output=False):
|
|||||||
info = run('bluetoothctl', 'info', addr, capture=True).stdout
|
info = run('bluetoothctl', 'info', addr, capture=True).stdout
|
||||||
if 'Paired: yes' not in info:
|
if 'Paired: yes' not in info:
|
||||||
raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.')
|
raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.')
|
||||||
|
if bond and 'Bonded: yes' not in info:
|
||||||
|
raise ValueError('영구 등록되지 않았습니다. 장비를 페어링 모드로 다시 켠 뒤 재시도해 주세요.')
|
||||||
run('bluetoothctl', 'trust', addr)
|
run('bluetoothctl', 'trust', addr)
|
||||||
run('bluetoothctl', '--timeout', '10', 'connect', addr,
|
run('bluetoothctl', '--timeout', '10', 'connect', addr,
|
||||||
'a2dp-sink' if output else 'a2dp-source', check=False)
|
'a2dp-sink' if output else 'a2dp-source', check=False)
|
||||||
@@ -292,16 +300,27 @@ def main():
|
|||||||
sub.add_parser('status')
|
sub.add_parser('status')
|
||||||
sub.add_parser('devices')
|
sub.add_parser('devices')
|
||||||
q = sub.add_parser('scan'); q.add_argument('--seconds', type=int, default=30)
|
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 = 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('pair-input'); q.add_argument('--seconds', type=int, default=120)
|
||||||
q = sub.add_parser('output'); q.add_argument('mode', choices=['dac', 'bluetooth', 'bose']); q.add_argument('address', nargs='?')
|
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('name'); q.add_argument('name')
|
||||||
q = sub.add_parser('dac'); q.add_argument('pcm')
|
q = sub.add_parser('dac'); q.add_argument('pcm')
|
||||||
sub.add_parser('apply')
|
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 = 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('--adopt', action='store_true'); q.add_argument('--address', default='')
|
||||||
q.add_argument('--output', choices=['dac', 'bluetooth'], default='dac')
|
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()
|
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':
|
if a.cmd == 'devices':
|
||||||
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
|
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
|
||||||
elif a.cmd == 'status':
|
elif a.cmd == 'status':
|
||||||
@@ -318,7 +337,7 @@ def main():
|
|||||||
run('bluetoothctl', 'scan', 'off', check=False)
|
run('bluetoothctl', 'scan', 'off', check=False)
|
||||||
run('bluetoothctl', 'devices')
|
run('bluetoothctl', 'devices')
|
||||||
elif a.cmd == 'pair':
|
elif a.cmd == 'pair':
|
||||||
pair(a.address, a.output)
|
pair(a.address, a.output, a.bond)
|
||||||
elif a.cmd == 'pair-input':
|
elif a.cmd == 'pair-input':
|
||||||
root()
|
root()
|
||||||
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
|
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
|
||||||
@@ -338,12 +357,15 @@ def main():
|
|||||||
c = load()
|
c = load()
|
||||||
if a.cmd == 'output':
|
if a.cmd == 'output':
|
||||||
c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac'
|
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 a.address:
|
||||||
if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.')
|
if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.')
|
||||||
c['bluetooth_device'] = a.address.upper()
|
c['bluetooth_device'] = a.address.upper()
|
||||||
elif a.cmd == 'name': c['name'] = a.name
|
elif a.cmd == 'name': c['name'] = a.name
|
||||||
elif a.cmd == 'dac': c['dac_pcm'] = a.pcm
|
elif a.cmd == 'dac': c['dac_pcm'] = a.pcm
|
||||||
apply(c)
|
apply(c, reconfigure=a.cmd == 'apply')
|
||||||
|
|
||||||
|
|
||||||
def interactive_pair(seconds):
|
def interactive_pair(seconds):
|
||||||
@@ -381,7 +403,15 @@ def interactive_pair(seconds):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
try:
|
try:
|
||||||
main()
|
import fcntl
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] not in ('status', 'devices', 'inputs', '-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:
|
except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
|
||||||
print('오류:', e, file=sys.stderr)
|
print('오류:', e, file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
let state, token, pending=false, tab='dac', refreshTimer;
|
||||||
|
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 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 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);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Radxa Mixer</title><link rel="stylesheet" href="/style.css"></head><body>
|
||||||
|
<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>출력 장비</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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{color-scheme:dark;--bg:#0b1116;--panel:#141d24;--line:#28343e;--text:#ebf3f7;--muted:#8ea3b3;--accent:#72e4c1}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 70% 0,#17352e55,transparent 55%),var(--bg);color:var(--text);font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:960px;margin:0 auto;padding:44px 24px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:26px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:.18em;color:var(--accent)}h1{font-size:36px;letter-spacing:-1px;margin:8px 0}header p{color:var(--muted);margin:0}.dot{display:inline-block;width:9px;height:9px;background:var(--accent);border-radius:50%;margin-left:12px;vertical-align:middle}.notice{padding:13px 18px;background:#182d27;color:#b1ecda;border:1px solid #285844;border-radius:12px;margin-bottom:24px;white-space:pre-line}.notice.error{background:#332124;border-color:#6f3943;color:#ffc3cc}section{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:26px;margin:22px 0}.sectionTitle{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}h2{font-size:19px;letter-spacing:-.4px;margin:0}.sectionTitle span,.hint{color:var(--muted);font-size:12px}.inputs{display:grid;grid-template-columns:repeat(auto-fit,minmax(235px,1fr));gap:16px}.input{padding:20px;border:1px solid var(--line);border-radius:14px;background:#0c151c}.inputTop{display:flex;justify-content:space-between;gap:10px;align-items:start}.input h3{font-size:16px;margin:0 0 6px;overflow-wrap:anywhere}.kind{color:var(--muted);font-size:11px}.playState{font-size:11px;color:var(--accent)}.volumeValue{font-size:42px;font-weight:600;letter-spacing:-2px;margin:24px 0 14px}.volumeValue small{font-size:16px;color:var(--muted);margin-left:6px;letter-spacing:0}input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer;margin:8px 0 16px}.rangeLabels{display:flex;justify-content:space-between;color:var(--muted);font-size:11px}button,select{font:inherit;border:1px solid var(--line);border-radius:9px;padding:11px 16px}button{background:var(--accent);color:#08241c;font-weight:600;cursor:pointer;transition:background .15s}button:hover{filter:brightness(1.12)}button:disabled{opacity:.4;cursor:wait}.quiet,.mute{background:transparent;color:var(--text)}.mute{width:100%;margin-top:15px}.mute.on{background:#403028;border-color:#715141;color:#ffcf9e}.tabs{display:flex;gap:8px;margin-bottom:24px}.tabs button{background:#0c151c;color:var(--muted);min-width:120px}.tabs button.selected{border-color:var(--accent);color:var(--accent);background:#17332c}label{display:block;font-size:12px;color:var(--muted);margin:12px 0}.choose{display:flex;gap:12px}select{background:#0c151c;color:var(--text);flex:1;min-width:0}.scanRow{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-top:22px}.scanRow p{font-size:12px;color:var(--muted)}.discoveredRow{border-top:1px solid var(--line);padding:14px 0;display:flex;align-items:center;justify-content:space-between;gap:12px}.discoveredRow small{display:block;color:var(--muted);margin-top:4px}.hint{line-height:1.7;margin-bottom:0}footer{display:flex;justify-content:space-between;margin-top:32px;font-size:10px;letter-spacing:.1em;color:var(--muted)}[hidden]{display:none!important}@media(max-width:600px){main{padding:26px 16px}h1{font-size:29px}section{padding:20px}.sectionTitle{align-items:start;gap:10px}.sectionTitle span{text-align:right}.choose{flex-direction:column}.scanRow{align-items:start}.inputs{grid-template-columns:1fr}header{gap:12px}header button{font-size:12px;padding:9px}}
|
||||||
Executable
+144
@@ -0,0 +1,144 @@
|
|||||||
|
#!/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=='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()
|
||||||
@@ -16,5 +16,12 @@
|
|||||||
"file": "nqptp-1.2.8.bundle",
|
"file": "nqptp-1.2.8.bundle",
|
||||||
"sha256": "0efab7d2e295981243cbd61c0d5cb940ec775ad517b02f23a748f1f6ab6a9095"
|
"sha256": "0efab7d2e295981243cbd61c0d5cb940ec775ad517b02f23a748f1f6ab6a9095"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"patches": [
|
||||||
|
{
|
||||||
|
"file": "shairport-native-volume.patch",
|
||||||
|
"sha256": "e0bc0e11ea0ed3816ba0f1a0762bdf40083ef1c4c71f481656dc7ba465ca0cee",
|
||||||
|
"purpose": "Apply the existing native AirPlay Volume property to the active session; no new gain stage."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
--- a/dbus-service.c
|
||||||
|
+++ b/dbus-service.c
|
||||||
|
@@ -715,6 +715,7 @@
|
||||||
|
if (((iv >= -30.0) && (iv <= 0.0)) || (iv == -144.0)) {
|
||||||
|
debug(2, ">> set volume to %7.4f.", iv);
|
||||||
|
config.airplay_volume = iv;
|
||||||
|
+ rtsp_set_native_volume(iv);
|
||||||
|
} else {
|
||||||
|
debug(1, ">> invalid volume: %f. Ignored.", iv);
|
||||||
|
shairport_sync_set_volume(skeleton, config.airplay_volume);
|
||||||
|
--- a/rtsp.h
|
||||||
|
+++ b/rtsp.h
|
||||||
|
@@ -31,6 +31,7 @@
|
||||||
|
// this will release the play lock only if the conn has it or if the conn is NULL
|
||||||
|
|
||||||
|
void stop_play(); // stop and drop a playing connection
|
||||||
|
+void rtsp_set_native_volume(double volume);
|
||||||
|
|
||||||
|
#ifdef CONFIG_AIRPLAY_2
|
||||||
|
ssize_t read_encrypted(int fd, pair_cipher_bundle *ctx, void *buf, size_t count);
|
||||||
|
--- a/rtsp.c
|
||||||
|
+++ b/rtsp.c
|
||||||
|
@@ -278,6 +278,14 @@
|
||||||
|
// other devices.
|
||||||
|
|
||||||
|
// If a conn has play lock, kill the connection completely.
|
||||||
|
+
|
||||||
|
+/* Update the existing player gain, without an additional gain stage. */
|
||||||
|
+void rtsp_set_native_volume(double volume) {
|
||||||
|
+ pthread_rwlock_rdlock(&principal_conn_lock);
|
||||||
|
+ if (principal_conn != NULL)
|
||||||
|
+ player_volume_without_notification(volume, principal_conn);
|
||||||
|
+ pthread_rwlock_unlock(&principal_conn_lock);
|
||||||
|
+}
|
||||||
|
|
||||||
|
void stop_play() {
|
||||||
|
pthread_rwlock_wrlock(&principal_conn_lock);
|
||||||
+25
-1
@@ -40,7 +40,7 @@ class ManagerTests(unittest.TestCase):
|
|||||||
self.assertIn('"radxa_mix"',f['/etc/shairport-sync.conf'])
|
self.assertIn('"radxa_mix"',f['/etc/shairport-sync.conf'])
|
||||||
self.assertIn('--pcm=radxa_mix',f['/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf'])
|
self.assertIn('--pcm=radxa_mix',f['/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf'])
|
||||||
self.assertNotIn('--single-audio', ''.join(f.values()))
|
self.assertNotIn('--single-audio', ''.join(f.values()))
|
||||||
self.assertIn('ttable { 0.0 0.5 1.1 0.5 }',f['/etc/asound.conf'])
|
self.assertIn('ttable { 0.0 0.707945784 1.1 0.707945784 }',f['/etc/asound.conf'])
|
||||||
self.assertIn('rate 44100',f['/etc/asound.conf'])
|
self.assertIn('rate 44100',f['/etc/asound.conf'])
|
||||||
|
|
||||||
def test_output_device_is_not_hardcoded(self):
|
def test_output_device_is_not_hardcoded(self):
|
||||||
@@ -51,6 +51,8 @@ class ManagerTests(unittest.TestCase):
|
|||||||
self.assertNotIn('E4:58',unit)
|
self.assertNotIn('E4:58',unit)
|
||||||
self.assertIn('Loopback', f['/etc/asound.conf'])
|
self.assertIn('Loopback', f['/etc/asound.conf'])
|
||||||
self.assertIn('rate 48000',f['/etc/asound.conf'])
|
self.assertIn('rate 48000',f['/etc/asound.conf'])
|
||||||
|
self.assertIn('--sbc-quality=medium',f['/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf'])
|
||||||
|
self.assertIn('-t 500000',unit)
|
||||||
|
|
||||||
def test_name_quotes_are_escaped(self):
|
def test_name_quotes_are_escaped(self):
|
||||||
f=m.templates(self.config(name='Desk "Two"'))
|
f=m.templates(self.config(name='Desk "Two"'))
|
||||||
@@ -64,6 +66,28 @@ class ManagerTests(unittest.TestCase):
|
|||||||
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
|
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
|
||||||
self.assertTrue(any('trust' in call.args for call in run.call_args_list))
|
self.assertTrue(any('trust' in call.args for call in run.call_args_list))
|
||||||
|
|
||||||
|
def test_bond_request_must_not_report_temporary_pair_as_success(self):
|
||||||
|
def fake(*args, **kwargs):
|
||||||
|
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: no')
|
||||||
|
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run:
|
||||||
|
with self.assertRaises(ValueError):m.pair('AA:BB:CC:DD:EE:FF',output=True,bond=True)
|
||||||
|
self.assertFalse(any('trust' in call.args for call in run.call_args_list))
|
||||||
|
|
||||||
|
def test_bond_request_keeps_existing_bond(self):
|
||||||
|
def fake(*args, **kwargs):
|
||||||
|
return subprocess.CompletedProcess(args,0,stdout='Paired: yes\nBonded: yes')
|
||||||
|
with patch.object(m,'root'),patch.object(m,'run',side_effect=fake) as run:
|
||||||
|
m.pair('AA:BB:CC:DD:EE:FF',bond=True)
|
||||||
|
self.assertFalse(any('pair' in call.args for call in run.call_args_list))
|
||||||
|
|
||||||
|
def test_reconfigure_does_not_require_headphone_to_be_on(self):
|
||||||
|
c=self.config(output='bluetooth',bluetooth_device='AA:BB:CC:DD:EE:FF')
|
||||||
|
with patch.object(m,'root'), patch.object(m,'run') as run, patch.object(m,'load',return_value=c), \
|
||||||
|
patch.object(m,'snapshot',return_value=Path('/mock')), patch.object(m,'save'), \
|
||||||
|
patch.object(m,'active',return_value=True),patch.object(m,'enabled',return_value=True):
|
||||||
|
m.apply(c,reconfigure=True)
|
||||||
|
self.assertFalse(any('connect' in call.args for call in run.call_args_list))
|
||||||
|
|
||||||
def test_failed_service_restores_configuration(self):
|
def test_failed_service_restores_configuration(self):
|
||||||
c=self.config()
|
c=self.config()
|
||||||
with patch.object(m, 'root'), patch.object(m, 'run') as run, \
|
with patch.object(m, 'root'), patch.object(m, 'run') as run, \
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0,str(Path(__file__).parents[1]/'scripts'))
|
||||||
|
import input_controls as a
|
||||||
|
import web_server as w
|
||||||
|
|
||||||
|
|
||||||
|
class InputTests(unittest.TestCase):
|
||||||
|
def test_bus_property_scalar_and_method_tuple(self):
|
||||||
|
with patch.object(a,'command',return_value='{"type":"d","data":-15.0}'):
|
||||||
|
self.assertEqual(a.bus('get-property','service','path','interface','Volume'),-15.0)
|
||||||
|
with patch.object(a,'command',return_value='{"type":"a{sv}","data":[{"Volume":-15.0}]}'):
|
||||||
|
self.assertEqual(a.bus('call','service','path','interface','GetAll'),{'Volume':-15.0})
|
||||||
|
|
||||||
|
def test_headphone_volume_write_is_rejected(self):
|
||||||
|
props={'Device':'/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF','Mode':'sink','Transport':'A2DP-source','Volume':0x7f7f}
|
||||||
|
with patch.object(a,'pcms',return_value={'/output':props}), patch.object(a,'command') as command:
|
||||||
|
with self.assertRaises(ValueError):a.set_input('AA:BB:CC:DD:EE:FF',volume=20)
|
||||||
|
command.assert_not_called()
|
||||||
|
|
||||||
|
def test_bluetooth_only_writes_input_pcm(self):
|
||||||
|
source={'Device':'/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF','Mode':'source','Transport':'A2DP-sink','Volume':0x7f7f}
|
||||||
|
sink={**source,'Mode':'sink','Transport':'A2DP-source'}
|
||||||
|
with patch.object(a,'pcms',return_value={'/input':source,'/output':sink}),patch.object(a,'command') as command:
|
||||||
|
a.set_input('AA:BB:CC:DD:EE:FF',volume=40)
|
||||||
|
self.assertEqual(command.call_count,2)
|
||||||
|
self.assertTrue(all('/input' in call.args for call in command.call_args_list))
|
||||||
|
self.assertTrue(all('/output' not in call.args for call in command.call_args_list))
|
||||||
|
|
||||||
|
def test_airplay_uses_existing_native_volume(self):
|
||||||
|
with patch.object(a,'airplay',return_value={'volume':80,'muted':False}),patch.object(a,'remember'),patch.object(a,'command') as command:
|
||||||
|
a.set_input('airplay',volume=50)
|
||||||
|
self.assertEqual(command.call_args.args,('busctl','--system','--','set-property',a.SPS,a.SPS_PATH,a.SPS,'Volume','d','-15.0'))
|
||||||
|
a.set_input('airplay',mute=True)
|
||||||
|
self.assertEqual(command.call_args.args[-1],'-144.0')
|
||||||
|
|
||||||
|
def test_volume_validation_rejects_outside_range_and_bool(self):
|
||||||
|
for level in (-1,101,True,'50'):
|
||||||
|
with self.subTest(level=level), self.assertRaises(ValueError): a.set_input('airplay',volume=level)
|
||||||
|
|
||||||
|
def test_packed_bluetooth_stereo_mute(self):
|
||||||
|
value=a.encode_volume(60,True)
|
||||||
|
level,muted=a.decode_volume(value)
|
||||||
|
self.assertEqual(level,60);self.assertTrue(muted)
|
||||||
|
self.assertEqual((value>>8)&255,value&255)
|
||||||
|
|
||||||
|
|
||||||
|
class WebTests(unittest.TestCase):
|
||||||
|
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'])
|
||||||
|
|
||||||
|
def test_no_output_volume_api(self):
|
||||||
|
for data in [{'action':'output-volume','volume':20},{'action':'volume','input':'dac','volume':20},
|
||||||
|
{'action':'volume','input':'airplay','volume':101},
|
||||||
|
{'action':'output','mode':'bluetooth','address':'AA:BB:CC:DD:EE:FF;reboot'}]:
|
||||||
|
with self.subTest(data=data),self.assertRaises(ValueError):w.validate_action(data)
|
||||||
|
|
||||||
|
def test_http_requires_token_and_same_origin(self):
|
||||||
|
import threading,json,urllib.request,urllib.error
|
||||||
|
server=w.ThreadingHTTPServer(('127.0.0.1',0),w.Handler)
|
||||||
|
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||||
|
url='http://127.0.0.1:'+str(server.server_address[1])+'/api/action'
|
||||||
|
try:
|
||||||
|
with patch.object(w,'allowed_host',return_value=True),patch.object(w,'launch',return_value='mockjob'):
|
||||||
|
body=json.dumps({'action':'scan'}).encode()
|
||||||
|
for headers in [{'Content-Type':'application/json'}, {'Content-Type':'application/json','X-Mixer-Token':w.TOKEN,'Origin':'http://other.site'}]:
|
||||||
|
req=urllib.request.Request(url,body,headers=headers)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req,timeout=5)
|
||||||
|
self.assertEqual(error.exception.code,403);error.exception.close()
|
||||||
|
req=urllib.request.Request(url,body,headers={'Content-Type':'application/json','X-Mixer-Token':w.TOKEN})
|
||||||
|
with urllib.request.urlopen(req,timeout=5) as response:self.assertEqual(response.status,202)
|
||||||
|
finally:server.shutdown();server.server_close();thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
Reference in New Issue
Block a user