From 5fbece12bbaa663923adf3d3eeb57497b314a503 Mon Sep 17 00:00:00 2001 From: jsahn Date: Mon, 12 Oct 2026 18:45:43 +0900 Subject: [PATCH] Add web mixer with native input volume and output selection --- README.md | 58 ++++++++- TROUBLESHOOTING.md | 33 ++++- VALIDATION.md | 31 ++++- install.sh | 10 +- reference/CURRENT-BOARD.md | 7 +- reference/web-mixer.jpg | Bin 0 -> 58881 bytes scripts/backup.py | 19 ++- scripts/check-build.sh | 3 +- scripts/input_controls.py | 167 ++++++++++++++++++++++++++ scripts/install-web.sh | 28 +++++ scripts/radxa-audio.py | 62 +++++++--- scripts/web/app.js | 11 ++ scripts/web/index.html | 6 + scripts/web/style.css | 1 + scripts/web_server.py | 144 ++++++++++++++++++++++ sources/manifest.json | 7 ++ sources/shairport-native-volume.patch | 37 ++++++ tests/test_manager.py | 26 +++- tests/test_web.py | 80 ++++++++++++ 19 files changed, 696 insertions(+), 34 deletions(-) create mode 100644 reference/web-mixer.jpg create mode 100644 scripts/input_controls.py create mode 100755 scripts/install-web.sh create mode 100644 scripts/web/app.js create mode 100644 scripts/web/index.html create mode 100644 scripts/web/style.css create mode 100755 scripts/web_server.py create mode 100644 sources/shairport-native-volume.patch create mode 100644 tests/test_web.py diff --git a/README.md b/README.md index fc241f4..a1ee264 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,58 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐ 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**. - 수신과 송신은 A2DP 음악 프로필을 사용합니다. 기준 보드에서는 SBC로 연결됐습니다. +- SBC 송신 품질은 medium입니다. 기준 보드에서 high로 동시 수신·송신 시 끊겨 + 전송량을 줄였습니다. 송신 버퍼는 500 ms이며 안정성은 청취 검증이 필요합니다. - DAC 출력은 44.1 kHz / 스테레오 / 16비트, 블루투스용 가상 믹서는 48 kHz입니다. -- 각 입력을 6 dB 낮춰 두 입력을 합칠 여유를 둡니다. 세 입력 이상은 각 기기 볼륨을 - 추가로 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다. +- 각 입력을 3 dB 낮춥니다. 여러 입력의 큰 소리가 겹쳐 찢어지면 입력 볼륨을 낮추세요. 지연 없는 실시간 녹음/악기 모니터링용 구성은 아닙니다. - 각 송신 기기의 볼륨으로 믹스 비율을 바꿉니다. - 출력은 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. 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 @@ -175,6 +224,9 @@ apt로 추가한 의존 패키지와 블루투스 페어링 기록은 유지합 |---|---| | `install.sh` | 새 보드 설치, 빌드, 서비스 설정, 실패 시 복구 | | `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/check-build.sh` | 서비스 변경 없이 고정 소스 빌드 확인 | | `sources/*.bundle`, `manifest.json` | 공식 Git 소스, 릴리스 태그, 커밋, SHA-256 | diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index a51bdfa..6249b12 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -69,7 +69,7 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on ## 3개 이상 입력을 더하면 찢어짐 -각 입력을 6 dB 낮춘 것은 두 입력 합산 기준입니다. 입력 수가 늘어나면 +각 입력의 공통 감쇠는 3 dB입니다. 입력 수가 늘어나면 각 기기의 볼륨을 더 낮춰 합산 클리핑을 피해야 합니다. 최대 안정적인 블루투스 동시 입력 수는 보드 컨트롤러/드라이버와 실제 재생으로 검증해야 합니다. @@ -87,3 +87,34 @@ cat /sys/module/aic8800_fdrv/parameters/ps_on ```bash 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 초기화 서비스로 +풀리지 않으면 재부팅 후 다시 확인합니다. 페어링 기록을 먼저 삭제하지 마세요. diff --git a/VALIDATION.md b/VALIDATION.md index f8007a1..c1aa781 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -3,12 +3,16 @@ ## 완료 - 입력값 검증, 연결 없는 출력 전환 중단, 서비스 실패 시 설정 복구, - 설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지 등 **자동 검사 11개 통과**. + 설치 백업 복구/심볼릭 링크 보존, 등록 장비 재페어링 방지, + 입력 PCM 제한, 출력 볼륨 변경 거부, HTTP 토큰/동일 출처 검사, + 활성 AirPlay 속성 파싱, 영구 등록 실패 처리 등 **자동 검사 23개 통과**. - Bash 문법 검사와 ShellCheck 통과. - Python 문법 확인: macOS Python 3.14, 보드의 Debian Python에서 확인. - systemd의 새 `radxa-bt-output.service` 검증 통과. - 배포할 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. - 기존 얕은 Git 복제에서 만든 bundle의 누락 이력을 발견하고, 완전한 이력이 있는 독립 bundle로 교체함. 최종 SHA-256은 manifest에 기록. @@ -17,6 +21,29 @@ - 최종 보드 설정: Bluetooth 출력, Bose QC Headphones. - 보스 PLAYBACK(SBC 48 kHz)과 아이폰 CAPTURE(SBC 44.1 kHz) 동시 연결 확인. - 기존 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`인 임시 연결입니다. 영구 등록 시도가 +인증 실패하여 재부팅 후 보스 등록 유지가 검증됐다고 주장하지 않습니다. +이미 영구 등록된 휴대폰 등록은 재부팅 후 유지됐습니다. ## 실제 청취로 확인한 범위 diff --git a/install.sh b/install.sh index 29d7e6f..8c1c628 100755 --- a/install.sh +++ b/install.sh @@ -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.validate(dict(name=sys.argv[2],dac_pcm=sys.argv[3],output='dac',bluetooth_device='')) 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'] assert hashlib.sha256(p.read_bytes()).hexdigest()==source['sha256'], 'Source checksum mismatch: '+p.name PY @@ -72,7 +72,7 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y \ build-essential git autoconf automake libtool pkg-config xmltoman \ libpopt-dev libconfig-dev libssl-dev libsoxr-dev libasound2-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 # Verify hardware and installed-package capabilities before switching outputs. [[ -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" + git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch" autoreconf -fi ./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \ --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" ) # 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 fi 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' radxa-audio init --name "$AIRPLAY_NAME" --dac "$DAC_PCM" radxa-audio status +bash "$PACKAGE_DIR/scripts/install-web.sh" trap - EXIT echo "설치 완료. 복구: sudo python3 $PACKAGE_DIR/scripts/backup.py restore $BACKUP_DIR" if (( WIFI5 )); then echo 'Wi-Fi 5 설정 적용을 위해 편할 때 재부팅하세요. USB 네트워크 주소 설정은 변경하지 않았습니다.'; fi diff --git a/reference/CURRENT-BOARD.md b/reference/CURRENT-BOARD.md index d0d680c..8f18f74 100644 --- a/reference/CURRENT-BOARD.md +++ b/reference/CURRENT-BOARD.md @@ -7,6 +7,9 @@ - USB DAC: Realtek USB2.0 Audio, ALSA `hw:CARD=Audio,DEV=0`. - 블루투스 출력: Bose QC Headphones (사용자 모델 QC SC). - 출력 장비 주소: `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` 설정을 재부팅 후 확인함. - USB 관리 주소 10.55.0.1/24는 별도 gadget/systemd-networkd 설정이며, 이 오디오 설치 자료가 생성하거나 변경하지 않음. @@ -14,7 +17,9 @@ 최초 AirPlay → DAC 및 AirPlay + iPhone Bluetooth → DAC 믹스 재생은 사용자가 정상 청취를 확인했습니다. Bose 헤드폰은 페어링/신뢰 등록 및 A2DP SBC 48 kHz 출력 연결과 가상 믹서 전송까지 확인했습니다. -헤드폰에서 실제 두 소리를 청취했다는 확인은 아직 받지 못했습니다. +사용자는 이후 iPhone → 보스 경로의 소리 끊김을 보고했습니다. 수신 패킷 손실과 +버퍼 오류를 확인했습니다. 500 ms 버퍼와 SBC medium 송신 설정을 적용했으나, +보스 출력 연결이 실패하여 경로 복구와 청취 검증이 필요합니다. 기존 설정 백업은 보드의 `/root/audio-mixer-backup-20261012-162401/`와 `/root/radxa-airplay2-backup-20261011-193842/`에 있습니다. diff --git a/reference/web-mixer.jpg b/reference/web-mixer.jpg new file mode 100644 index 0000000000000000000000000000000000000000..df144773559257e1dc16bfaffcf3b1d8a423427d GIT binary patch literal 58881 zcmeFZcU)83wkRA#Q4v%`q$8V75ReiO5QHtE7lDLGSDKPgLz5y_kd9O(Gz~q0Ae~SZ z1SJSa3B8E)4ho1M`c{JOv(LGE-+RvY?)TrD{Fr6T(dJmQj=5HT4E*>2I;o?ftpTE; z0fA_MKhTe1&}9(a!9#S14${#bqC0$;?g;%!ditYB=}#X&!EloGG#eZ1=`&|IxCPI0 zaPo7VIm3I7mtRO&6fDYqR{XrU$az5#Q4tCgn!|?=(;uN{rl)5X;W)z~@*jsEFF{O4 z4r(3((bDjN4lvQsGSU2a4dMcnJaB*((DaW$LwoSh0lLFSfZFRPK{T|qphI-@baV&l z>5d*a45B#zR5Q_?V*btKw3M20Y}{cMJ{hCy=;y^<(r}M^@z^70_+@{0U=@UA3JBTS zc_t*5yy}IRdfzXdV3U)-aM`D4^)(jb=M&O0_ljS2{TKo<0IfK{M9T!a1p1l>a(VnG zj{llHc=rGZbnvkj1%5zFfRY@gL?9YoAo^JW0;z&Paa8yb01Bco{E18>aEek(!+T#9 z0Mh>?0P_3j01O0Dqp}5o4jmD=3j)N^9NgFFzLp53`lL1`rih>h5ehnu7D)DI`7e{l z|Ii4eeef=TL&FP1AP`kl90*Vf2!OJu)Odjm^`TN>+WXBDxUWj7-B*`J^^dllQazvw z+7G`{sy-(HW&(F96$ekLsZtV39t65ejX|I*_q70%JO3+kGP&xdg=NHXHhutf-FV_thR&a2*8onqsSO|q+rV;q^l9*4GIXqLNQ6$Nw$%PIdgT$&w zYA$YTMVeTH{DOfcBXBlqxFBxnj3z$`61G4qA z*UMWkyIjpgEno|VCcL}WKMSis5ZNrO0-xF^7aVnW?o{3^o@1=WH$qo6Z{YB_tY;x+ zS9dg}O0){eO&7CFXGx|-avO6OjOO4KMUBC9(dXpDITSv6X6fXn*r8rUhnLnv|X$p4?|Qxx@K2c9?t&CwriW=N|M{@E2tMxSLKnK1XXh zC)(2CdI0BiB^POWBz194ar%n^awW+;!cwlyssfYa*lC<#q>yqWSI)>XFYH~Fy8fJJ zZV!osI#(uJ(Wb}Tu2Jz-%=Vsz%!Cyw;t>*`D37wc6tq6w;j!u8sp8$!b4O}U<)jxq zBijoLyi;zvc~V_jzmQfwp7Ao)IkfLOvk!~!>WrdtNnzh^l(-#VIKqT9Xsloak2b|g zaE0b4j5hcUx_&?DRvB@7EPP%JoKKLhu+sKUO(6B-V}JWpn=l^rbtKq(R$=Q&Op`b2 zy{?OO!kkNqPWbElpIvoGk09-RcH|NX_NfWxw+3F;@rdW9oQ$qff3S-+VYHm|L9jzxz6WBK z&vXw(@l^J|FBx?y$}aSBOH!Om-I@-^jqoWbB60;j&Gz@U{Q$-6{Qzw*!K!K0SC4Kj z^Tbv7xxm-Zd0>8Z`-oGoXDdbW@*F)839*-dj~vn! zi#PKno(~Oc_yHQ*`Kx7}snI;Rp{84Xvr%BTALs(7`#w zyc5JUFS}R5@mAvE&d4p4d~$0r6uSE+z9>KAQtqy2Py5;2uEp0(!wUj=m3_v|7P=PNL|4N&Hr z|2k`ffJs*1zA8`$tPwwFa?ruMKo(eWD3ja%v@Wv0{`_OXpdtX#Y3ejjSzoA-)BBU) ze!LF?;CaCR-!3k$%R7QuzTgWB%R`rZ@!9c;@oG?kNFUZ?kNDkdx=dDRwT?IL9qxWR zLATcn~83LWr9 zK0Z&}y_cL86;b2odY98z9fyegAZ({2UjeJXLY+D4kNcVf@;!n*2+dO7BK;VFry8LsI9 z<1I8CR}*`3j6CNPZ)DtNVwU*?<9`cN6lZqR!Hx~k9PY{ldLQSLbW5c>cMdx*=G;@5 z$X0@ma-21~6k(z`R9|by`8B$LYRSJ?t0=3<0o9+T{xACm;2G>MW~cwOlu=pzj6_sH zXgjVi*TWLe4fxwf)bdm(te0PkA=y}e6MmU>%;(Z9-zsCw*S9szs+0o%Uln+NqCNVF zc9uf3UpS6dpWy>z#SqCx=wOFle6lfQN7@gZ;U=AM7tmn8GXTA9Kd?{|Q4rnT{o+5x zv@15C6N9(TbbXadyXxqpd;xQLB6G#2>uZ?sz1uvtIIN;5rfRdDS2JVo%e;)H)vfI8 znwyJrPC~K#xAnYJtdbBMq*0A&=G-8U_q8pZ8~N`SZ@Y$QXLYG@A#{%NYYU;==dl5? z7{bPcB^hdM4>gFFd$pr_Th(O zUT0?Jf-h~uVe!r4jk0YYLAa#GVHoDQnWr!xAcnQyA-Qo}0k= z)cgIrRN`_AnXb&{s2nb9gNo!3sw^y&>rcN+l#R4Nv0RzRJdzKK7&kyIq#?Iy3W0o&ka%xRE^PGt8u-9$-L1qjC_%W$>B zEge`2^(Nf&!lK`+u)I$@RzF|Zyy36>`@$oQ(0O~$*x1+MRZ!0-Xs?x7O9>`-f90B& z^-pi+B;H&mmBghJ!Z{OVa^j+`a$4mI$7XPR6N+#qBSphoYsVZiyT<8?>xBeR5UAc~ zWnX-32{8)(160+r+X;RjbK=Wl!$W_tQm{CmnJYv;J31}$(+ABOcy#|f4DMRtE5Ul? zu0QL%ls&#%nfb+#OAU`1{8m(~K#710!dckT!Pmj#;RfO4*Gz*z)_IMG!`F-IyVXhB z4g*=SdV@?{dAQK384};tS@oc#_%_`S-#d-;`9G$My_1aAkl=bFU;+k9{*Fxkkz9Qbn1Q=UFf#%JlPq=DDmcrfiu6ziINj-sw@PR;PmXFA}A2=)0~ zXXFePcjH(C=M$cEPfe^jZI-{4g^6M2)@Fgu4P={=J?c7g_N+df>GlOZT+X+%n%`r0 zoQ^-b=`9g<)kt&!pWiKhwdbgzRxi0R;40bBSSi^Z8F*ygAhG7vrZ`@`j6F_`*7|jI z@5m_;E15)6k*S#U2GnYfZ0+m-`fSl~ms_c_#|4YcBB;BTChHo9&+K)ONjveviJc4y zpS2m*>qRrs#Y+Kj0xH%$FRzvqetY;sM~vazsO9LTs$mJH26%5rwmzbzW)qVaTh5|T zCsy?}V&iFM$dkt9_V-fkmEr8^p-=T4Z25}DBln2319#L<>|L(CGo{Q*HLX-IGWFfbDP*7pn@@G@m)uh(QrdkZm0AD7%4oq>AI2tRViO9{{bpx z1Ow5HA0Q7yg*~nUqdCL_3qqXrq_=Fjn4tR5GR({U_h@w}a${P+#vIHI@bgaE-MKVE zAd73XTi7Q(Z-F`wYf+p25gv}=A@=4evo+@1isS*K82AW1!n=;I!W z@2II(sPw-wVew&CE5GcOm$jk*zK-8~R!yJZUV{%SnXuxx0)ux*C2V9^*a#JGpILLW zQ6{8uG^cC!G%hcStB2$Ia625KVe{l0I-fUxfVzUlO6SUZv{#N*){KeSdzDKVinb#^ z``2dsuWaN#2d(#ik5NPqiC05iysC*)^YC509qD|0#_oV3i$sXcjh-VLR=p>3>y#Hc z)D$!2v0|l(qmg_#u&Z@shP9g8S{}wLvB%2mwwDv~e8p5>pO#Ei#~=zdd8fOj67LZ| zaiC3SPvt%5M=4GVmPQ>xe?Eux`g8;qyfJaLYU@e&;db2*!Uh)JS1ICnb^sUiydYXO_t{xLg{#k@z0O(% zJ?3G;mmO$#alu^RcCXUfK+PbD#l@i*Qdo`H6Zwq(s;?(>WkVWt%I@Pn`*^t$@_UEQ zIg^nK#RdA~c2^ixmCimGWw#RV#_5_4UuW*>Oj6gwN#!TLDau?`)@<~>F9v@CMM_kN z;@HJGjXu3m>YC?AhvHHy!8cUy5iafpaVd1L&eWa*amO8@p4a|32ksvXc_|Ch{%+~# z_K13@1$G%f*WG8J18N8F0!Y7(cEAd!3Y=E~gu&Vop2i=b+H7d4UzKw350GE8s+*Uv zhL~5A*0WNJvj@FRq&3TX@UfwhwsoEl;WMtxzKYLBWPM*Huqx9#d`G1gn2F0PMO?EN zovMyqFXte1Z1{8`lBJ^K0#7(u#i(8rS4}`%D2xfe3~TtUPjShZ`3$$8UVmPW{`NQY~5k z=($Sd_L=8gH5bZ)DzXck5^J)eAt+)sEBQT2cJkH2Tu%JpGDJT7b{Ywzn1Ii}ujA9$ zPi)`Iit1Do^BnHkhh_If{WRNa#^ zBnG!o#Az~k>JfaH;2yh|eg{7tI~K@!$pDPPdix4b%~coPd#^244u>S#1-?T=ASu(= zO~j(Tlr1#MJgQ~nAMB>_d-78{rz zDEzVmamZ8=*VfD^ADNpMW!W!x2HC)omp{#qj@BoI=HW7V0_5&B(}m4D41S^06~yGE zix?l}!zd(+B{Grb#%8dS#~z|5(Ba-`XS!1>mTl`(yeYPr^H_YJhJD*#-1DQ$!2oL9T zq6fh2bn%vE1`D_PU{Ray#T{n0c2AB(1e0%eatQm9He9@Ja37;^>9_k95 z2*PQRk@8kW8n_%I%akrS>m{ez+@krUm@$h?`6&K1{JG+BhiDEZasDYgo@GQ%O`MPr zM@y;lk#K@_BGl&WQG{{OmlGA^VcOoptM0ol+X;z4|31nKbbHV*f6~trz~ArXBthf5 zCyIZ7o@M+1ow+>G$g{ej_91hdSe31-??=~u**l_Hz zA*U(05K8ndTxLzv250OC_fqON;ck0V@EPSNO+H~=P1!@Dxzg$#hH5s3jO_?dV{R-` zXVt{A%0>t5_1pR|+GAXUp$FM+;xa-)txUd>0-M^H%u$q%aHx%i(uO%VabHz zdPE&Tec`Llcsb6ykZtd5;gCY^Nt+vW=lV#B5A$-e1I-DVnWY>GJHwj@L?#I~b9p+# zWo**9D*JYIJuayRT#IG5e6u?HDL&|XhIU1*Q(vOB506auvDEov!vZemkNBjPEz9S^ z9`-?Jtld?z_e2W4ed`!s42!?0Mw0L&QtGeg(arLgo-4xrnf)@{apl{`*7Oazq@;1# z(I}4bXXW0KV_(3ljn`RxKl2+YA8uuKs!wbeIHjM}-+GpID^`L03D>d=cP&L&pcja( z33G6Rc>2#-tSKLVe}b=T`S^I&G0|Hx#pFT%2jBfwEVGCLF3b5iM^JGJYcJ)}%9IY-~v_T?@ z*ctAEvC2xqiOelyF5k+`icx%Q($~hxnMj%_MDsbo5vzRd+5Tz{7&907nkdd0|5k~X z=gZn$wJYcw$8F5-Z5iITI@PVufvI=tW6W!Y-%g!H|Q@nk|r^+7ub{t8hp-Lp16 zcyFt|3S4#m+bA=fgI5vEz`hTSySHgLckc%%&o^dQ`_5XX;+wm5BI`CG?PA5SOQ-vh zq{Jvz?9Hiw_@y@Qp1!=E#&bKmw@XyF&-LTe>RD^7y1L=5%$7@b;3yc>lF#jYW>G=( z$Ha9qOqwV-EU0S_ixueb-Xw08HeCElyC~URs@>i7{PY(1#zL=Ec2k$)Z&5}ayVBFP z{ykTRN`HWSPW%8>vi<-a+}>IDBaY$Nm1ia`+|#0ZofAE)5fEYrlO--M89urw-J`P+ z(HwMceRH_EMma`sI?lOVDoS5(62Z;(^6a&yAE27w0n^|ccHpwspfbgjSKsf$oirX} zP5R+Ig?b$UzCAq z9m%am!a^oz7 zQ>^{_df>qJ!m@kSl4r^EpT=8<@)4Bkz})6crRM7CekJ=F#XRmLV7V~z1%th?Ig>=E zhTwpX#KCW70;e~CzG@c7=wRkfRENVylE)u(g5IcDXYg3&id}pO;{N55IJkci0JxTD zc!8DTF7Qty@bi*jf1gFU5TLpOw17Gg=oyIjv6>(?`zxaQWB%AmeZ3<(uqoRoRE z@%)u-ENh_K679WWZ2joX{0x0!}%gw6hYNXps8by^=ddqYv!A zXV!o|91jrQy#hCl)ZB1sy(NmGNo=VbcEAx57q%cZw_HvxZWCl-(O35nviCAln0vj66Z#;f zR^1(Ouur$pLFtrJzSE4Bakd={m0GBYoK3l4?fmq`plc~i-;lAST6?0UTkhqir%Zdn zQ_}58M${`wz#Ru!HYMVNp?YA^0SQG(qs!P{)_g%^MfQLf+ow$AB2wYD0ok29ko~Bd zX*uIP(Hq0W6!jR#YSO5ci?iK@mTx=qDcsy$3&YJWoW_{^1euNjE7*YLj0`xev%P1Y z4W-9ec)Re0nr7=)7@4{4oDTV(5v~x}?{R-a634>c)kG>T(6Ca0dO1Grp1)@Kkq4xng0I*MSh*EJ1 zxck|^E&`oA_?QBrJi4hkWrg&Wo=}nLvO89znK!@J)EEfRbW&1{ZUdkIIv!xAxU$Si zi7tQ?mhBg7%AyL=e0@??xhhJ$$A{!fyhn%JK`d~DSe+`slk)$oYy7;)1ImBrHS3+1 z4ew{{)X2|?9-7j%loEVpBoG(p42B@Ya2P@(4(uS4ntt_|gdk?qg*hoMiGv+U=>6O) zU9U+lsML>i$0=W|kh-{1;?}WY?dtrryj;8}+ulcMl)KzX!rQpB&^VUAtJ@*2@~l2n ze$=3K&%0Lv!U%%&rgLWZGH;dXi0F;-P9p}7-`~i=1H*9n(tMI#j;wbKJUUQIPK*~> zB@nlA@rAng>-ABDMqzn4i!jvj3y$E+H?2?{|L*0Iw@;e5gLqFPi=F5~{;;+Qtan;u zYpIKs-KSDcp?9gAby0Av{`1Xf!R*}p2k~kq;ubzfUqA~Q*UKd`Yp zWInDXdYkqYjRO;NT6k={m@)3Mw{i7d<|!p@*Tg-ZUsNN zN8J`Eg6x-LPsF{Jy&y+bS_BRoEpN$q_M-u(mw0(`!dU<_iUrhgMrZ)8<} z-E#gs*wXM)u?48{!Ml_Ma53+X3d$e>{c4Qr0ac&`v=bN-RE(p)7Jw^MI3=PIP(~3o z96Y_BQYZO8COu$isZ!y8H|@1%X5KciO0d@^TCcbjD}(L0L#}qSXt6zYwl*B96C2X? zNJp^cyJj2sIr$h8+ z`Z&KslGZ`5x$aY$(v%J5J~pQC+(&T#pAk%u>1w+D8O4WrvllxDf?vAGNLt_V zxC!V)q~zSEV0C_b&CguqaMWB=EJbqwpFp+@e^MlGdmO6UpHh5Gbtnb?32a?E8cJc{ z`uZFo!G>4AAN#Y7QXi%NV%|XjD(l?F+mH2>i-T73f6~c-KV^Kjl^D zwAU2dy_x%yyn8d}%W+NJzLb4(YEFS$(%jACnh?P5&*gn~=5Fp3CUq2cRcJ-)vayM` z^Zq>n(bxX9>{Q+qXG~$QO=0gzt54vHdr~N0h1LOxCZBS+xKAYjjP{YZUiP3K31lzo zuk0hu(4|-^2m*TSBfUA#_>(%F$_)(~nrsyX#6A+F&?tahJf_O1%qR`wx+_=p$(*8E zs}n^vRZdGkaVjqg&Kq7?fS6M8H^f~3gnXDsm!iJ{g@MOgnm2J&lTl%4(0?n{sr;Xj z|4u5!NK}<6hWSWswZ}W!|K1$3LlgV$r`k;6UQYkMnH9LDKWj_-ZyFg~b1Dyp>&!86 zg~x;;j%;j8KG*Z~rZq;KlNcKejA?itp#NLG`4d$d7%<3znQuQFJf*rnOR#8xM1BGP zocidDj|=|*X|0C!b+lUSPW=GQbFa&^c4oXY_TgsjYOxkYE#e91hsC`V!ytOG)zChF z9Hh|BmxwsF^;>`Rmk;S?3lgki8Y1>sd{&&(^};awu+A`oCyO_n>#60c{x*E1CiD%k zag=*lk0C6d-i(anKp~>?Zv^8C^TRA(BPZMoNeX^n6BYGpT<@u&%e3M8erTAc`of#b z;s=Y1A!Y@ILp2PWH!jR8UF?+?ebyb3?b6XHEg0Kd+;gP_<}3wSUnr0AG><~sb=Q6) z>6hv`!*?!0S-Pi{8P<)ZV|(#trZ0$Wy|xNnCCX4uJ_i%A&fw#>@tqq*8g~%OTTW?a zhHybIW3B1j*pik}GnRVYAzbuGSjO+u3=upbg~J*bU7tj_oY=OTm-FskiuJzQ@4($4 zHtlJbnL4z6C5fkmI(^SrJ=$ zBmdiNaP_wVJ-e*cqaR#`VbQv2_By!G>W#-jMZ@2j<2sMABp8*n3APAX*#%7Z+rmk4 zFT?VQ+?B~;gqhea(Nmw3!H)dI4 z_u6@gtx{a9&fLN#R9stTD*Xbt*p$6nZyP^y8V)z|NNer3a(_>9oFg}`J6D;KNQZ27 zxm}38LvLp@FFryku%Pp&RxG*OpjKM-CuPIs%;)p7o(0ClWjpkFq2=^b5{on&qik`X z`MgpJRjRsLvEG}^?P~6rG`1H#z3=9|6FW3;<)^X-V4C1rZkhnATP@LZ%MRaPi_})t|EzrN%H($Q&C++qi@TNI3f9+ z@QWw7({#H;J zei#93Pg+!nLLw_f$Sl%CxV7_&^hQT zw3{9`Ivf|!5j$Ul6ko1mj4iXA@yo~JybQddvT)}?9FAWAsgOrh z9}gb$ud_O6dYi5rjPXao+>2w2*RCM?h$SvOk!@X_>Z1v;k)_GQSL-!15!2iJ`EBgX=JQOgNm$$j zf2X{*L&jxZLburMV#&UR9I~7P;yr$>ctK+}$7tD2MQX?gVQS%0d5jh%7RMo4hV;L> zV8fg!{iPQ|FfC^>QP3Y=gMVllyz#szz<$I0vY`hKX&8QMUdR^gEI`7i_Zn6SjriS^ zX9er*?67-<^}^OHrt+t%dzbA!Dy;NfV73w_t>dZJdLF6%dGh^V%^&KusaN|R+rX^h zCejf2V2?jYD6$q2^A;rg{vWR^QjR%4rGg)W{;`4llO{`v4fTfKW5<~bQ?{pd@{D~} zeV7CcFV|hNjgRMgZ$G@auv{*lUkNwVzS0ZRz7}hT=KH*=KXYEX$pewip=PEpsBHCV zQTbvRZaW(v+m7~UVR6q%1=;cWlfvfn^Jg%cuk@mwrHF%CU2xzXs;)%1iNxTYTJvXK zXWGgeqgj=#>~ix1T@crqFCeiuj->2qyrAXeO#8I#9Tht&k+@l0?2Q@1JbD{tqr;!* z?)bfG{?eXZ?GI29aN|01Q&lf2n0sLnYN71F-BFQF2rMXVRvHvYwj`9s0=}Ew9X(8; zXg3GRNpxYg((FSl#D=RpPJv+fJC?x0c%BYgR?ch;llC{u3h|M!5O4)f+zYU=koFzJ zI{{p8##W;3;_1owSR<3J4yTuvj>8iDoF-$tR=2H~$DVrT=XfEfHWzUA7<@CSq#8U_ z6Kua33qI%Brj*9d!YcX*Jbk|A-AM+=?e2-vf*TU~y@hDSY&pVSH5P|$xRY21_ubL` z(0#03*#eip^5qE5`gK%~boR1;u>ytw?O;AyMZRn2Vw3fxEm&-xz=}_U%d8ik-ZWN@ zZk(15ubPGM(s)B*o8rOc%-Ukt9Z3QKiAIqm0`5k#G_GnhUl#6t8X}&DO*i)&3Xh5t z##<&sp^Z6vbGQsY)jxHB|Ml#F1<7$gfrVfS0z6dG#)!}+2Ufc#-SYvh7b zNNw!nMfUH1!T>Qmy4p#td(aZ^{$Yg;WUtuLM9rSy&<8}JTG@{mXH05Wx%(cD5}J55 zavtRa!~Rra=~M^zUAG@gn3l$PD8b7FS89<4s!mX$fYxt2 z)*}HWAGAoj0|dPG(lWJn!t{U&k*Oj9At%`xBFEWUsSE(<#6voYL<;H&D)jmRfFSTs z1Wf$D#imTMU$_G3Y)>)m-M99R+bsrM%C9Dm0#F8CX?FJd^YIirH5veRI>Hd5;T9?Z0ZKS0zpr9V6LeGZ*Ds-}GQ&hN4Q6Yqmojv_e3>X-sWvDeD)*n(-W@EE=q_S=d zq~bpW@c&sLpfRQ9C;mwQf19F8mU##l>6!6dSaif-b>nK|X*m2%>F0%SNuAZac4W<;FpG_3qs=~aY z^C2DQ_!2^mIR2)@10Zm`66hGb++@8r*wkpf)^wG(W3cIG=~E=+KErbAMaZfM1@wOebH<5pWF`$W# zO$PECpd*qZ^i_ycw34P2$pw}aSr3!Vi`@Y#TME@_cAx+dkRu*_4Pm(AfNXC1 z)?_Mic7{z#EfLRp;xpsbsyk1wHPe|ESYCSfS2WlDhQ{{4V{nxsj*qH9yf70*f!6^0 z^LDiVUf}`%pJ@KS6yj_`fo9oK+9`5q8&Kxa5~Z2XSiiUZ@8myHSW_BFe?(deFe3vL zuq)k#|BL*2X^%hn?B`DwYph~%%|Ae5{tRp%rnVHK$>{3_0)MQCAmBTPyFbUo70@Bx zzi?9m=lu+3pDX408>ykQlCKzOZ(PcVRXz?or}F_M`}Uv5)Bn#U+PIu_%foydd^J*(bj+*Q`b1e= zrpC3dlxgS=24j9Cb5KreE1Y zYb%$x7*g_xY+%6&wY8m=Hxs^E7M$jNi_OIRho8?Wf6RrJ1*I4+?OZzXfMm7|ht)U{ z@{f@p*O)@d@D@T~$mgPU-mXvQl|NR5B*njc^6*yvhv*CQ8;R@cq2tf`+UWWCc9`V3 z1E5|S;gcVjb&3d;0+qnoFEwXFvx{fQjF3%2~l!Q(X-#$$>VLOA+qt7~3Odm=|UdP{Ai5H1QAM}~;Az2t8# z6|XW?AeHFKUhQM1ni@}?k>Pov_>C;&56~$uhMejTGOHC^Lo0FJ(q3r=8iXz;W(A~# zJUC}*5?v$}rc2)oYofJmQPj+2>a>JNSCNW-r@5-6-=uDzol_Vqh|@HNSbu>TRkLLT zx3FzmEf7b-!{QB{OUafu@eBFMPv<7J&(>tzk@jCNSS`k#@2lce63><>ZKf5Q=gmt} z55JJ3jh?o&3%HsWmN*X+BUGX-Tv)6J1*RnutMf+NdtrLBxe6P+Y#@+A&)4m}tnH_c z4wo`wh77|(bU?Jc)JIGI)9DWsfYaUq;9G&8mjnO&;N`q|f()I4gkB8x`h8W--o`}s zgJ_J#0}u5Gx&@1g^UvTyM3YSGoN#Ap$)|*CIF?{&A@ek0g6YlEknbnw7Ev2D+Q@Nj zG%Ei?UP+(U+gL%U*054Zuj`b4klRNUbeZL%b3Yqu*<0GU?{{2rJI6#j2`r&94cqPL z+!J+b*mJDFX&5Ct{GO#lbZzDqsDl{E z5RHQ39Ig}wO5R+FGBq!A)0pG7dM>-Bl1_At*-8vB%-Yc8bJPC{Ro6r*oDU1WXq=(t zTDGHPVjEJTVUBdr&dE{N2N=2Isyf#ybDxnTd47P*#0fSaOu$Y9#Ii4BEK>&o2je$o z^ykMTXW7RiZv+{FW9 ziPrbIXGw@<2PhiDfF>YD~rkY$L&Hc{#Nm7M#o;_)` zr0GozNZ&Hx)X1`a&Oo7l{nhmXOjHgEzEe9F9gp5Cl&$6u4Bo_FjKazAP8p z)3KeOFFKW+wScl|2=;QzN>=Rc#LHJqK?ivM^$ zIpKTIB_|ajb`cQ6U4X^UJ6r1e#MImW1JC~25%y~;Y`S4IJGJ%XBT~i^bV!X^?BT3n z8&^R3Gy-_>@YM;Q4i@xnVF{dn9oUv8vEoseaFxsR4eb>vRd$P;w@(Fx44qX^pFI8l zs{S);2s|b%)zz2lJ$=UTMxM-L{2#_ub8S;|xf!umd3ynz~T?R?W)d z#U92}CZn&8*VF7b{lS2`n@>3@#*e&0XG*dE1ogQzgB+-j=Kobudg*^)4Lef>Rt9ut z-ElqpU_A-e)YsBO%UL4RJNj1e%^hG!{q;~2`QsLndbFp!yYL?kHBF8TDb}*p2YPlw zx$mIMN*{XIK!-E8#nwzb)`Hd0_^g7Ea1Kp5IYm>kI42escz*EcLvm-?J!|*l!_Rjh zP_LYBculKkPF{tPwya(bHn6qaH82^TR~8DQwX2&@1A_*i1S0)qS+t)UaRN;hV zm?C^vjobM>BS-gzg{i#s&*vV0l6`QDqr3yjCjqy_M>`8E9M+;+$yN7?OY*-7_6k~M zAWN9O;z}@vbmN5J9O2`l9t|E*Ea9u?%DbS*qQEi{0^ZJP3~vyYS`Zc^%w`?v$-)76 z@&D)GR(RTUoIJFf)&XuEWrWFgy)jg>-EmGNO(UcRTiWGaXO+$*-rUUTJQOvrrmQKB z;CrKM8phv$U2i65tKnD?e?JG&%6{$@Bw|6^zxEsV8?7auw%d&BB7@*IC>%=XkSyTk z7HC}1HJ#U2bHb6}@lB=F?OC;qb3uuq)zH{N^Aiilgpy0h*U-kg`J_st^LQd0LGFM^ z;susIySsYT4-Lb2{BI7^+BR8nsD2?nh0P(bN*M~BhIu`QwSAC7He=7XdOlj3=f|DcJGm$wX z1?`2)mA}C4#nb7u26ZZ^x{4RaXtjNhfm_yEA;d2p&xvD)z=vZ=OF2{Hi+T>MLX0V| z3NK;;0!yr%tF0GqAuUO)S4?CAEi}tZw7$CcA~St~PtLLs@wDR`t1rJt4miW%J`UX} zBZbag6*>p+V?}P*f|2i{)IE*3(v7jMjT`B{AAW$;m~}R6bP4#2>-DWV@vq+wgvTqL zK$i#2c=@Z&-Q84*xnDhuc%Z18-5u>8u;(%c*11}-@+@PmYzUzgU!symDkgW zx2MlLR65y#H>!uV%HxN>G7oab!wF>P@T$(F(eGs=DQjIHKdjWB@^XBjDY~J*wV7-> zZ=YLbSdLcODuVWrN1p1BwCr?#XpGkqlhp|6K@;v1R-xkhx{EgjqD4kpGK){)__R{i zftOOJELUIhTs?86d;B2$OcZxv-Jsi6%_a-&rr4?2@v?iK!ENZ;QKip4k!l`N+sLBI|?#U3X7rKmPS8{hMW$sOM4MHiF_Z z(0>jM<`NJOY5j$ZELn9fHGf_m&G&)f0Vc_c!PF(1$&?Y*E*-yO151krhb&z`o+v;G^k!VsK zUe6|uhJ#_6roG{^sMLY0g~ z5&l@QRD4a!Go+z0!sc+HwX90_@VSxjkpX-%b86y=Bx^&lSQd{{vleFZx8<3}|BiI$(%G^MW`+b19qbR+ z5Y;X8^P`bNs(~=Nv6XZhr+eC>Z}>D~$oEyw zdD;b(V3TGo+qTMk4EJw^RqscHpS+Ql3m1ULlAiV^vu=A^M_IakQam46a-;~qSmsy3 z&A(PBa3oCI^M%R;J$9PaDsJ(+m;YXPNJgVZyw&pN7{j?34bx ze&qA1Z`wiEb|M;YJXGd>Z+H}e!!dFt;dT3tOn5t!=^tYig}{F7*7h3M2`0p`I%)T= zP}U_0{5uCJPEPpBtW)&OP!X$Mq#Yz+q~ zxp6N)aakV0eSruUlzl8ISP*RUor$w#S`*5E3btDI`3>3!?M$eB13TE_c3f^b>LIIO z_!|q?!9_uRb(sRpxTfPEGuwPe&*-AOA|yM)p+Y6BtVgH}FEJLE8Bbcm3T2MsmK|MZ zMmy@`@if5`l5Hw`9_w`zf%0GYsdd&h>p>4qa4{8NT)LRq-+uN*oik!ufZG3`d&%J$_(J^a&-T~A}*Hut%j zh(7XkV11ug(}6QHlr8IdqkJ(Y{AOy5eI&a49a;i-;oBc9sdZMne?%7BGeJL$bPdxs z#8%)l#Nu?hlT4-acfwQjgi0hYNlUsiv)q!a?R64LnJ?t_hM0VG-efV+H!*=Fp36az z*$oobiCoKY&lerR=v+mtWa#&Lr#pK{;D;m19DjgTAYPUDY(erDMThzAiIfU2CNy! zIv%CpG<`fT`<=nAYEJMFCD3g zRyf?*58siTb#W8e7<_l=VxQ@-V>l+7C9I$y3e%sA z-t);kB3mfZkTes+1~X$CnAza7%RGGFN*1r>+8d$k-C%`xf*Z9l>f6Uj!4;msNqJ5o zCrsDAa!CbYHFXtxO-eQLIk`y9j$QeMkB~)jCP9iPkX<%ACNJOY1)KZ;-J|*B(y?sg z<-+L-LHAZf)Ag8SMR#-v?VS9N$g(D0fPqH;^FmW0b5?_7bl=?~E9RtGaH{Td^if>p8Qk+7|^%=aEF?6HPy z+F*H3q2O&&qt{1d;lT{e)sUsitgOBJkrTdPba&lvE;XvPB32u}7rzj5 zyHcTuvAhj&5EC>l%C;;TUxcd|qEdUU;4sp4_u4ttS6ki{)!{Cey70kG4wJ*t;km-G z9U3kIeJFAv$rZh*U~;s*z^`cjFvR2{&wxnLnK$qIrOP`WJ+T}%Gb<$rRlu3hWVqv= z!wm9vhH`597iH2c(Q{f@3c*kQW7MB_SvVc*R)0^l%K`}VQ{0k8Q&I>EZPvs+g^SstlJCZbuIJkI9 zvn1z!210RgP<`O@mxWoLQfoTq$c*Ew-aX`^%03fuB1fWBfzz?s{+wJAcJcBV91?7Y zo_6=Td+=%MEdv$aUn`9ryX=^)jZ?#6mR#Py@&g=^e)h9TR1d(fE&hMx2beUO;oC}E zbBeU@{Tf=A8^~b?0&ReYIPBklG2=9%A8`^L_VD6});>J`$h88JM+$RX5->!o&x+cmS`Fxm-wRLtZZ*er}Em zJm;dLLFj`AzC)4W{_fEzliqRj6ve*LiT7~tKo+zKtcls_qrJg^iF!X@7Qym5&yrnt z-ZXsn_v?i4zwIXfT5;fCcgS~7lY=(pD;iFH#1xI?Z1sOUq{>-%GAp;RP5|!FETDbx z6Y|{`2&O%(14%T`%S%@8IL#=c)ZX#N&E*_f%A{w)#UPDaS7w}EZ*Yigichp667+z> zcAAgQ1!nd6MdM3rPY}AtG7@)0xsYMeuD&8o=6C(YT|)Y`+TyX<6@1N>k7b_p>a}Fb z>H9Fl=YgMKPVh6Ss)o!*8WnMfX^+lY8e{6e?#0y}Zs;p$bef5onm=Lylm8G3(U)T2p9hZv4xJgNtjX$_M1)@#+ z>1=m2+4sJyai;7^gY(w4U<=od*H2R2DlK|AZgeY!r!ObOz7iK?<$BC1* zjkIPeV5sEjmVRt`yD5={00jc@%F}sT@10KXq0FuK$Sg5~SoGBjZ4M$=ekqwemKV{U z_J;m8UzJI<3#NNPXY1`;Qk-BEPj90_0hx@P%x045MuK`cZJuNcnLAJA_cpD2ri=1}##|6iOGJT8f261+5J4&UUU!L;*yJr2b zyYj>Tm?8P)_C2MIf>%KH0LP!to8t4J233&hf8r_qs~PeatA978arwDN6aCp2UefRH z(SY6CeqR8*G5=|x6-s%hyDa}Ra4|Se_-hvL?-Dm#m zNOoTf@c+E{4_vK(;s2p2>Dp!K(IOZPFD1yB_FeMVWcGZx0lZc0Hf#A?p-TN8PiEKh znKc{67HmZb$3pLIxZS7>WYe6WQJHZ;BgbPN;jdM6_R)raPqL^nqS-YZs<&hV!^|&` z_$tTzRubp3Kda+Yw6o;q^K$$h!oMlR7^ETi@KznEZB@VzxOHo4rv;kLi*L;>-~0g* zN?bzMz3bHdyzzh7`|h|Vldb=_c3iL^qBNB%p-Bk{h^P>HKp-J>lul?8njpp11qBR9 zm#zT(5b52orM2t~p|Cnv`D8_pC>;gL&*U6bnOYBtdjcDcTV#3m~NrhPQ-sh{zpr%b^ z{+vRuxkmN@1yy& zQ~~!aTB+^+`6|rg_wPD>zLo@Fm{6N|4Ubdv_*&6)lx zocrhom#@jbs2Jf>%vlHB=(-vCa&&&F3p#$LpWVBsyI0t-brx)VCHN`REQ$M@yjS={ zLyeeJb88EaSVQ^PR(!$zP7SYorcOZQ={N6heZSE7J60dS&f;!oz9gDaqp@~2JOVpu zXCG}kJ>hLl)ys}GR6wJrgzCFSQa%Bw7E|j9Spst^LfyySE4?v>ce^6hbw9~4M?u_Fxbhkj!A>b z%tM>t`)j>kIEHElGR@_{JhNtuf?_V!-|!0_g!tHLslwAZBNX*d(NhQAOPxD&^HVFD z0+L0TuXZyv$XCGi^Cv$64)IBC@-nhEVRfNc?RRo()f@wja@R))q@)k_9@Qs|4`joI zy%NZt7uwz^k4cs{$P5Wv{9xfM3NkA<&rNpWU!Pb5Ud=Z!bgWAHd_eu%@_I3e1FgHfhaso9`JnHB>jztaGmEj&cXeGubhK@i5HpT zoG%Crz^QL5z^>2F9j4p=T;G2E_SnW@T-)X#oFD>z$VPqR7f8o%@8o z@fYO~q0^SO^tnbnwY?*OBC39NH7A#~mWRt3KYZJ2T7EnIU(7E{;-pX-cikKn>q2e7u7_#(M#hrX z+I)EuMYYJ0_g?#6#`tMX@w<+Twx=)Uojn=wUX95D47p!hWh2I4)YH<68PF*z8mI^3 zu=N?nNKcr$fzfjCiGnwZxzs4<5{rT()LJ~Aolw2E_OQZC7On96S21aQA9j;*^2gb+hbPsxjh$vGU7^&c5`SaM-A-meg+Oz(EPnKqE29yi#85z!1?q;5rTkwwj@;TRH(B zS`wGf-;Pd2477;4@cWS1Tl&x_Y~s!I3Uvc>T-LR%gcazU8)9*yMWp2IUhOEKG=!^LyUkGb=nrQB-6yB1qn=mH3xdd_9^Rdw=TO+2IqxO@DmH zTF-;dVKCi!frA{88IBG-7_k5%adL-2+}-dJFg;=_QvtpcNr#gRyf?*s+M3KSLD0)c z$E4v1oYxD+P+I)fESxlxvG?{qz_KeZrsu*>1oZ?;46=t(h0g5`p#j6ISH%%dxk}RY z5@AZQBwP}4LPW4&%w)Y^2Uso#`c(wVeQViU#YeRp0UJ5E|d&^)Srg5M7&Jv zy%4xVW71+o4k4z(t14obV}{7>rTMDW-&Kxh^~?_0ob1t&dn%R0IOR}V+C$N6oypaP zwF>mLt5ZGBComXF54awF>%YHzlqIj_2baE2KH9ytas{jsW*Klpc9)3CS zs@x@40;c|eB;XJ%BQUhOB;19=)+)8&@Y5YD3yE1d$8KLSuG85!JL3?VQ1n#iKFptB znV%K1H2zW#e&Y>(jEg?T_LdiH@1FZ859|&S;4O$nFIB?dr&s6^*;5qwN*-r&FRQ7Q zvc8#}ak zGZ|PPhC|C$WJlB@NqGr=EA&rliYUJN}edqt`RlsNV$f<3jzfK#mZ~wO23E!UE z=e~k%_*FLO7XZ`?yL9-b{(HG5neCI{t^bnmJEGt7#lM5h-@NjjCm>V6kNFpW&A9Jd zh-okXm;Vm-tZ7^3+q&Hy08fJX>Cmd~T*&U?fUDL{3kWHK3^rjUm3@YH@0)n^d{^q3 zT-@z&t%vu0*r1NM@f6wnc?F$fPL0Wg&gA_yjx_@77rL{slZz#js@e0R>zN)K7o585 zyEP(%y>#CbKEyBtz!xfVKbm}ev09(qxpJX!^#WomrZjIRpnC2@%=Cs%NM2f9XUcNn zax6N_(r8O8#BN=y%YY~v%26*puyvQRUf=ft|GkAYyRM%!p7{i@n=ITW{qWb)vUENH zw7EoAo=d$hpH$639bJE&{MKTLw{SYx0KXX{@>1%DcuTBFp$;Z9EE=Z8cw{oqp+u&W zMf$@w?hk|BoaEre=+quBz-8XBD40RyA`0%MuQQEca#FDM-8bDcXTbrF9UO`=z*K)A z`htQ$yl6*Sm97V0wyyyM0$G7XHWYga~}!>l*md5c;tERXH|1TbM*tn2Ho8-2KGUd?Q$ z^+t%()#nN{xs&&$AuFi8<7||c*-rpGJao==)bm9M!RiyBs4%!*_K9uUEZ4!N<6nr~ zMcDf?j{)BbRNbbT()X3dkH*1vm)MgBvpAAjx1WDNb%`_T`~fy6Kd3m&Nx zeK*nqjc>b@>5^8LNAjjoy6r32uP_9MGKui*n!Cv{*mPs*N%E_Fwoq^&92@ z`uF@1w$WGF&uxgye9DxG5AQ%*(LH{$i_L|{M|3RWtuCH)3pEUt*jU-Wh2ApT?2leN zNed>uJ$s@5OT6#veIeLs(1v~IeAbK*Zcms_NUQQoQRI5F*n$j=I6Qygy*Vd`tcj z{YFO{Y&~V!b$n}Y;;7pSjk{oVHizvtj-xS^X_ox4VW45K$lz4$4Hx=|LiziWB-c4k zmnXC)P)+)HUV41S$NrB}uLIW8*WU#V9E5xV99K>K1h}Evba1kMgll0v*xLTT{>nB5 zPubZM`sIy-Z|{64T)R_AoxHd_`ZfIV>Xz%~s|DYH2Pz6*!`noaGyc`Yv~cvAq)H5+6T45>V+tbNl7*cF}Utf6rvQt@8wM;>y=Q*B_W>^?Fi@;K=GVp5{#g66n<1VefJwjlmZGtj|+HR5%a8JW5$`%QBz{ zVfcJ2?Wv4xPxNZ%DF9)+TL0+zJ=~%{&F>}-kDO(t9Y7KNFTq&oM94I@J2K-;-5u*4 z_9bHds(IC&{FJwly<$!BRh^X1JY&_j=ZPCPi1k)Z6~iJvE5?$R^r3qh`yyUOOoLv< zX}_6j>>6VXj{|mC{e7ALkozg@`<46o+lTp;`}@s457QgZ#lI&C_---4o*BF*PINvR z?+X^oi9R!xltQ#B@M=YAO3r!GJSJdJn%+{XacQs38DVrmrXcQZh69frvZx)Q{>%|$ z9o+#}mS1SF6RY*#!Gv~y|K+!LI@bp|I37fCCf$$I%c*peK*_8GqOYC3$Ap{9E3WaV znoQDxBNm!1-D!#M+MV3W%m*r)h-JPt8CI4Pv$^o=VE}+dMD$@7co69v`wq)d-ET#YVe~}v2bCzAsNNRB9d)9 z>x1RqQmT(yyCNCxYXge5`R0CACNi4Yq@XaQt3x3B{(6evI!lFMVNND@nE0WWBM9IO zX-Z@E8QqbG(wUq@YHx7xUvE1#T{MWde;n-QlgO5BJ5%L@f}w0KSEuDF>lxP?!HfyJ zIqZOp*N^kcU`Fjaj4nYlzT&a`4qqC)2+71L1}-Rx0{(9C`W*{kf5ul9z}2{~EPyXe z$1mNn9oqXvP37-<=nt{je;^=l;ow3NotS9Ux*_AVf>ZkZ?3y08|I|DVVo_QC0w zEwouNPQJ|RVu1gBfzjVCnYVuZ?AszEz`mXL{z;B>)ni|tA_Z@=%3#;-Tp`j|Fe%G# zMg3YPV8?)u!sS^XbR6BBJQ!To@1Y?9aR|cmxWS2Blm;M<$eH~*Y~ApYue|Oq_<$~f z_|_>dJB|R}MfB@{SlPP{Orp^^PsZ9KxWV45UA0H5TsUwau9r1)V+)o)Vsb6%VCv|y zzo#Xrt*i2Aa~F!c%0b2Q4+T2pAg5CRgTH52cknK%G;b-+O!bR7jFx zpX7`1K{#Aj$Wq_uMG6sxLQbuESO>$@9{*PPUqeFC-b@3d=m&seA3ajLp1>d>M)UH< z%Yrgbg}qPe7e%ie`>)$xz&B)V@;4yCA)t6z{uHoU2?cR@YUXyqBlMc;$VDN9VCMrm zs&?P=r#$EoxePiGU_llc*B&waayNRkyXMQ!{}&S#R%?IkzDYV_Vj&kY57(M#a}1AN zuqz*Lg);Yt*SV|>Rk`%vMSreeOGFkD<9qM$U$GT0<-^r*YsbARKk7Czqc36h6 z;Z^ga$M4EX1W3@?-{vfy`EnP%^25mgg~U~5kE%G&z)en{sh4$ex4!{Mpx?Jr|HiCk z4zW|)!;CpW_a6EuMY--@h0s8WZ9u{{@!vgn4%YxEJzaf7f1nonQEFIMaa9%Z{koQe zHqU&jxXXcoiU!P9;&6v&kd%pL@XJX!WEW@4ut|bkgj;>xVst<9x1NPGK=`_DM&C0s6VM?myFq1YJ!F+zydAs;2~zP1 z4W8;yhB|b5X*zy6OWUYy&F;=c_bbLZYkT$MF880EQ+xyFcM>b0Wt&Idt6(7cmsM%) z0_$6gTh&ZUY+gSht1Km>?T9ccEE>geoW!mKMo3+d!QZ-Y`%Q2jMM-;1<&66(@J#-x z?4+bYV*yK&+;Z2&d>a4MahRquOC*+vO?MJ- z+Nx8aRKOnrQjfJyzFOtnqyr_=+MmuzZIGe6OTVE!dfVl^b@Q2!+i4Jo@uUu#Ia@IjwK5 z)mJyHogx=|NfV|9_Rm3ef$G#q0v2AS`HI##apJ7<(f^RM3JpnW%#AdjQNmsn62^|TqRU*0p_n; z_a5Tg43R8`_PW23&S9zwS1GHkE+X^uUgGs+awWv?yBbwJULPou^@Ed{Hu_-= zdD-V^jBc~~M}u}uh4eaH?AiyO4`SJ_f@a4!XhT$Jv-D&#(`>R$3WS49Sj^4Uluu2m z(}T{#U{pJHrn{~E(@dpPb=euY$8ql!a=JovCgKjn&(gAOkmzPC9v@S$xD$Wt5B>lT zd=u^&e%mku0e=Sx+|dHqtMpZb4CwrZM&ED3w8Z*l#$G&faeL;T`W#?8_0LIjewg{W z7fBwT4_R4}(m9&WbVr747K2mv9tTV}k zh0DBh(H^*3uj$DhN*Hq*(GW%L%*zd)F#&wXHb5fpUDZQ(+?MVgPQ&m|F`sqi}7lE=$j(n#E^!#X7ZE*>@ZhHunFe% zlYc0X`f>f+EKU3`0;yj`uIFxT!?U*M?3d@BZ<-;rZ4(@ZsjmP)exG(_m}*#CRxSI4 zZ3+simmMzwZFp?V$Vh*_p|M`M0eHkbUcHy`ha$h+S$^^q$+{!rtNG=A@$=d1KZ*Zz zCnR7}0>-_AJ@kSu8VMuptZ}na!!@Nzq#|i^DlKqRB=qS%0LiuwZ1-{s#wILSXr>(gysBkdNzAJR|g%}v_$m-lw| znbW}yptBS`ex_)6+i1t0;IE9sTTG7b9pJB=+--B+pC_i*e}|*{H#7R*9{Hx02AXX6 zih%9?#@qQ)@GZexzgFN7lMBl7YwUM{?X~7_&Hc=oA9lSxhDgUWmtOI;9zi)Qd2`=tj!MsAP$)EK|Nuw+TiogeQ5{WvYtFj^z*1{*B=O% z?ooE^>zhD;s)uV~>^xiPycSI|?;q3VO;_16fE;~R?)Hdsp7%|hFCES0!g0O3t~4G! z7_DfZd{8sM{4LYjS3b6Se^*$iR-Zg4&AMFd>5yyPmWRp9It8ttm6usri{6$NzTjJz49e)yLGpr-Lt z2!snS*inpDv7}o%^utD~Yn9s}th7CbRO2C+lP2*mTB!{A_Lj-)_^bozv-xiV^dIz_ zynOHL5LBcUbyu+fL09Zyy;*DVf{+ND&ROx59|#PvoK(}6JtupaARmU?MN*Mzd;12v4X_Ul!|}QhLXUALuYzzm6e$nN(@K9KB{K%& zss7MY#Nt@r1A}<`x|4*X%L)^-jT{h7btZFYPXmdYtdItTS*~k7_E_S*hb9GhVR%pC zWlAhV9>rBgHP3fSjojPDBf<1fZQ($(#ECf~yrpz}e3p&}sybrg7@;>`-LdaqI-q)W zwWtyXwIsc7P>iVdL#7P5%%|Fuxq~HS-5G?PhpXmIYjVlN@H7VUb>VPs;c2?J+| z%+%-LuGLpEvyV)DYAlfbQ+Iakixf&hZa>>-o4)oG{ftttI*@~U zt5^<=)__l}7$#IBmriq{L>+>Of#2srj`*cR|0641`%82IbQAGADr;!M|BxW>-9#xBO(nZ*`(QDY~V*IGm(O^ZE{OR_OT$#)p931 z(=f0|x3wK|UFnQF1ebbye@-}+rz(bKD@MyTCHkRBuxBflq0GoJm;oDX zAXcY9uTW`02Ggy3yA>iKp}xG-Y|uK9SWGOfv&h8ZSF4A>_H|t5gd?~DH@O6)JSo44 z#D~L{*qp{ywRP49jt_+wKh+}7whPG-BZ}(OX#KGQG7Qy1Zzt@j`P^j50#`|NEC|8@W z5c{JD6Rt2HiGi`hvPrG!bdK|cprt8co@IH&4cllOm!h4fdd*{bH$B9np7hL`15EX9 zcF4GacMlxSv^jeu@okx(7>D88K<}<2Qc5D)Igh)!N$mWQ2T*pWP{t<0FD5)>+iwrD zi^<&J-}EmG7k=5SPp<4b^4Ry9>*0=RFr+2DCG+Q}b(KcuLT|&Ma&Ipof?4WzNA4b! z9yB$+Xg@W+p5qKD zGELvml=1L|BJTvq@NA{NZwI^AZW1lc9tz)Yn|KVP>@LnQ$e-x19AtHxbvlsYJG=$Y z@6%Ota-UgfJzXvsJCxoGhROWW!gyC{+J#H8obvm4?d)Bn)D`d4yRo`9SJojWCAL$z za#%%!-OGpijY@bO4}D@mX0(%0v$672e>oc!E*X*}BY&Kp^ThDE2dQp^(pQTB*_(`Y zgM^VFD~9FO1;2m1pzwwI*L3<~&<^I6OU9&WhUL6NyjlN7(F;$BKV3*JCUIJzPL>#J z`_$LIxm4`-AuQ|3P17M9PSX72HJ)d~)rxux=SJM#2*;XJ-Io`0>=7k0B2CXjR-i~U zf`3zZVK_nG`_f-5Mu_FY1S@xHOioaQHutzm=Y_6r0aE2KE`4CI+~>642@7|@(q`Vc z$Adt{8Lo+&f|&4g@(_bu!rc#M`uC*FHz;+vRH7ht5KpSIQ^0Bj*o#$Yb zqr~uE$|BA8Co--Si^o7weNC_hq;ycm?6m&<6a_9_WkRyH3*)qXtWi<^s=T@;PWM>p zea(`6Y1G5MOVOwq-w4UKnlR7Q@YrkaJZUD+#XAxst6FN>?hDVqpOWXa5;GqRc9**j z%@c!~9YYuOkSdVMEeP~AWJE2pg3GSB(W$hojLPm}%D9HReWDK+ZG!JP6NfHmBZEf4 zFxd_lxsV4Zw1wLr8lIKK(RNXT3B!g^3p~z`%U=#X$RUM3}|2AcZOwo!MWF%(6cY4U@ zCZTohSl7IHwayI_5f!U~VqnV}KDmBDSqQIxZ1hTrrYH2~<494CM3&SIc@j1O%CrWh zaNy^Tm4-*R#c3Np5a0F(YFDDfQaC`q_J@nuk6Q)dfQ3qE1V%yfT@{{{RcYuXpFK)q z){x!mJW||qKw5DenH|q&2}C<}RiJxvgQ6qREU93u8IISe(2NV8s164A+AXzrcGwO3 zWD+scttE8+3x)ijPqlZclAtVRn?6@d4bF3eUY7 zMi%^0@z4OUT_6r6L6Ixc-DZCZXV=6f)N$P5I{p@8`zG#)jwPzycz1 zPKsUP2)dpVZPBZPRkVufk+=f8c^nKM7 zOnnEt1t8!0OzO++bAV}^@AKCGhrhxIq_?G5PE7hIF!8ou4(DI;{;%D>omo(&C2bh|!GgD`WEDQ>M(uWQ4^(!6oV^12NCtObN{AR6~s9?(>A2 zqfF6?`>VVFjfWLS-<7>FuE|a;znwwn`u{L1>}y-0U)y>K4*b$q<1cNM*lmgZ(pJcq zww7`rF<;uM@TILUVdS*W-Spqj`nPnWF4_F9Q1mZwvc+ezMxSfs{Dk9O(&sW0zVxs7 z?x5TnL1yPmjEMwjMD4H<_1w?=GP<8@`_~yie>I8EE%}o1^C$fmWNi3S)g#+gjsINL zpP579Ppit5U4E(RzbE6rrH#zbk!}V3Xe(c)Y7wxZ5rsHeY}=ISHJGub6L_KL$3vF{ zf3MTSJO)`45;TJ}ilFEEj$Oe2(w^oB;S7fBHEA@Fo8L_>24BoikiLC=hn?8sIO3{6W!2 zgYUGAY4%~hXO{Cb;C~tvXHt;q_(I`d6a43@^XCon z^0{80Q5D7TY}MXnBHpDWa;*<)HI03d>lj$>!k%r-bR{u=cK^k%^X<2p##EyGvr!BH zeAV|b{#!;Dz-^{9a79~Z23HMkVsS)^9Sg-Cq#~&Av9lmc=uvqbU zlQf6r6mabDVMXf2R|oJeKxae_hLD$*4Xz_4y`LS`zA|v) zWQoJ&XA`K7(TVJ$FsAsIVh7lxE3r~+F;N&D_n0DGD-f(^i)?C^JWKUE8&#_i_~PpJ zg#33tzwRVJO-C> z7)Cp|bw8urOphvfZpRai6o7$X#ti#)rEx3A`sY^JOIIv`-1!tJ;WiGaP32U=^5*r8y=D`|KMq@e;0^=joN?4ki!>-NiKIcZ zljr@FHYX7^PM737N;H>C47gcdn!C94>lzl_j?`Hd_i)3d3HjkO?kjTctF+9!)Gut| z80%tvAHtgnY<^h;Tk(Bk;HJ@58e#Yr^kOsnV=_?o+{)tlq!W(=Ch(}s2{6Nb1jJJ*Thjg_2K)YE}G7SJ34Mv=c>GNI102~U1x}h_k%D#=4-4DL$%I+05F?H$ZU?I6JXq3718rk=1 zy$w?*ONO@CTzXlfY@Dllhx?>hw#Duy*E^HJi6m48J|SKVV-r0ERIfX+tdXtb zEEkszCbHEUUV0MggKq#;Ac2Uf1Ixo58K+>TP2g9`4SHUr0#vHbDv6-S&#$}Z@S!(q zL)h6g?@hkt_1gQUgRs!SYz`zr^TJ-cksHqc4R!eKA^X=)8HwdKGppS)D#sbt_rWHW zgS~0qhOl7n2Me6Mte8AJIGjMt)d`q@4x($VTSgW|#1YC&b~nPoSov*-PgOt;CO`59 zUqPyfK;OiaYb-L7X_I}F4r@vHW%St?7e%$k*Gw5y0}hnzYp?u z9k&~iP|$8UYfz%&h~cA=tmv-9MKR=f2q*G5h^En)vrG%G(n^Bm*(I6KU-;a&tro3L zv8=Au2#(ja(lrF{2A_wN{c``A8v<7}PcA@>E+<;ftta#DyGFHgskb=eA>lo7n@wwK zCNH7-+_)w`|9FUb{&_yLg|6c!f$HI9kGop0JM2nBSjC|$;bf17#Oq*bMREb3>NGszu+^phsz`aYI>LM^?~ z9%l&(v(Q5pxi}pP4jqm@R(x(?QY+3C2V{EflqOc{NimO~F7cYgs7t)I-7`HcD5!T1 z0tjjguQY_8q1wELs;{IZRtb3LRa0JGZprgubsYujG!-3&?@xp^!r+hL7qaC`w6Rkq z;)YeuxC}5a_qe+_7m=!g#VH)U}apdyY{z@AOPSFMTtmNmry&vbpWrP*GTAq2s zDrgW#oZ|Iw)nND_7NP6mYU=aOf_=42yl~r$a51K&Ry$Fapd_MV=PuJOD-d4w_$5#v zwa`(z9+l#xVPHN+DDKI^tSTOIMr}W;1vcOsW=H#a(+Oe=`P15w7B!>(Y zN|sXce%$XCDB+e#Yb7Qw=Hc=kNy_rEMY3T~vZTKUWTrs>eg+a(rRWAdsueTP(=Hbc zTR#`5dEcLN$iMbv_Y6>%SCHxWislnz0uAcsC;2Atdx#o*f*Q@b^3clJeLza5_{CtZ z8g|UVKty|3&Jc4p5+4TZnBrf6f!4uxkv$$H{Z^r%ME9Gc#9^o&&~E?Q(nLo{GPQe{ zD04{whs{f|x3f9la$o;GP?0#<;JJMIryNctS5(=+1C2f>JB&{ks9W){Zl zJKmKb8){k@YH3+d%US|mJpgn{#rU@yi^uD2E(Arx6+@`T_l+)`FcX*ZK}xqw*5bnu zc{1o_(Pul{t@KtB59SwSnoR}L-|4J)XRP8DjmX?#4z&xxyRE=t!6kqu%$;nQhK}S+3 z*%kx4@Ep7%CH=VSih&z3DGqyB(2-NZlGF|byT%3@GOu73Gv6EUk{gc#Om6)PN6|OS z^AMNfulO=h&KKSi6M72#?VR|J%G2EXsxav7++@V1Da>8%3dAC|IH+gL+mzRoh6o zwjHi0jbha2M6@N0%U9h}F~@6&cjk2sH1{J4NFW7gtYA6i#ne0+25}ND<3&xU4EM|! z<6NRqb>SAZ9f_x-`#A@3K8&%$w^PvbySr zQTWekh01$9%ceOR>V#5As}D~Ft6{P9)I1o;05QI5QIpfDAPuR%w2&xONJn;AoJJ>K zlcV;yjcY6A=SL+rpx4jla}JHKS3OZ-d-`g=8#c<2N`x+xj>-{}qz2I-_z5>$h2?XG zwU3O$ORa@K*=${REXmlUlYBzFgd!GT9H%FJqLOI_Iv_rGPf4)Hk(w8ry+-Y77KYkJ z?b@$myR?R;*brmJB$l0z92x$J+S$dDYjs0aZ47FD2KIw_#M*HU$E47%b^OfLr5&VAgf1x)6d@QR^H5fxqEvkPw{^&E=Knd%3 zXM(#Gam-BDKdJ0?<{674Zi5G?DNvXtZd~jWAXa@KGzw=4@31XN2TYDlEafg(S!Js7iyPk=^sSYb;=`I*YqJuzmMPp~kh6 zR&CoHLMqNFI|F8gi;lq7!|sh5$P_Y%_bkBW)@OE;qsYU%{Wfa}jj|jdy9j&-YIF@( z`0?W0i%$T`O{dLlvrm99Jy%Gg)ci94(U`!lAt9gqEGAO!h;+z{h6zxf*t>Dnv-5mx zrMF`=78eN>GP!S*GMKK_?x4lGtWXNhf_Q@A*Dk;W2Ql7gy* zi8|A*cKo?{MT$!t6oA)X#p~mkM}WzeBdglD|>(GE#pc}5w=x1%yVxFi1EtUXurOrTkqkCROTnq zobKIBU)&--YLxY9UWF7JsroF_R?AG5NjBoeXsJRIjnwgXC0mBQ7x!vj`8??UMb|bs z+0^Z;{}RYGu`9po$bL^5e>hvzN#vm=pd=kUX>{xy{5@Zm-6z01z`p*c)cCrr(6gzVXMq+dU3d{cx`V z0*}^m#9`|xl>!YkksU}^7smC%b>*b>`(l?Q@zSz9DMM*g&i$R;dk)FRn=*Y-v@10q z$d0I5G@TI$Bvq<+a4|7nR|sY1Yt1^V4OE@H94~&YA$;QXd)V~6(OaNbBvHC~4#t(E z2fyn4Yy>wq&Wk~qF6fZXl*S->0w~CASf*OeBy6UrZ7ukg>!sqnC&M$>>?x~N4BJ45 zLVGrE%gh9*8j3fmuE6X?x>=eASFp~yV_SD;sI6@xfJaT&0$TcNV5CADRK z6p2;8-X@N$J2y0`PVtFWAELn2$(u6~Yd@LFI=d@yz0VM0-EF1kZUysU!il1$$s!k) zQu{r$rcbmWj+rIS+e2*eRI}xHm;y${c~8xW9&^b<;`900nS0fa=&!zbjxuDTtjc)9 zpfo6?Y2SB!f`8(r?QQC{l5gA8p9vQM4i~?h;vcJg^HyKtsOFBlAmMC?@>#fU%s8Gd&0H)@5y*H4#1vmM z#LHk5NDAnO3pJfBbMx^{abW)L&J4I>fvaVjrs6c+OojlXnqU-yw;Xti)nl}BZXVH? zQQ@LP9H-9NFi|s&{M?B?(q-pd5q7BRSVCe;#G1Veip0^ht4H6=h>)9SmO&`CEjoM_ zTPvLaW-vx-MZAL>GV6Mg6SGI-G+AWw{&4iB&&Lu>g(!WFM5o3=9=OHQ|&pC zx%t3$h|xMFT1%^?FDmK`{ZFJ3GTG2Y@DUUUS zCDJ2@HkojcCkzmzs*27-{jmTO*g!xEajD~RLzXm zX*vM`JWkIC8=%vA46ENg98CbjEUz+1AX%!)N!a-k7504$J|fgWN z@)OwU)PVt)!QE;}0f+CCYe5g6gTa~}8wwAKddi}o^#dhJjProrn2cgKuL3GezFhbOkAOEBJB@ieh)cS@;adYr`>y7N|5WtLGEFnbJyTEv z=LD0%5hpvgymQi1#AC}en$|$Y3b1KvgQ84S*fPeDb3rZQA9C8idsP4Vi~pwOQZdH} z=pYc7*O$ks>Ts+oSV5AjE=K2JGz>{6YGdgTolpMY_^YGM<^Y?2M)}U?D^DiGcGE@#umdL2t zy<7pj{Bt9?>SQJxS^pxN%hAYtSZ*q3iXqOwey6#pK`xixL{GgYMV{px|LtQKu9iL&BDlpaSv)``^m(UlvgN@0+R9y!__@Z@h3wrE&f4;8tkQ*zd-C-q5L=cUKOUfBXcFu0p^!jQv6VNA8v_@~uP^+S|~X`F4eY5GHNL4+cH6`cjTalMPnio3RlYGGEK zP1IE6@0e7PE{K>JiCRmJk?>nq!HY-V9$8%xn+}pIUkFgDp1y-#K;LK6yk`$)J>xCJ%QeVwsr8m zIJk4teN%=&rAxLH)YZF!`V<28@X>7*2)ELl=b5l_(3CvP-4T(#C|?${tE|a#`Sz$+ z=D2UgpzEOi0Jr5!HKiW>;g63kBz@!An06XJPK7=?Z_3w$-rDcJ;x`Z#)|%Wu7TjR2 zbHioWKv&MNX--KyO=eSkfdXNKjn+()ltftT8`6~|3{6@YH4Ys7l&1W9(<7^xvbix| ztF<${7x~_fmF-}sOv6-PrWh$WEeXMGo^(ZhR^O=Dr7o`bKsD^6YSL$%+<}p%N4PbtTlI( zOBU`Ot&y#GSwNU(QYnqYrFKC2$axp}cJO#6PPg<;)dr+e$k=CVS-Vq}Z6Rl0&I@;G z6SY0##tl95Z@(_^iG7i<5GZE(Ox*+z@2WJ;nWgX@EcMr!mH=__TKI)I)yPw?Bnvk! z3UyUMN)A74j%yA8;_LHr)mop<=)IgjQa|YGDyL6erdfz#U1qxL9jl%5k{x@S2W z!&RMjH3hOmX4gsG$i#yD%&a)7Vt(YRj69JVb$@2o>70#;&$`#EnKt{b6GXqY~<$S^mk~xtxV0bs~HnGaF+^VbRGQ$!@{_K~o zg3M$tnEA3Ml~uKSR~Z*YXP3KGswHWV+6RddTz zAK81+lvoPzeVfj2xtEqX6ss;ylh!42j?HKTiO0kU65=;{Qv;b;2Kx0BroXK~bnwLB zGF;0;E|xktr+}z3lf}v%MQSx)yIz45<_rV|Hy67sg!dRa&+7txk{G*+LC1ux3J?0o z0D;OZ&+|Nr$2j+A261=pbxzu3D;;!-?$S2i#eTtM#zxnu-?H##AIgMQ)PkeBUMDmK zP}y&EEoIQC*Gr-Dne&(6YRV5|++K?b4SIvPBnESk9LNGj$#`>m}&9iRlkp!BwVHV<{c=O?5`NKxxME$sID_ z3->He=XJC6YI{_^Bd2PmiJAAMH}e}4VO;q{NN_)7Hi7)p(l8iTml~5u>FVT=(VuaM zoijn}P&CCjRnA0%n+tLie5G=Ibwx40%5b*CD@8+36Krn6btwSxZJrG6MX)_GSf?UuBYm>+1A^| zJibsV)cej21TOWS=xHCSvF4AEsi0^L(uEZzI$qz+ANWC(KR-yy7C1{$aBRM2c(zxP z&a}PGaRU-r1vwFV%GdxRRX+G@%L`NqLB&FsiJ}9p2!0KOF zt43B^bAp0$o0h@L9x|P|4E-R#+K$=WGGz!fp_lu<&yvQyLf6#!em%@byA-_&DIX5b zyn#m<#U{mUS0>8?1wbVKLV545j)3rVW9ZXByv0X~Om}bNv5rjFL`%aW^Jm)U?+xDN zuMXMV#57(^O3iK%a(57FN-c49e}M`YjB;E_15(soVeNXG3Jz`EkLnU;`=?h{mdoB} z$CWuYvo z=3dm%n;WOVG>wksqTAXX*C=6G2;QihQ?dXDad}C)z1rGlQD5$1~ zqj3m3->YY1vhIFTy@V6yfhp)3qZi~GG&3V1cx3@ty@NeeDbJ;xG{o?5fw%HaA+pD`2(1F1sxi>`Jf#x0y zt?S^Q``6}xhKYWiKzo4>EW9Ox-oppynSyEc00XrUL&bC$PRWM+7u3Iin(e0 zQ2^9P58eL>z}#RMumE_oT>dSr8pB`h(~=W$>%l{FFqnbS_=N2(l_T*D1h*ecsvtaF zI9xSE4w^vnl`FnrBCqHdMVnw;}_nA;V~J;k5bX|9wnopje>;{y+DV+M!Beh z6-Rv9&&WlSC8tNYI}H*(FyBPDE+lCQW^C5BN~O#cYsxR8Cl&Gd%JX4u{@%QThpkqn z_OEFNAgt$;Zce~Bq6^*px%^e0kQag>aF8KB98E%|#nXHbBzB~@xP(BXP8`=h73`JQKo@JgD@^inL{%33F}t|u(a^r_+KY(w1rKq4$n-BPkF55Lc6 z{$vli8n)ZLxcGMXnlmn+u8*lK>K1coK)3XQ49!;{2k0j1 ziR5?R&aP8#@TJ`xo~f%Fb|K`|$m}t>(s>|hubVjv)&7o@I-zd0J~UHTAX+0tdk*S~ zGc66VaBnzg7dO~yN%j*2;<-p%6Mlx4IHszF<>tfbtC7o!HDbdv9x!8?g*fS`FZ9aG zP7hz@xKs^wToDncEa8o;fI`(frE9wi59rM1!W?Fzdv(aVtdOcvh=C40Y{5HqQe)L2 zC}W_OyK++`=9bD2sw{9Fi;N^puWN|~gCL_VcU11B%9A=K%*K0^KC9SgDUw*p$`PaI zQKqa8J=Uwn0``h#@T}vCuTv5^WwlO8DX5 zTN!s5lzo^Z-~D8D>kkWCU(znaye5tgJ-^BJ;rNrLsMenA5;z<- zP@9l=*E;XSt9O?!3wx}*JXYX@;w zFeDsfBHL2F=4&qwA`sE(=|9dNdjHwI_lYjpAV=WCh7Ouzb8A^~X~^MhWNZaj(0*#H zA2SsCQxGkTzmu_XHApfi|7+xdWUY;1b#Oi0k9uIjiLG_ zM$rY+sUzk;7!nGAJ!TmhWC}@Jme#e}r{64iG z=KY31`P0Auo_Ady%}UQmw#nfu(NBmIb5lQ#o>on~ra$vYmQnrp-~Ur~z|Kd5!eK4} zu#PWweTv4*i2Wc$h#Eb^wUI&I^I-Qf2aNZvlCJn#?a!_GSYy`W(xdQSa!i|71h?Y4 zE;|6dwx(QL=15-{o2*8D05|`$M8@N;zr9vDwPBg6{5j#ql3L5Q{m@hHa^lmkKd6U9 zNJ!S}l#y?{E@Hp2Uwt}coBtsWx%Te!CGeS~mg+Z`$c#NU<1^cKA=NLgm7|XfbPP!e z=1WVf%R6BJ5k;y;cp|(=$z%y(ahWB2Ik6SyKt# zYW*qlUlhxZ*XwJ$;e z3GouEJrd{Jj)_N`go3^jZn8#JuS`es+{1#cos1NLww2fE0+H9JhPU&x)(g{l2f-!4 zS=~8CSv)b<3Nk01tk7>G9|-%p<^Ff`Ik7c{VJCqLAoD{Kk-BcL;9ehWgD=#)*Q>VT zf;`uG5NEsXq2^Yq^&w7!S=6aP8S(a=Azf#(9bfSax4lj)9d2 z_`Hb7IM7CZb6d0>B+7zQIk9h`QFHvBH^I04Rw}NEndKEZ`2)M1tZi6d_d>rY zv&i+VLy-L-49zx$W8?=IRXuw!eO_fL;+*FSX(mk9->yyt1Ra2h#n?dW^m+L`n)f!3 zQ2>z1MrYq9C{K>07sW17Wi5zOB^Dgm4P}vT(k-k9|lH~Lvpx#Cm%_9L`hv-ugXycp{RlEQU7!6EqQSV^zTQ&&a(|7YGyx=|o6~wRH z`b8|WHh3;>g^Era+d#KkeIiHtB2G?$tOHr2i4}*?dYPdJ;T_nREC-rxR@0$nCPQot zM`c%R81=vppLSKGgaO~!ojPpMlQ%h+Ca<76F+Ai172kh){;TF@ThRO+-T+G`6h-}F zcnHGs9k>{T!r&R2+y?$u-9TUUP}ezUOPXeD9n|MAf&HXvWF-7OAHjaZ0^O7&{g6OH zwGiU8h&nSB(H3=*a`b_2-Iw#=qM9tB>QrAWa)_@zct*qIuy8rKGX|i_IL{=Y7rRwQ zgroniZ9=^ zKZ~^Zb=Qk;)-V6`Y!A9^$}5^WI8n8XpTiR@euOOn6o#tOdV?P^n%{`tzIXM0RIdyH z)PK8>)R&t6Cf>dfCJJ7)X}EB-rmQ){a&*+&Aja!c|Jl-JGdOMTLxA`SG|m^J441mv zSzaU|SI#XVDwNW_`b-r(AY+J7KJ%%wgF#Kw8P$xrdS~yjo|Ys4VqYzF!qp^4Bh(n; zB&eHV&vT=$Z?11D8#YSnhnmfY9LGPc@`RTl04z+%nZMVF#Qmvj} zaUw9IS2^dbaHeAY#OT`H5qGMAzfL?h0w& zeL{6&;Y*noWx1WKcl@65^B+BsSEvyP_&BUoP1y_}p|QsIRYRahP87>L-Mc=TX2P^7 zJn-3GKVC?pa3VY4WDf3!uSTVlsBw5Z}^HSc0IYHoJtV|97h`9+vCnzb2I zow_Hpc~UcxlXpn$?q%Fh#;y=_5qAfe`%=(=HG6F^lb%Dar)vr9i_S_6`f!;uo^#TU zaO^AK?LC1VQnZhcRUnMrTo^qcoKuv$PZp$e?#z=U&@Slw9Ky}GQ`kiuKj#e9ETum(i zC7Qnx{wT;QP{hFVjSHW)YwAfH`P?DwEl~8M)&R_Mg>f6#f8R$fILJLVu(PJes~GgK zS5p?vyFPO)XKFU9uAa9jWIz?rEfninmpuiO@UrIf<(F#MC`!MbZoV00US3|QXe7l- z^vZ|mXWW#?MvOl_UB%y=|3Y$wSv-8km={FZ(Dls3w~Fq{7A@Cv2?y6mLzCuJts=jajI%TE!4h@zHtqnLo0p*56OQ9v zq_rgnr(U+-Rj&P$LM&HaNp(S(2=UC`TRS-RbG~Px;XzWO*W4&3NT0?-#IdAF(WFTE z$xFvVlFiaPFIIAS-BA?+q=soGxjJNW1m2J6lBlR~>7HMf#^JuKU)(|;3_on6p6f~^ zxg0SkXv(|O8>OcHxYk!}0yla+q(Q{-I1e%ef)0fUI1-y2+rC39| zD%ss;*Q41|aVcSpf{}2BZ#6DI`>3zY_m|4FyZrvrm^Xpvx0i?K7RhtMcn=?Q01@vpn! zJ$&Q9w^p2rE4mCWo%$mLx}-Sxu*EJ|bse?fl3Apt+m0M^CFBn^_NYeNzXPY`z3r&r zDj;x~yng2$KL3T!Q97xr`f>O7WQLs^R0W{I;`u1hwmSGw5~gVJm9*R(n5EXBzg8FG z$4=QV2DoV$%n_4nmoiR@ZfRGJRAy6`Wu`FXxgb zu5s{j5j|N{&rv(S3_y@21PX%o7HwN=Ijh|9`QDq*L61?OR(&apN+{5xJOe7IVqVTUU-iAc5aJ7ni;wTe6e9Pa;?$CHt%PN5bqvskoV&S?cGU_!vEnUk!06EDIelorqHRza!@;Zpj$ zs_&n))&w0-%@ID7+F2O2ABAJH_vSOy6fe%^!#Qb#>LGgPV4cK3e5c7qVMoPVK+ABY zxcyL=Dqq7&b+eHQMW8xz!KGLFZRcg^T7<=$i~Zf9LkBFvULlO_kmr!Xnu_#zc{I@# zT{Djb1>(TSbv&S+vtskz@_xO2 zUFmP##aZng-0*06qs-b(lgm2brYYYruP;@;qULQ_&MU6xdfIZK2*Jcl&OpoMa+ofY z(}rp&f29AUX2mt@-KZ_It=HED({nj}O>F9g<93bdHxSP>|7O6D{dZ1_xT8dQn z^8Mw>*A3D0cNYdG+K(0Mgqoq~6ILj|=8)ySbMaq~P*l_MD~vf}G(4K9(@vX(EeE(Q z9n5FpV#U^unATkLSca~W&fL0ib!^htEksQp*L1++UKCr+G}3Y(8}TFGeZ*;*tyF^4 zaMklLB~@R_ADt-C)e7xPF)hpib#aA8ED0zA8-YBqLhwq2so3YWkIfU73>OxaR5#{ex%1^#r{r5KrDawRlYmTXabx`oz{^7CUoNEUy#6p|v^c z*yZ-~&mooH%_KhaIg;LOy6;Cf8x6l9<2oG**$#gav%jV~^PR&G&bf4`?=q`dPz_F} z0_*$hAj|W#$;oaq6$6SXI_caEG4(#Gm7gE4#&K0Ay1`u*9!}w4qKf*;Z(L8;H)9tM z?<9oE7=sWi-z^xsJpegYJ%m3f=D=Y>i(eqv6p7Y3 zxTnqosmDoH(aOC6nK4o&9Qvt$JdEeqn4z&@7LC=O6jH4Lqpm=dNJ!2X~$?)RG08CK@4*h zbInV!QvU*lZF%!bi(9JtdU!Un9AYZ(WQho$uj=aF&WP{q#>bL?2_zc6ele|G#i|seU$sS?OHNx_B7lzHfM;Jht{=Yb!)Kjd{G0OUo{FPUiYR>QJ#@f z2UTD5`fWih5TAH`YwaY?EV&PXjz|YCE`)iS!D5a(3MjhN7WgP$Qyy8QeeV&L3MykK zk8C`08)d5F&Wlwz*JTmDS2NM{20>HXWo3leY$d$9JnMy69oZ|i%CRNSZj%_st^<+u~&FNvHAJE7m2E*{hc7JO*l#NdC6Rnn?GJ2rm zPmZ~WP*Fi3xK4~f#KpLb0<+%7jyFBrcn}0GD+cAK2bp}e3gy?eFTU?DS3P_NHGTmZ zqB~9D0r{h7tmdKhBe@eEf%9^VXg3(AEO?bMq3#8BV!rn10D*}E?ZqqGCSEqDFMC~I zb$j5HO{4E+@qIw?O}Z3Wq7U)@~WyJVu#SK5!JLB}u zF>aG3y5D)FW&dNxB{h&wIMtBKGaYDZqVHQEh{;Jc9zo_1P0w{1Ec-mGF(KiCl#16` zxRO`x*6kb3Oty;z@GRmGMqWCb0x;i%-J4QJ89Y`-q?B?IRmMj*C`Pcdf|6+}Ge z$h0$$K3`&xRCI2q(5&2ehc8T5jXZ7NwW677}^p|Wvo>J;NrSZhB>qKP|LfqihNwaJR0yz}fD)Nz@Z zvcnMvvxMyAqtz`IB8#pFZ9>(vApy-A>6pI_M3tw#>QreW`!Ngv%c<#H;#7a()|&1x zJ6UnmeU?emEsyEuGz3~2Z~>RE^hZa)p7JgCIGOjbH40TiD>zkm2Zx(VYhw^yIqb=- zXkJnVKp`Q)=J~+O&fNkX+LEq{3x}AyPfX>=@{wN={H+2I^%Tl<$9J%s<|itz%q$_X2Rw1Uihj z<&t}-oOS|DYd_=Tlaydoy|`rI6$Vv00Nk!p&}R}<=N?%%3|7&%eE81gq#;^cUo5rG z&KZfYGR=Wyi|jWYc+(?Nn20f(1W0;7&mJAouMBjKs$kIaV+AMX-HdXfs|i*Auyu-ZW}s4R zyymj1@9Kd9#!=!}T6b1)rY)f>g~kjzO=OfSYr?K;fg zGvm6Cc;?qW5YEYC1q?g%^c>vAntQkbAv z)+Fl5BDZsTeVTK@oagM z+iCSldPN{Qx{jQn8Q>PER+E;`HCnQX^B;PY zXQEmAMLfwqw60tiLRd51L^!^wSBte;OJdF7QW!HPEthNV^B;GIa}rJav)1s?uD23> zB6j3=wKZ9WK^B)j{-b#Ed&*=bo-$z59O{f+VWJU{?37pGlOd*8&dY$&2kx$AoaH{4 zwd+{mzl()h+@WzgHZ`Ar1^o7-znW?OKNP?JGhUGfi-YB%m4>eWnZ|#r&A;uY-B)YO zs@HP#oi4ZW_V~1424U32l(2`~sRjE8KT}TKWMWt!rhoCzXOVt>Z~MLv^pRz?Btw>s z2mHrn-w!VTs1g3-^3&hkM^g%0LW@_I3+Gw`r%Ja6n{7R_bsqDVMrHUf6I=gnQ{O{JGvy_BF7bH z=a{wj7}PK0S>QTW@p92{f|_EhnCI$%5S#Ed2p%B4biryk6p^T1;G|{yMSVGWIB9TE zd((}4!Ar@o($>1cEr2N25H))g_CPP?ufs%lo9*>Cbv1wN++7cJ=WzTw5ej8J#q~x4 z@@O<;UR5SC+vdvDLL>${<+!R-6)d*)rxiIuv&7 z>QiP+^iNOtVa7bFpaDEps8U1ggQJ63%)(ZMljJ&$!~k;15PHOFn4!*E0>l=`^1MF0 z?eMq0ma)9-y?n!(5o}pXG-iuqTej}ix_sE#wtb*>4Zf-<@dC_qvv5&%9U`dH2P+Tn zh1p6EFv`_ijw*Z*UdEpY0cWXq)>n%UjbbesX3kXCb5=#I_BWhlj6oHTwCL`4UW6ac=vD7YCS^R0e@`S! z-sO~&JT1tM)q}lRzWB0?IU&DC;qlZ;OHo38`Se_#YO(U!*9Yva)_{)c<#b5boz&P) z+B%>{VO`(1b1^S@yf3~LGBL={2_*_q{jBE(@3oAEbRa=xRb)m!rqh&=-rA+$8K2;iJ8@p^dp51GX*uyGy(v+!c}8Ze)lhxi%3PAlD7@{(k1j1{lf}!vkKJ5PqsG%QObxt66>B8gw~K>9;Y8`_)o(H`mC@B$uWUYh z;RSX)F_X^~#A$s+gpmBvK}Sf6jWBM!2cY!JCl}OIiJ+9V~;j@<_gTQdt2~MkqW&0(Ol*438@5gE_x-d2kGv=1acta{@274otD^ zvjf+SkJe6_3Z1#r<8SQA9A?=F3_H^5N!~f09=3_kN!Ptc!4^6rVL8T1X#5%VFih}1 zTDrLB&Rz7T!7NAFZ6_;?zn<7u&!Db5S@=?V(XQXkH~?r9p3ddfAwDl+65}+sJ$FuZ zvj>K=r(eD58ev{F*KJcsj>+&3Q)vKpWqa+DKyC+8TJTNnaA!Zb#H*pnURl=fA1*5aFoV@D-c z+@~p4XyiW5(_%w*yk!0gxbiBl(ba<_BLzbfkKf(MRcUxkti9`o)d;0-kpSnQP{eST zl%>UlR2b=~UF~uBR-|+Ww>uEI>{-YADjBUaY)#a?PHYaxhj(z%iesK(iq=~E)zW!n z=UGaV%=cdq)}S$9>}&jo#g^?ub-Vl9_7y1t#0cSZkTC|NHJR=|VA>#B3Z`t>uf?^B zSnUZnof51>;s~6s`#behbV$gYy)sa97?8u0SMaDV@$oTnat4<+J@9XKaD^>A+|FO& zal*x3ysFWldWdx22;Lx$d3FgZ7Na+ewsNh5>j3io(hhzVAC<07i| zQZ%r$&5DHr06qNp;fqt_Or#pqL3{Bw&l&uE1+Go~_T0!u-AiFM5rgMCT|Q6&!1A9K t6E_>o2t3jG5>?58Zg8}3-=%&OYOBHtBx4Fo|5pR`@4Ek;7Wj4SzW`8jX7K<3 literal 0 HcmV?d00001 diff --git a/scripts/backup.py b/scripts/backup.py index 4faee47..0b5b5d5 100755 --- a/scripts/backup.py +++ b/scripts/backup.py @@ -8,10 +8,18 @@ import subprocess import sys 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 = [ + '/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/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf', + '/etc/radxa-audio/input-memory.json', '/etc/radxa-audio/asound-bluetooth.conf', '/etc/systemd/system/shairport-sync.service', '/etc/systemd/system/nqptp.service', '/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf', @@ -48,9 +56,9 @@ def snapshot(directory): def restore(directory): manifest = json.loads((directory / 'manifest.json').read_text()) # 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('알 수 없는 백업 형식입니다.') - for unit in UNITS[:6]: ctl('stop', unit) + for unit in UNITS[:7]: ctl('stop', unit) for name, existed in manifest['files'].items(): target = Path(name) if existed: @@ -60,11 +68,12 @@ def restore(directory): else: target.unlink(missing_ok=True) 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) # Bluetooth and Avahi may have been started by apt during installation. 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) if state['active'] and result.returncode: print(result.stdout, file=sys.stderr) diff --git a/scripts/check-build.sh b/scripts/check-build.sh index 71863f4..acb5d26 100755 --- a/scripts/check-build.sh +++ b/scripts/check-build.sh @@ -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" + git apply "$PACKAGE_DIR/sources/shairport-native-volume.patch" autoreconf -fi ./configure --prefix=/usr/local --sysconfdir=/etc --with-alsa --with-soxr \ --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 ./shairport-sync -V ) diff --git a/scripts/input_controls.py b/scripts/input_controls.py new file mode 100644 index 0000000..0ae4547 --- /dev/null +++ b/scripts/input_controls.py @@ -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} diff --git a/scripts/install-web.sh b/scripts/install-web.sh new file mode 100755 index 0000000..8e4b3d5 --- /dev/null +++ b/scripts/install-web.sh @@ -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' diff --git a/scripts/radxa-audio.py b/scripts/radxa-audio.py index aaad4d4..240e22c 100755 --- a/scripts/radxa-audio.py +++ b/scripts/radxa-audio.py @@ -66,7 +66,7 @@ def save(path, text, mode=0o644): 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 {{ type dmix ipc_key 553001 @@ -87,7 +87,7 @@ pcm.radxa_dmix {{ pcm.radxa_mix {{ type plug 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] 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] Requires=bluealsa.service @@ -143,7 +143,7 @@ StartLimitIntervalSec=0 User=shairport-sync Group=audio ExecStartPre=-/usr/bin/bluetoothctl --timeout 8 connect {addr} a2dp-sink -ExecStart=/usr/bin/alsaloop -C hw:Loopback,1,0 -P bluealsa:DEV={addr},PROFILE=a2dp -c 2 -r 48000 -f S16_LE -t 200000 -S 4 -A 2 +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 RestartSec=5 TimeoutStartSec=30 @@ -199,12 +199,16 @@ def check_dac(pcm): raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.") -def apply(c, initial=False): +def apply(c, initial=False, reconfigure=False): root() 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"]) - if c['output'] == 'bluetooth': + if c['output'] == 'bluetooth' and not reconfigure: # Preflight before changing a working DAC route. run('modprobe', 'snd-aloop') result = run('bluetoothctl', '--timeout', '10', 'connect', @@ -229,6 +233,8 @@ def apply(c, initial=False): run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp', 'shairport-sync', 'bluealsa', 'bluealsa-aplay') run('systemctl', 'restart', 'bluetooth') + if run('systemctl', 'cat', 'aic-bluetooth.service', check=False, capture=True).returncode == 0: + run('systemctl', 'restart', 'aic-bluetooth') run('systemctl', 'start', 'avahi-daemon', 'nqptp') run('systemctl', 'restart', 'bluealsa') if c['output'] == 'bluetooth': @@ -238,12 +244,12 @@ def apply(c, initial=False): run('systemctl', 'disable', BT_SERVICE, check=False, capture=True) run('systemctl', 'start', 'shairport-sync', 'bluealsa-aplay') required = ['shairport-sync', 'bluealsa-aplay'] - if c['output'] == 'bluetooth': + if c['output'] == 'bluetooth' and not reconfigure: required.append(BT_SERVICE) if not all(active(u) for u in required): raise ValueError('서비스 시작에 실패했습니다.') alias = c['name'] + ' Bluetooth' - run('bluetoothctl', 'system-alias', alias) + run('bluetoothctl', 'system-alias', alias, check=not reconfigure, capture=reconfigure) print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm']) except Exception: run('systemctl', 'stop', *units, check=False) @@ -257,12 +263,12 @@ def apply(c, initial=False): raise -def pair(addr, output=False): +def pair(addr, output=False, bond=False): root() if not MAC_RE.fullmatch(addr): raise ValueError('장비 주소 형식이 잘못됐습니다.') info = run('bluetoothctl', 'info', addr, check=False, capture=True).stdout - if 'Paired: yes' not in info: + if 'Paired: yes' not in info or (bond and 'Bonded: yes' not in info): # Outgoing pairing agent lives only for this operation. controller = run('bluetoothctl', 'show', capture=True).stdout was_pairable = 'Pairable: yes' in controller @@ -275,6 +281,8 @@ def pair(addr, output=False): info = run('bluetoothctl', 'info', addr, capture=True).stdout if 'Paired: yes' not in info: raise ValueError('페어링되지 않았습니다. 장비를 페어링 모드로 켜 주세요.') + if bond and 'Bonded: yes' not in info: + raise ValueError('영구 등록되지 않았습니다. 장비를 페어링 모드로 다시 켠 뒤 재시도해 주세요.') run('bluetoothctl', 'trust', addr) run('bluetoothctl', '--timeout', '10', 'connect', addr, 'a2dp-sink' if output else 'a2dp-source', check=False) @@ -292,16 +300,27 @@ def main(): sub.add_parser('status') sub.add_parser('devices') q = sub.add_parser('scan'); q.add_argument('--seconds', type=int, default=30) - q = sub.add_parser('pair'); q.add_argument('address'); q.add_argument('--output', action='store_true') + q = 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('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('dac'); q.add_argument('pcm') sub.add_parser('apply') q = sub.add_parser('init'); q.add_argument('--name', required=True); q.add_argument('--dac', default='hw:CARD=Audio,DEV=0') q.add_argument('--adopt', action='store_true'); q.add_argument('--address', default='') q.add_argument('--output', choices=['dac', 'bluetooth'], default='dac') + sub.add_parser('inputs') + q = sub.add_parser('volume'); q.add_argument('input'); q.add_argument('percent', type=int) + q = sub.add_parser('mute'); q.add_argument('input'); q.add_argument('state', choices=['on', 'off']) a = p.parse_args() + if a.cmd in ('inputs', 'volume', 'mute'): + sys.path.insert(0, str(Path(__file__).parent)) + sys.path.insert(0, '/usr/local/lib/radxa-mixer') + import input_controls + if a.cmd == 'inputs': print(json.dumps(input_controls.inputs(), ensure_ascii=False, indent=2)) + elif a.cmd == 'volume': root(); input_controls.set_input(a.input, volume=a.percent) + else: root(); input_controls.set_input(a.input, mute=a.state == 'on') + return if a.cmd == 'devices': run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l') elif a.cmd == 'status': @@ -318,7 +337,7 @@ def main(): run('bluetoothctl', 'scan', 'off', check=False) run('bluetoothctl', 'devices') elif a.cmd == 'pair': - pair(a.address, a.output) + pair(a.address, a.output, a.bond) elif a.cmd == 'pair-input': root() if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초') @@ -338,12 +357,15 @@ def main(): c = load() if a.cmd == 'output': c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac' + if a.pcm: + if c['output'] != 'dac': raise ValueError('--pcm은 DAC 출력에서만 지정합니다.') + c['dac_pcm'] = a.pcm if a.address: if c['output'] != 'bluetooth': raise ValueError('장비 주소는 bluetooth 출력에서만 지정합니다.') c['bluetooth_device'] = a.address.upper() elif a.cmd == 'name': c['name'] = a.name elif a.cmd == 'dac': c['dac_pcm'] = a.pcm - apply(c) + apply(c, reconfigure=a.cmd == 'apply') def interactive_pair(seconds): @@ -381,7 +403,15 @@ def interactive_pair(seconds): if __name__ == '__main__': 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: print('오류:', e, file=sys.stderr) sys.exit(1) diff --git a/scripts/web/app.js b/scripts/web/app.js new file mode 100644 index 0000000..ae4840e --- /dev/null +++ b/scripts/web/app.js @@ -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); diff --git a/scripts/web/index.html b/scripts/web/index.html new file mode 100644 index 0000000..0658f0d --- /dev/null +++ b/scripts/web/index.html @@ -0,0 +1,6 @@ +Radxa Mixer +
WIRELESS AUDIO

Radxa Mixer

연결 중…

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

입력 믹서

입력별 볼륨 · 음소거

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

+

출력 장비

확인 중

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

+
RADXA MIXER 공통 감쇠 −3 dB · 스테레오
diff --git a/scripts/web/style.css b/scripts/web/style.css new file mode 100644 index 0000000..967251f --- /dev/null +++ b/scripts/web/style.css @@ -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}} diff --git a/scripts/web_server.py b/scripts/web_server.py new file mode 100755 index 0000000..cf1962b --- /dev/null +++ b/scripts/web_server.py @@ -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= -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); diff --git a/tests/test_manager.py b/tests/test_manager.py index d128d64..f63dcff 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -40,7 +40,7 @@ class ManagerTests(unittest.TestCase): 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.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']) def test_output_device_is_not_hardcoded(self): @@ -51,6 +51,8 @@ class ManagerTests(unittest.TestCase): self.assertNotIn('E4:58',unit) self.assertIn('Loopback', 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): 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.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): c=self.config() with patch.object(m, 'root'), patch.object(m, 'run') as run, \ diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..a01c608 --- /dev/null +++ b/tests/test_web.py @@ -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()