Add web mixer with native input volume and output selection

This commit is contained in:
jsahn
2026-10-12 18:45:43 +09:00
parent ceef64dfed
commit 5fbece12bb
19 changed files with 696 additions and 34 deletions
+25 -1
View File
@@ -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, \
+80
View File
@@ -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()