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:
jsahn
2026-10-12 21:54:38 +09:00
co-authored by Claude Opus 5.5
parent 5e8906b43d
commit 7fa372e801
5 changed files with 63 additions and 8 deletions
+1
View File
@@ -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번 포트입니다.
+7
View File
@@ -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`로 갱신하세요.
+25 -5
View File
@@ -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()))
+14
View File
@@ -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
View File
@@ -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):