From 7fa372e801dc0b559bdcee10ca2ad88cbfe93af0 Mon Sep 17 00:00:00 2001 From: jsahn Date: Mon, 12 Oct 2026 21:54:38 +0900 Subject: [PATCH] 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 --- README.md | 1 + TROUBLESHOOTING.md | 7 +++++++ scripts/input_controls.py | 30 +++++++++++++++++++++++++----- scripts/install-web.sh | 14 ++++++++++++++ tests/test_web.py | 19 ++++++++++++++++--- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4ba209d..d2b60b1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Mac/iPhone의 AirPlay 2 ── Shairport Sync ─┐ 설치 시 보드의 호스트 이름을 `radxa-mixer`로 바꾸며(`--hostname 이름`으로 변경, `--keep-hostname`으로 유지), Avahi(mDNS)가 `이름.local`을 알립니다. `.local`이 안 열리는 기기에서는 `http://보드IP`를 사용합니다. +웹 믹서는 Bonjour 서비스(`_http._tcp`, `Radxa Mixer (호스트이름)`)로도 광고됩니다. 현재 보드는 `http://192.168.10.83`이며, USB 관리 연결에서는 주소가 유지돼 있다면 `http://10.55.0.1`을 사용합니다. 웹은 80번 포트입니다. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 5025616..1cbf19f 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -131,3 +131,10 @@ SBC medium 적용 후에는 500 ms 버퍼가 필요 없어 믹서(dmix), Bluetoo `/sys/class/bluetooth/hci0`는 있어도 `bluetoothctl list`가 비는 경우를 확인했습니다. `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`로 갱신하세요. diff --git a/scripts/input_controls.py b/scripts/input_controls.py index d8de1aa..aefb238 100644 --- a/scripts/input_controls.py +++ b/scripts/input_controls.py @@ -35,6 +35,22 @@ def objects(service, path): 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(): return {p: interfaces['org.bluealsa.PCM1'] for p, interfaces in objects('org.bluealsa', '/org/bluealsa').items() if 'org.bluealsa.PCM1' in interfaces} @@ -141,9 +157,13 @@ def set_input(key, volume=None, mute=None): def devices(): result = [] - for path, interfaces in objects('org.bluez', '/').items(): - props = interfaces.get('org.bluez.Device1') - if not props: continue + base = ['Address', 'Alias', 'Paired', 'Bonded', 'Connected'] + for path in bluez_paths(r'/org/bluez/hci\d+/dev_[0-9A-F_]{17}'): + 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']), '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',[])}) @@ -151,8 +171,8 @@ def devices(): def pairing(): - adapter = next((i['org.bluez.Adapter1'] for i in objects('org.bluez', '/').values() - if 'org.bluez.Adapter1' in i), {}) + paths = bluez_paths(r'/org/bluez/hci\d+') + adapter = properties('org.bluez', paths[0], 'org.bluez.Adapter1', ['Discoverable', 'Alias']) if paths else {} on = bool(adapter.get('Discoverable')) remaining = 0 try: remaining = max(0, round(float(PAIRING_UNTIL.read_text()) - time.time())) diff --git a/scripts/install-web.sh b/scripts/install-web.sh index e5c0244..9ac41ae 100755 --- a/scripts/install-web.sh +++ b/scripts/install-web.sh @@ -22,6 +22,20 @@ NoNewPrivileges=true [Install] WantedBy=multi-user.target 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' + + + + Radxa Mixer (%h) + + _http._tcp + 80 + path=/ + + +AVAHI systemctl daemon-reload systemctl enable --now radxa-mixer-web systemctl restart radxa-mixer-web diff --git a/tests/test_web.py b/tests/test_web.py index 174e062..417c5a7 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -58,13 +58,26 @@ class WebTests(unittest.TestCase): {'action':'discoverable','on':'yes','seconds':300},{'action':'discoverable','on':True,'seconds':True}]: 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): - adapter={'/org/bluez/hci0':{'org.bluez.Adapter1':{'Discoverable':True,'Alias':'Radxa Audio Bluetooth'}}} - with patch.object(a,'objects',return_value=adapter), patch.object(a,'PAIRING_UNTIL') as until, \ + adapter={'Discoverable':True,'Alias':'Radxa Audio Bluetooth'} + 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): until.read_text.return_value='1120.0' 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) def test_dac_selection_is_one_atomic_cli_command(self):