Fix empty Bluetooth lists in web mixer; advertise it over Bonjour
busctl cannot convert some BlueZ advertisement data (Apple
ManufacturerData, a{qv}) to JSON, so GetManagedObjects failed as soon as
an iPad was paired and the web showed no devices at all. Read only the
needed Device1/Adapter1 properties per object instead. Also publish the
web mixer as an _http._tcp Avahi service.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐
|
|||||||
설치 시 보드의 호스트 이름을 `radxa-mixer`로 바꾸며(`--hostname 이름`으로 변경,
|
설치 시 보드의 호스트 이름을 `radxa-mixer`로 바꾸며(`--hostname 이름`으로 변경,
|
||||||
`--keep-hostname`으로 유지), Avahi(mDNS)가 `이름.local`을 알립니다.
|
`--keep-hostname`으로 유지), Avahi(mDNS)가 `이름.local`을 알립니다.
|
||||||
`.local`이 안 열리는 기기에서는 `http://보드IP`를 사용합니다.
|
`.local`이 안 열리는 기기에서는 `http://보드IP`를 사용합니다.
|
||||||
|
웹 믹서는 Bonjour 서비스(`_http._tcp`, `Radxa Mixer (호스트이름)`)로도 광고됩니다.
|
||||||
현재 보드는 `http://192.168.10.83`이며, USB 관리 연결에서는
|
현재 보드는 `http://192.168.10.83`이며, USB 관리 연결에서는
|
||||||
주소가 유지돼 있다면 `http://10.55.0.1`을 사용합니다. 웹은 80번 포트입니다.
|
주소가 유지돼 있다면 `http://10.55.0.1`을 사용합니다. 웹은 80번 포트입니다.
|
||||||
|
|
||||||
|
|||||||
@@ -131,3 +131,10 @@ SBC medium 적용 후에는 500 ms 버퍼가 필요 없어 믹서(dmix), Bluetoo
|
|||||||
`/sys/class/bluetooth/hci0`는 있어도 `bluetoothctl list`가 비는 경우를 확인했습니다.
|
`/sys/class/bluetooth/hci0`는 있어도 `bluetoothctl list`가 비는 경우를 확인했습니다.
|
||||||
`hciconfig -a`, `btmgmt info`, `dmesg`로 확인하고, Bluetooth 초기화 서비스로
|
`hciconfig -a`, `btmgmt info`, `dmesg`로 확인하고, Bluetooth 초기화 서비스로
|
||||||
풀리지 않으면 재부팅 후 다시 확인합니다. 페어링 기록을 먼저 삭제하지 마세요.
|
풀리지 않으면 재부팅 후 다시 확인합니다. 페어링 기록을 먼저 삭제하지 마세요.
|
||||||
|
|
||||||
|
## 웹의 출력 목록·검색 결과가 비어 있음
|
||||||
|
|
||||||
|
`/api/state`의 `issues`에 `Failed to create new json object`가 보이면 BlueZ 기기
|
||||||
|
속성을 JSON으로 변환하지 못한 것입니다. Apple 기기의 광고 데이터(ManufacturerData)가
|
||||||
|
원인이었으며, 현재 버전은 필요한 속성만 읽어 이 문제를 피합니다. 오래된 웹 파일이
|
||||||
|
설치돼 있다면 `sudo bash scripts/install-web.sh`로 갱신하세요.
|
||||||
|
|||||||
@@ -35,6 +35,22 @@ def objects(service, path):
|
|||||||
for p, interfaces in raw.items()}
|
for p, interfaces in raw.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def properties(service, path, interface, names):
|
||||||
|
"""Read selected properties only.
|
||||||
|
|
||||||
|
GetManagedObjects cannot be used for BlueZ: busctl fails to convert some
|
||||||
|
advertisement data (e.g. Apple ManufacturerData a{qv}) to JSON, which
|
||||||
|
would hide every device.
|
||||||
|
"""
|
||||||
|
out = command('busctl', '--system', '--json=short', 'get-property', service, path, interface, *names)
|
||||||
|
return {name: json.loads(line)['data'] for name, line in zip(names, out.splitlines())}
|
||||||
|
|
||||||
|
|
||||||
|
def bluez_paths(pattern):
|
||||||
|
return [p for p in command('busctl', '--system', 'tree', '--list', 'org.bluez').split()
|
||||||
|
if re.fullmatch(pattern, p)]
|
||||||
|
|
||||||
|
|
||||||
def pcms():
|
def pcms():
|
||||||
return {p: interfaces['org.bluealsa.PCM1'] for p, interfaces in objects('org.bluealsa', '/org/bluealsa').items()
|
return {p: interfaces['org.bluealsa.PCM1'] for p, interfaces in objects('org.bluealsa', '/org/bluealsa').items()
|
||||||
if 'org.bluealsa.PCM1' in interfaces}
|
if 'org.bluealsa.PCM1' in interfaces}
|
||||||
@@ -141,9 +157,13 @@ def set_input(key, volume=None, mute=None):
|
|||||||
|
|
||||||
def devices():
|
def devices():
|
||||||
result = []
|
result = []
|
||||||
for path, interfaces in objects('org.bluez', '/').items():
|
base = ['Address', 'Alias', 'Paired', 'Bonded', 'Connected']
|
||||||
props = interfaces.get('org.bluez.Device1')
|
for path in bluez_paths(r'/org/bluez/hci\d+/dev_[0-9A-F_]{17}'):
|
||||||
if not props: continue
|
try:
|
||||||
|
try: props = properties('org.bluez', path, 'org.bluez.Device1', base + ['UUIDs'])
|
||||||
|
# UUIDs is absent until services are known; devices may also vanish mid-read.
|
||||||
|
except ValueError: props = properties('org.bluez', path, 'org.bluez.Device1', base)
|
||||||
|
except ValueError: continue
|
||||||
result.append({'address': props['Address'],'name': props.get('Alias',props['Address']),
|
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')),
|
'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',[])})
|
'output': '0000110b-0000-1000-8000-00805f9b34fb' in props.get('UUIDs',[])})
|
||||||
@@ -151,8 +171,8 @@ def devices():
|
|||||||
|
|
||||||
|
|
||||||
def pairing():
|
def pairing():
|
||||||
adapter = next((i['org.bluez.Adapter1'] for i in objects('org.bluez', '/').values()
|
paths = bluez_paths(r'/org/bluez/hci\d+')
|
||||||
if 'org.bluez.Adapter1' in i), {})
|
adapter = properties('org.bluez', paths[0], 'org.bluez.Adapter1', ['Discoverable', 'Alias']) if paths else {}
|
||||||
on = bool(adapter.get('Discoverable'))
|
on = bool(adapter.get('Discoverable'))
|
||||||
remaining = 0
|
remaining = 0
|
||||||
try: remaining = max(0, round(float(PAIRING_UNTIL.read_text()) - time.time()))
|
try: remaining = max(0, round(float(PAIRING_UNTIL.read_text()) - time.time()))
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ NoNewPrivileges=true
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
UNIT
|
UNIT
|
||||||
|
# Advertise the web mixer over Bonjour/mDNS (_http._tcp) next to HOSTNAME.local.
|
||||||
|
install -d -m 755 /etc/avahi/services
|
||||||
|
cat > /etc/avahi/services/radxa-mixer.service <<'AVAHI'
|
||||||
|
<?xml version="1.0" standalone='no'?>
|
||||||
|
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||||
|
<service-group>
|
||||||
|
<name replace-wildcards="yes">Radxa Mixer (%h)</name>
|
||||||
|
<service>
|
||||||
|
<type>_http._tcp</type>
|
||||||
|
<port>80</port>
|
||||||
|
<txt-record>path=/</txt-record>
|
||||||
|
</service>
|
||||||
|
</service-group>
|
||||||
|
AVAHI
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now radxa-mixer-web
|
systemctl enable --now radxa-mixer-web
|
||||||
systemctl restart radxa-mixer-web
|
systemctl restart radxa-mixer-web
|
||||||
|
|||||||
+16
-3
@@ -58,13 +58,26 @@ class WebTests(unittest.TestCase):
|
|||||||
{'action':'discoverable','on':'yes','seconds':300},{'action':'discoverable','on':True,'seconds':True}]:
|
{'action':'discoverable','on':'yes','seconds':300},{'action':'discoverable','on':True,'seconds':True}]:
|
||||||
with self.subTest(data=data), self.assertRaises(ValueError): w.validate_action(data)
|
with self.subTest(data=data), self.assertRaises(ValueError): w.validate_action(data)
|
||||||
|
|
||||||
|
def test_devices_skip_unreadable_and_tolerate_missing_uuids(self):
|
||||||
|
out={'/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF':'{"type":"s","data":"AA:BB:CC:DD:EE:FF"}\n{"type":"s","data":"QCY"}\n'
|
||||||
|
'{"type":"b","data":true}\n{"type":"b","data":true}\n{"type":"b","data":false}'}
|
||||||
|
def command(*args):
|
||||||
|
if 'tree' in args: return '/org/bluez\n/org/bluez/hci0\n/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF\n/org/bluez/hci0/dev_11_22_33_44_55_66\n'
|
||||||
|
path=args[5]
|
||||||
|
if path not in out or 'UUIDs' in args: raise ValueError('No such property')
|
||||||
|
return out[path]
|
||||||
|
with patch.object(a,'command',side_effect=command):
|
||||||
|
self.assertEqual(a.devices(),[{'address':'AA:BB:CC:DD:EE:FF','name':'QCY','bonded':True,'paired':True,
|
||||||
|
'connected':False,'output':False}])
|
||||||
|
|
||||||
def test_pairing_state_reports_remaining_only_when_discoverable(self):
|
def test_pairing_state_reports_remaining_only_when_discoverable(self):
|
||||||
adapter={'/org/bluez/hci0':{'org.bluez.Adapter1':{'Discoverable':True,'Alias':'Radxa Audio Bluetooth'}}}
|
adapter={'Discoverable':True,'Alias':'Radxa Audio Bluetooth'}
|
||||||
with patch.object(a,'objects',return_value=adapter), patch.object(a,'PAIRING_UNTIL') as until, \
|
with patch.object(a,'bluez_paths',return_value=['/org/bluez/hci0']), \
|
||||||
|
patch.object(a,'properties',return_value=adapter), patch.object(a,'PAIRING_UNTIL') as until, \
|
||||||
patch.object(a.time,'time',return_value=1000.0):
|
patch.object(a.time,'time',return_value=1000.0):
|
||||||
until.read_text.return_value='1120.0'
|
until.read_text.return_value='1120.0'
|
||||||
self.assertEqual(a.pairing(),{'discoverable':True,'remaining':120,'alias':'Radxa Audio Bluetooth'})
|
self.assertEqual(a.pairing(),{'discoverable':True,'remaining':120,'alias':'Radxa Audio Bluetooth'})
|
||||||
adapter['/org/bluez/hci0']['org.bluez.Adapter1']['Discoverable']=False
|
adapter['Discoverable']=False
|
||||||
self.assertEqual(a.pairing()['remaining'],0)
|
self.assertEqual(a.pairing()['remaining'],0)
|
||||||
|
|
||||||
def test_dac_selection_is_one_atomic_cli_command(self):
|
def test_dac_selection_is_one_atomic_cli_command(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user