Add reproducible Radxa AirPlay and Bluetooth mixer setup
This commit is contained in:
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Snapshot/restore mixer-managed files and service states, not apt packages."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay',
|
||||
'radxa-bt-output', 'radxa-bose-output', 'avahi-daemon', 'bluetooth']
|
||||
PATHS = [
|
||||
'/etc/asound.conf', '/etc/shairport-sync.conf',
|
||||
'/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf',
|
||||
'/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',
|
||||
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf',
|
||||
'/etc/systemd/system/radxa-bt-output.service',
|
||||
'/etc/modules-load.d/radxa-audio-loopback.conf',
|
||||
'/etc/modprobe.d/radxa-audio-loopback.conf',
|
||||
'/etc/modprobe.d/90-radxa-airplay-wifi.conf',
|
||||
'/usr/local/bin/shairport-sync', '/usr/local/bin/nqptp',
|
||||
'/usr/local/bin/radxa-audio', '/usr/local/bin/radxa-audio-output',
|
||||
]
|
||||
|
||||
|
||||
def ctl(*args):
|
||||
return subprocess.run(['systemctl', *args], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
|
||||
|
||||
def snapshot(directory):
|
||||
directory.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||||
files = {}
|
||||
for name in PATHS:
|
||||
path = Path(name)
|
||||
files[name] = os.path.lexists(path)
|
||||
if files[name]:
|
||||
target = directory / 'files' / name.lstrip('/')
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target, follow_symlinks=False)
|
||||
states = {u: {'active': ctl('is-active', '--quiet', u).returncode == 0,
|
||||
'enabled': ctl('is-enabled', '--quiet', u).returncode == 0} for u in UNITS}
|
||||
(directory / 'manifest.json').write_text(json.dumps({'files': files, 'units': states}, indent=2))
|
||||
|
||||
|
||||
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):
|
||||
raise ValueError('알 수 없는 백업 형식입니다.')
|
||||
for unit in UNITS[:6]: ctl('stop', unit)
|
||||
for name, existed in manifest['files'].items():
|
||||
target = Path(name)
|
||||
if existed:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.unlink(missing_ok=True)
|
||||
shutil.copy2(directory / 'files' / name.lstrip('/'), target, follow_symlinks=False)
|
||||
else:
|
||||
target.unlink(missing_ok=True)
|
||||
ctl('daemon-reload')
|
||||
for unit, state in manifest['units'].items():
|
||||
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]
|
||||
result = ctl('restart' if state['active'] else 'stop', unit)
|
||||
if state['active'] and result.returncode:
|
||||
print(result.stdout, file=sys.stderr)
|
||||
raise ValueError('서비스 복원에 실패했습니다: ' + unit)
|
||||
print('복구 완료:', directory)
|
||||
print('apt로 설치한 의존 패키지는 유지합니다. 블루투스 페어링 기록은 삭제하지 않습니다.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if os.geteuid() != 0: sys.exit('sudo로 실행해 주세요.')
|
||||
if len(sys.argv) != 3 or sys.argv[1] not in ('save', 'restore'):
|
||||
sys.exit('Usage: backup.py save|restore DIRECTORY')
|
||||
if sys.argv[1] == 'save': snapshot(Path(sys.argv[2]))
|
||||
else: restore(Path(sys.argv[2]))
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Compile the pinned sources without installing or stopping audio services.
|
||||
set -euo pipefail
|
||||
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
BUILD_DIR=$(mktemp -d /tmp/radxa-build-check.XXXXXX)
|
||||
echo "Build check directory: $BUILD_DIR"
|
||||
git clone -q --branch 1.2.8 "$PACKAGE_DIR/sources/nqptp-1.2.8.bundle" "$BUILD_DIR/nqptp"
|
||||
git clone -q --branch 5.5.2 "$PACKAGE_DIR/sources/shairport-sync-5.5.2.bundle" "$BUILD_DIR/shairport-sync"
|
||||
(
|
||||
cd "$BUILD_DIR/nqptp"
|
||||
autoreconf -fi
|
||||
./configure --prefix=/usr/local --with-systemd-startup --with-systemdsystemunitdir=/etc/systemd/system
|
||||
make -j 1
|
||||
./nqptp -V
|
||||
)
|
||||
(
|
||||
cd "$BUILD_DIR/shairport-sync"
|
||||
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
|
||||
make -j 1
|
||||
./shairport-sync -V
|
||||
)
|
||||
echo "Sources compiled successfully; no installed services were modified. Build tree: $BUILD_DIR"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
exec /usr/local/bin/radxa-audio output "$@"
|
||||
Executable
+390
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Radxa audio mixer manager. Debian 13 / systemd; Python standard library only."""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
CONFIG = Path('/etc/radxa-audio/mixer.json')
|
||||
BT_SERVICE = 'radxa-bt-output.service'
|
||||
MAC_RE = re.compile(r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\Z')
|
||||
PCM_RE = re.compile(r'hw:CARD=[A-Za-z0-9_-]+,DEV=[0-9]+(?:,SUBDEV=[0-9]+)?\Z')
|
||||
|
||||
|
||||
def run(*args, check=True, capture=False, timeout=40):
|
||||
return subprocess.run(args, check=check, text=True,
|
||||
stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.STDOUT if capture else None, timeout=timeout)
|
||||
|
||||
|
||||
def root():
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError('sudo로 실행해 주세요.')
|
||||
|
||||
|
||||
def validate(c):
|
||||
if not isinstance(c.get('name'), str) or not c['name'].strip() or len(c['name'].encode()) > 80:
|
||||
raise ValueError('AirPlay 이름은 1~80바이트여야 합니다.')
|
||||
if any(ord(ch) < 32 for ch in c['name']):
|
||||
raise ValueError('이름에는 제어 문자를 사용할 수 없습니다.')
|
||||
if not PCM_RE.fullmatch(c.get('dac_pcm', '')):
|
||||
raise ValueError('DAC 형식 예: hw:CARD=Audio,DEV=0')
|
||||
if c.get('output') not in ('dac', 'bluetooth'):
|
||||
raise ValueError('output은 dac 또는 bluetooth여야 합니다.')
|
||||
addr = c.get('bluetooth_device', '')
|
||||
if addr and not MAC_RE.fullmatch(addr):
|
||||
raise ValueError('블루투스 주소 형식이 잘못됐습니다.')
|
||||
if c['output'] == 'bluetooth' and not addr:
|
||||
raise ValueError('먼저 블루투스 출력 장비 주소를 설정해 주세요.')
|
||||
return c
|
||||
|
||||
|
||||
def load():
|
||||
if not CONFIG.exists():
|
||||
raise ValueError('설치되지 않았습니다. install.sh를 먼저 실행해 주세요.')
|
||||
return validate(json.loads(CONFIG.read_text()))
|
||||
|
||||
|
||||
def save(path, text, mode=0o644):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp = tempfile.mkstemp(dir=path.parent, prefix='.radxa-')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(text)
|
||||
os.chmod(temp, mode)
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
if os.path.exists(temp):
|
||||
os.unlink(temp)
|
||||
|
||||
|
||||
def mixer(pcm, rate):
|
||||
return f'''# Managed by radxa-audio; each input has 6 dB of headroom.
|
||||
pcm.radxa_dmix {{
|
||||
type dmix
|
||||
ipc_key 553001
|
||||
ipc_key_add_uid false
|
||||
ipc_perm 0660
|
||||
ipc_gid audio
|
||||
slave {{
|
||||
pcm "{pcm}"
|
||||
format S16_LE
|
||||
rate {rate}
|
||||
channels 2
|
||||
period_time 0
|
||||
period_size {rate // 100}
|
||||
buffer_size {rate // 2}
|
||||
}}
|
||||
bindings {{ 0 0 1 1 }}
|
||||
}}
|
||||
pcm.radxa_mix {{
|
||||
type plug
|
||||
slave.pcm "radxa_dmix"
|
||||
ttable {{ 0.0 0.5 1.1 0.5 }}
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
def templates(c):
|
||||
validate(c)
|
||||
dac = mixer(c['dac_pcm'], 44100)
|
||||
bt = mixer('hw:CARD=Loopback,DEV=0,SUBDEV=0', 48000)
|
||||
files = {
|
||||
'/etc/radxa-audio/asound-dac.conf': dac,
|
||||
'/etc/radxa-audio/asound-bluetooth.conf': bt,
|
||||
'/etc/asound.conf': dac if c['output'] == 'dac' else bt,
|
||||
str(CONFIG): json.dumps(c, ensure_ascii=False, indent=2) + '\n',
|
||||
'/etc/shairport-sync.conf': f'''general = {{
|
||||
name = {json.dumps(c['name'], ensure_ascii=False)};
|
||||
service_type = "airplay2";
|
||||
output_backend = "alsa";
|
||||
audio_backend_buffer_desired_length_in_seconds = 0.5;
|
||||
}};
|
||||
alsa = {{
|
||||
output_device = "radxa_mix";
|
||||
output_format = "S16_LE";
|
||||
}};
|
||||
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
|
||||
''',
|
||||
'/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf': '''[Unit]
|
||||
Requires=bluealsa.service
|
||||
After=bluealsa.service sound.target
|
||||
[Service]
|
||||
User=shairport-sync
|
||||
Group=audio
|
||||
PrivateUsers=false
|
||||
RemoveIPC=false
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/bluealsa-aplay -S --pcm=radxa_mix --volume=software --pcm-period-time=10000 --pcm-buffer-time=500000
|
||||
''',
|
||||
'/etc/modules-load.d/radxa-audio-loopback.conf': 'snd-aloop\n',
|
||||
'/etc/modprobe.d/radxa-audio-loopback.conf': 'options snd-aloop pcm_substreams=1\n',
|
||||
}
|
||||
if c.get('bluetooth_device'):
|
||||
addr = c['bluetooth_device'].upper()
|
||||
files['/etc/systemd/system/' + BT_SERVICE] = f'''[Unit]
|
||||
Description=Radxa mixed audio to Bluetooth headphones or speaker
|
||||
Requires=bluealsa.service
|
||||
After=bluealsa.service sound.target systemd-modules-load.service
|
||||
PartOf=bluealsa.service
|
||||
StartLimitIntervalSec=0
|
||||
[Service]
|
||||
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
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=30
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
'''
|
||||
return files
|
||||
|
||||
|
||||
def snapshot(paths):
|
||||
stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')
|
||||
directory = Path('/var/backups/radxa-audio') / stamp
|
||||
directory.mkdir(parents=True, mode=0o700)
|
||||
records = {}
|
||||
for name in paths:
|
||||
path = Path(name)
|
||||
records[name] = os.path.lexists(path)
|
||||
if records[name]:
|
||||
dest = directory / name.lstrip('/')
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dest, follow_symlinks=False)
|
||||
save(directory / 'files.json', json.dumps(records, indent=2) + '\n', 0o600)
|
||||
return directory
|
||||
|
||||
|
||||
def restore_files(directory):
|
||||
records = json.loads((directory / 'files.json').read_text())
|
||||
for name, existed in records.items():
|
||||
path = Path(name)
|
||||
if existed:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.unlink(missing_ok=True)
|
||||
shutil.copy2(directory / name.lstrip('/'), path, follow_symlinks=False)
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def active(unit):
|
||||
return run('systemctl', 'is-active', '--quiet', unit, check=False, capture=True).returncode == 0
|
||||
|
||||
|
||||
def enabled(unit):
|
||||
return run('systemctl', 'is-enabled', '--quiet', unit, check=False, capture=True).returncode == 0
|
||||
|
||||
|
||||
def restart_inputs():
|
||||
run('systemctl', 'restart', 'shairport-sync', 'bluealsa-aplay')
|
||||
|
||||
|
||||
def check_dac(pcm):
|
||||
parts = dict(item.split("=", 1) for item in pcm.removeprefix("hw:").split(","))
|
||||
if not (Path("/proc/asound") / parts["CARD"] / ("pcm" + parts["DEV"] + "p")).exists():
|
||||
raise ValueError("DAC 출력 장치가 없습니다. aplay -l에서 카드 ID를 확인해 주세요.")
|
||||
|
||||
|
||||
def apply(c, initial=False):
|
||||
root()
|
||||
files = templates(c)
|
||||
if c["output"] == "dac":
|
||||
check_dac(c["dac_pcm"])
|
||||
if c['output'] == 'bluetooth':
|
||||
# Preflight before changing a working DAC route.
|
||||
run('modprobe', 'snd-aloop')
|
||||
result = run('bluetoothctl', '--timeout', '10', 'connect',
|
||||
c['bluetooth_device'], 'a2dp-sink', check=False, capture=True)
|
||||
pcm_list = run('bluealsa-aplay', '-l', capture=True).stdout
|
||||
playback = pcm_list.split('**** List of CAPTURE')[0]
|
||||
if c['bluetooth_device'].upper() not in playback.upper():
|
||||
raise ValueError('출력 장비의 A2DP 연결이 없습니다. 전원을 켜고 pair 명령을 먼저 실행해 주세요.\n' + result.stdout)
|
||||
units = ['shairport-sync', 'bluealsa-aplay', BT_SERVICE, 'radxa-bose-output.service']
|
||||
states = {u: (active(u), enabled(u)) for u in units}
|
||||
backup = snapshot(files)
|
||||
print('설정 백업:', backup, flush=True)
|
||||
try:
|
||||
stopping = [u for u in units if states[u][0]]
|
||||
if stopping:
|
||||
run('systemctl', 'stop', *stopping)
|
||||
for path, text in files.items():
|
||||
save(path, text)
|
||||
run('systemctl', 'daemon-reload')
|
||||
run('systemctl', 'disable', 'radxa-bose-output.service', check=False, capture=True)
|
||||
if initial:
|
||||
run('systemctl', 'enable', 'avahi-daemon', 'bluetooth', 'nqptp',
|
||||
'shairport-sync', 'bluealsa', 'bluealsa-aplay')
|
||||
run('systemctl', 'restart', 'bluetooth')
|
||||
run('systemctl', 'start', 'avahi-daemon', 'nqptp')
|
||||
run('systemctl', 'restart', 'bluealsa')
|
||||
if c['output'] == 'bluetooth':
|
||||
run('systemctl', 'enable', BT_SERVICE)
|
||||
run('systemctl', 'start', BT_SERVICE)
|
||||
else:
|
||||
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':
|
||||
required.append(BT_SERVICE)
|
||||
if not all(active(u) for u in required):
|
||||
raise ValueError('서비스 시작에 실패했습니다.')
|
||||
alias = c['name'] + ' Bluetooth'
|
||||
run('bluetoothctl', 'system-alias', alias)
|
||||
print('출력:', c['output'], c.get('bluetooth_device', '') if c['output'] == 'bluetooth' else c['dac_pcm'])
|
||||
except Exception:
|
||||
run('systemctl', 'stop', *units, check=False)
|
||||
restore_files(backup)
|
||||
run('systemctl', 'daemon-reload', check=False)
|
||||
for u, (was_active, was_enabled) in states.items():
|
||||
run('systemctl', 'enable' if was_enabled else 'disable', u, check=False, capture=True)
|
||||
if was_active:
|
||||
run('systemctl', 'start', u, check=False)
|
||||
print('설정을 이전 상태로 복구했습니다.', file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def pair(addr, output=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:
|
||||
# Outgoing pairing agent lives only for this operation.
|
||||
controller = run('bluetoothctl', 'show', capture=True).stdout
|
||||
was_pairable = 'Pairable: yes' in controller
|
||||
run('bluetoothctl', 'pairable', 'on')
|
||||
try:
|
||||
run('bluetoothctl', '--agent', 'NoInputNoOutput', '--timeout', '25', 'pair', addr, check=False)
|
||||
finally:
|
||||
if not was_pairable:
|
||||
run('bluetoothctl', 'pairable', 'off', check=False)
|
||||
info = run('bluetoothctl', 'info', addr, capture=True).stdout
|
||||
if 'Paired: 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)
|
||||
if output:
|
||||
c = load()
|
||||
c['bluetooth_device'] = addr.upper()
|
||||
c['output'] = 'bluetooth'
|
||||
apply(c)
|
||||
print('페어링/신뢰 등록 완료:', addr)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='Radxa AirPlay + Bluetooth 믹서 관리')
|
||||
sub = p.add_subparsers(dest='cmd', required=True)
|
||||
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-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('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')
|
||||
a = p.parse_args()
|
||||
if a.cmd == 'devices':
|
||||
run('bluetoothctl', 'devices'); run('aplay', '-l'); run('bluealsa-aplay', '-l')
|
||||
elif a.cmd == 'status':
|
||||
print(json.dumps(load(), ensure_ascii=False, indent=2), flush=True)
|
||||
run('systemctl', 'is-active', 'shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay', BT_SERVICE, check=False)
|
||||
run('bluealsa-aplay', '-l')
|
||||
elif a.cmd == 'scan':
|
||||
root()
|
||||
if not 5 <= a.seconds <= 120: raise ValueError('검색 시간: 5~120초')
|
||||
# Keep the discovery client alive; stop discovery on timeout/interrupt.
|
||||
try:
|
||||
run('bluetoothctl', '--timeout', str(a.seconds), 'scan', 'bredr', check=False, timeout=a.seconds + 10)
|
||||
finally:
|
||||
run('bluetoothctl', 'scan', 'off', check=False)
|
||||
run('bluetoothctl', 'devices')
|
||||
elif a.cmd == 'pair':
|
||||
pair(a.address, a.output)
|
||||
elif a.cmd == 'pair-input':
|
||||
root()
|
||||
if not 15 <= a.seconds <= 900: raise ValueError('페어링 시간: 15~900초')
|
||||
run('bluetoothctl', 'discoverable-timeout', str(a.seconds))
|
||||
# Interactive agent: approve the intended phone's service with "yes".
|
||||
print('휴대폰에서 연결하세요. 서비스 승인 질문에는 yes를 입력하고, 완료 후 quit을 입력하세요.', flush=True)
|
||||
try:
|
||||
# Relay a PTY so service approval answers still come from the operator.
|
||||
interactive_pair(a.seconds)
|
||||
finally:
|
||||
run('bluetoothctl', 'discoverable', 'off', check=False)
|
||||
run('bluetoothctl', 'pairable', 'off', check=False)
|
||||
print('서비스를 승인한 휴대폰은 sudo bluetoothctl trust 주소 로 신뢰 등록해 주세요.')
|
||||
elif a.cmd == 'init':
|
||||
apply(dict(name=a.name, dac_pcm=a.dac, output=a.output, bluetooth_device=a.address), initial=not a.adopt)
|
||||
else:
|
||||
c = load()
|
||||
if a.cmd == 'output':
|
||||
c['output'] = 'bluetooth' if a.mode in ('bluetooth', 'bose') else 'dac'
|
||||
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)
|
||||
|
||||
|
||||
def interactive_pair(seconds):
|
||||
import pty
|
||||
import select
|
||||
import time
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvp('bluetoothctl', ['bluetoothctl', '--agent', 'NoInputNoOutput'])
|
||||
end = time.monotonic() + seconds
|
||||
# Do not send asynchronous agent registration commands in a single batch.
|
||||
queue = ['default-agent', 'pairable on', 'discoverable on']
|
||||
next_command = time.monotonic() + 1.5
|
||||
try:
|
||||
while time.monotonic() < end:
|
||||
ready, _, _ = select.select([fd, sys.stdin], [], [], 0.25)
|
||||
if fd in ready:
|
||||
try: data = os.read(fd, 8192)
|
||||
except OSError: break
|
||||
if not data: break
|
||||
os.write(sys.stdout.fileno(), data)
|
||||
if sys.stdin in ready:
|
||||
data = os.read(sys.stdin.fileno(), 8192)
|
||||
if not data: break
|
||||
os.write(fd, data)
|
||||
if queue and time.monotonic() >= next_command:
|
||||
os.write(fd, (queue.pop(0) + '\n').encode())
|
||||
next_command = time.monotonic() + 1.5
|
||||
finally:
|
||||
try: os.write(fd, b'quit\n')
|
||||
except OSError: pass
|
||||
os.close(fd)
|
||||
os.waitpid(pid, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
|
||||
print('오류:', e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print('\n중단했습니다.', file=sys.stderr)
|
||||
sys.exit(130)
|
||||
Reference in New Issue
Block a user