mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
355 lines
15 KiB
Python
355 lines
15 KiB
Python
import json
|
|
import stat
|
|
import struct
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from openpilot.starpilot.galaxy.hotspot import DEFAULT, HotspotSettings, atomic_json, hotspot_name, validate_config
|
|
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces, attribute, attributes
|
|
from openpilot.starpilot.galaxy.hotspot_worker import (HOLD, STANDALONE_SETTLE_S, HotspotBackend, HotspotController, choose_channel,
|
|
hostapd_config, nm_activating, overlapping_routes, radio_channel)
|
|
|
|
CONFIG = {'enabled': True, 'ssid': 'TheGalaxy-6b86', 'password': '1234567890123456'}
|
|
DONGLE = 'b0c4a280b2f96b86'
|
|
|
|
|
|
def backend_mock(ready=True, ap_channel=None):
|
|
backend = Mock()
|
|
backend.alive.return_value = True
|
|
backend.ap_status.return_value = {'ready': ready, 'channel': ap_channel}
|
|
backend.activating.return_value = False
|
|
backend.connected = set()
|
|
return backend
|
|
|
|
|
|
@pytest.mark.parametrize('change', [{'enabled': 1}, {'ssid': 'bad\nssid'}, {'password': 'short'},
|
|
{'password': 'has spaces in it'}, {'extra': True}, {'password': 'é' * 12}])
|
|
def test_strict_settings(change):
|
|
with pytest.raises(ValueError):
|
|
validate_config({**CONFIG, **change})
|
|
|
|
|
|
def test_settings_revision_permissions_and_credentials(tmp_path):
|
|
settings = HotspotSettings(tmp_path, tmp_path / 'status', clock=lambda: 100, dongle_id=lambda: DONGLE)
|
|
assert settings.read() == {**DEFAULT, 'ssid': 'TheGalaxy-6b86'}
|
|
initial = settings.snapshot(True)
|
|
payload = {'revision': initial['revision'], 'config': {**initial['config'], 'enabled': True}}
|
|
with pytest.raises(PermissionError):
|
|
settings.save(payload, lambda: False)
|
|
assert not settings.path.exists()
|
|
saved = settings.save(payload, lambda: True)
|
|
assert len(saved['config']['password']) == 24
|
|
assert stat.S_IMODE(settings.path.stat().st_mode) == 0o600
|
|
with pytest.raises(FileExistsError):
|
|
settings.save(payload, lambda: True)
|
|
unchanged = settings.save({'revision': saved['revision'], 'config': {**saved['config'], 'password': ''}}, lambda: True)
|
|
assert unchanged['config'] == saved['config']
|
|
runtime = {'revision': saved['revision'], 'updated': 99, 'active': True, 'state': 'active', 'reason': '', 'channel': 149}
|
|
atomic_json(settings.status_path, runtime)
|
|
assert settings.snapshot(False)['active']
|
|
assert not settings.snapshot(False)['editable']
|
|
for updates in ({'updated': 80}, {'updated': 101}, {'revision': 'wrong'}):
|
|
atomic_json(settings.status_path, {**runtime, **updates})
|
|
assert settings.snapshot(True)['state'] == 'unavailable'
|
|
|
|
|
|
def test_settings_reject_symlink_and_oversize(tmp_path):
|
|
settings = HotspotSettings(tmp_path, tmp_path / 'status', dongle_id=lambda: DONGLE)
|
|
target = tmp_path / 'elsewhere'
|
|
target.write_text(json.dumps(CONFIG))
|
|
settings.path.symlink_to(target)
|
|
with pytest.raises(OSError):
|
|
settings.read()
|
|
settings.path.unlink()
|
|
settings.path.write_text(' ' * 4097)
|
|
with pytest.raises(ValueError):
|
|
settings.read()
|
|
|
|
|
|
def test_fixed_name_migration_password_and_restart(tmp_path):
|
|
settings = HotspotSettings(tmp_path, dongle_id=lambda: DONGLE)
|
|
atomic_json(settings.path, {**CONFIG, 'ssid': 'Old custom name'})
|
|
assert settings.read() == CONFIG
|
|
current = settings.snapshot(True)
|
|
with pytest.raises(ValueError, match='fixed'):
|
|
settings.save({'revision': current['revision'], 'config': {**CONFIG, 'ssid': 'Other name'}}, lambda: True)
|
|
custom = {**CONFIG, 'password': 'my-new-password-123'}
|
|
settings.save({'revision': current['revision'], 'config': custom}, lambda: True)
|
|
restarted = HotspotSettings(tmp_path, dongle_id=lambda: DONGLE)
|
|
assert restarted.read() == custom
|
|
assert hotspot_name(DONGLE.upper()) == 'TheGalaxy-6b86'
|
|
|
|
|
|
@pytest.mark.parametrize('dongle', [None, '', 'UnregisteredDevice', '1234', 'x' * 16, b'0123456789abcdef'])
|
|
def test_unregistered_name_is_not_invented(dongle):
|
|
with pytest.raises(ValueError, match='dongle ID'):
|
|
hotspot_name(dongle)
|
|
|
|
|
|
@pytest.mark.parametrize('status,expected', [('wpa_state=COMPLETED\nfreq=5745', ('a', 149)),
|
|
('wpa_state=COMPLETED\nfreq=2412', ('g', 1)), ('wpa_state=DISCONNECTED\nfreq=5745', ('g', 6)),
|
|
('wpa_state=INACTIVE', ('g', 6)), ('wpa_state=SCANNING', ('g', 6)),
|
|
('wpa_state=ASSOCIATING', HOLD), ('wpa_state=4WAY_HANDSHAKE\nfreq=5745', HOLD),
|
|
('wpa_state=INTERFACE_DISABLED', None), ('FAIL', None),
|
|
('wpa_state=COMPLETED\nfreq=6000', None)])
|
|
def test_channel(status, expected):
|
|
assert radio_channel(status) == expected
|
|
|
|
|
|
def test_automatic_standalone_connected_and_back():
|
|
now = [100.]
|
|
backend = backend_mock()
|
|
controller = HotspotController(backend, clock=lambda: now[0])
|
|
for status, expected in [('DISCONNECTED', ('g', 6)), ('COMPLETED\nfreq=5745', ('a', 149)), ('DISCONNECTED', ('g', 6))]:
|
|
backend.channel.return_value = radio_channel('wpa_state=' + status)
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
backend.start.assert_called_with(CONFIG, expected)
|
|
assert controller.tick(CONFIG)['active']
|
|
now[0] += STANDALONE_SETTLE_S + 1
|
|
assert backend.start.call_count == 3
|
|
assert controller.tick({**CONFIG, 'enabled': False})['state'] == 'disabled'
|
|
|
|
|
|
@pytest.mark.parametrize('results,expected', [
|
|
([('a', 149)], ('a', 149)), ([('g', 6)], ('g', 6)), ([None], None), ([HOLD, ('a', 149)], HOLD),
|
|
([('a', 149), HOLD], HOLD), ([('g', 1), ('a', 149)], ('g', 1)), ([('g', 6), ('a', 149)], ('a', 149)),
|
|
([None, ('a', 149)], ('a', 149)), ([('g', 6), None], ('g', 6))])
|
|
def test_choose_channel(results, expected):
|
|
assert choose_channel(results) == expected
|
|
|
|
|
|
@pytest.mark.parametrize('state,expected', [('30 (disconnected)', False), ('50 (connecting (configuring))', True),
|
|
('60 (connecting (need authentication))', True), ('100 (connected)', False), ('', False), ('junk', False)])
|
|
def test_nm_activating(state, expected):
|
|
assert nm_activating(state) == expected
|
|
|
|
|
|
def test_join_leaves_running_ap_alone():
|
|
now = [100.]
|
|
backend = backend_mock(ap_channel=1)
|
|
controller = HotspotController(backend, clock=lambda: now[0])
|
|
backend.channel.return_value = ('g', 1)
|
|
controller.tick(CONFIG)
|
|
assert controller.tick(CONFIG)['active']
|
|
backend.start.reset_mock()
|
|
backend.stop.reset_mock()
|
|
# Android Auto drops home Wi-Fi, scans, associates and joins the car on channel 149.
|
|
for channel in (('g', 6), HOLD, ('g', 6), HOLD):
|
|
backend.channel.return_value = channel
|
|
now[0] += 2
|
|
assert controller.tick(CONFIG)['active']
|
|
backend.stop.assert_not_called()
|
|
backend.start.assert_not_called()
|
|
backend.ap_status.return_value = {'ready': True, 'channel': 149}
|
|
backend.channel.return_value = ('a', 149)
|
|
assert controller.tick(CONFIG)['channel'] == 149
|
|
backend.stop.assert_not_called()
|
|
backend.start.assert_not_called()
|
|
# Only a station gone for good moves the AP to its standalone channel.
|
|
backend.channel.return_value = ('g', 6)
|
|
now[0] += STANDALONE_SETTLE_S - 1
|
|
assert controller.tick(CONFIG)['active']
|
|
backend.start.assert_not_called()
|
|
now[0] += 2
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
backend.start.assert_called_once_with(CONFIG, ('g', 6))
|
|
|
|
|
|
def test_hold_does_not_start_ap():
|
|
backend = backend_mock()
|
|
backend.channel.return_value = HOLD
|
|
controller = HotspotController(backend, clock=lambda: 100)
|
|
assert controller.tick(CONFIG)['state'] == 'waiting'
|
|
backend.start.assert_not_called()
|
|
backend.stop.assert_not_called()
|
|
|
|
|
|
def test_backend_channel_reports_idle_stations_and_asks_networkmanager_only_for_them(tmp_path):
|
|
def command(args, **kwargs):
|
|
if args[0] == 'nmcli':
|
|
return Mock(returncode=0, stdout='50 (connecting (configuring))\n')
|
|
return Mock(returncode=0, stdout='wpa_state=DISCONNECTED\n')
|
|
run = Mock(side_effect=command)
|
|
backend = HotspotBackend(tmp_path, run=run, interfaces=Mock())
|
|
assert backend.channel() == ('g', 6) and backend.idle == ['wlan0']
|
|
assert all(call.args[0][0] != 'nmcli' for call in run.call_args_list)
|
|
assert backend.activating()
|
|
run.side_effect = lambda args, **kwargs: Mock(returncode=0, stdout='wpa_state=COMPLETED\nfreq=5745')
|
|
assert backend.channel() == ('a', 149) and backend.connected == {149}
|
|
assert not backend.activating()
|
|
assert all(call.args[0][-1] in ('status', 'wlan0') for call in run.call_args_list)
|
|
|
|
|
|
def test_backend_skips_car_station_without_status(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(Path, 'exists', lambda self: True)
|
|
def command(args, **kwargs):
|
|
if args[2] == 'aa0':
|
|
assert kwargs['check'] is False
|
|
return Mock(returncode=255, stdout='Failed to connect to non-global ctrl_ifname: aa0')
|
|
return Mock(returncode=0, stdout='wpa_state=COMPLETED\nfreq=2412')
|
|
backend = HotspotBackend(tmp_path, run=Mock(side_effect=command), interfaces=Mock())
|
|
assert backend.channel() == ('g', 1)
|
|
|
|
|
|
def test_networkmanager_join_holds_the_running_ap():
|
|
now = [100.]
|
|
backend = backend_mock(ap_channel=1)
|
|
controller = HotspotController(backend, clock=lambda: now[0])
|
|
backend.channel.return_value = ('g', 1)
|
|
controller.tick(CONFIG)
|
|
backend.start.reset_mock()
|
|
backend.channel.return_value = ('g', 6)
|
|
now[0] += STANDALONE_SETTLE_S + 1
|
|
backend.activating.return_value = True
|
|
assert controller.tick(CONFIG)['active']
|
|
backend.start.assert_not_called()
|
|
backend.activating.reset_mock()
|
|
backend.channel.return_value = ('g', 1)
|
|
backend.activating.return_value = False
|
|
controller.tick(CONFIG)
|
|
backend.activating.assert_not_called() # nothing to change, so nothing to ask
|
|
|
|
|
|
def test_ap_moved_by_the_driver_to_another_station_stays_there():
|
|
backend = backend_mock(ap_channel=36)
|
|
controller = HotspotController(backend, clock=lambda: 100)
|
|
backend.channel.return_value = ('a', 36)
|
|
controller.tick(CONFIG)
|
|
backend.start.reset_mock()
|
|
backend.stop.reset_mock()
|
|
backend.connected = {36, 149}
|
|
backend.ap_status.return_value = {'ready': True, 'channel': 149}
|
|
for _ in range(3):
|
|
assert controller.tick(CONFIG) == {'active': True, 'state': 'active', 'reason': '', 'channel': 149}
|
|
backend.start.assert_not_called()
|
|
backend.stop.assert_not_called()
|
|
|
|
|
|
def test_dfs_channel_that_will_not_start_falls_back_to_standalone():
|
|
now = [100.]
|
|
backend = backend_mock(ready=False)
|
|
controller = HotspotController(backend, clock=lambda: now[0])
|
|
backend.channel.return_value = ('a', 100)
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
now[0] += 16
|
|
assert controller.tick(CONFIG)['state'] == 'error'
|
|
now[0] += 16
|
|
backend.ap_status.return_value = {'ready': True, 'channel': 6}
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
backend.start.assert_called_with(CONFIG, ('g', 6))
|
|
|
|
|
|
def test_backend_ap_status(tmp_path):
|
|
run = Mock(return_value=Mock(returncode=0, stdout='state=ENABLED\nchannel=149\n'))
|
|
backend = HotspotBackend(tmp_path, run=run, interfaces=Mock())
|
|
assert backend.ap_status() == {'ready': True, 'channel': 149}
|
|
assert run.call_count == 1
|
|
run.return_value = Mock(returncode=255, stdout='')
|
|
assert backend.ap_status() == {'ready': False, 'channel': None}
|
|
|
|
|
|
def test_routes_and_config():
|
|
assert not overlapping_routes([{'dst': 'default'}, {'dst': '192.168.180.0/24'}, {'dst': '172.31.254.0/24', 'dev': 'galaxy0'}])
|
|
assert overlapping_routes([{'dst': '172.31.0.0/16', 'dev': 'wlan0'}])
|
|
assert overlapping_routes([{'dst': '172.31.254.1', 'dev': 'eth0'}])
|
|
config = hostapd_config(CONFIG, ('a', 149))
|
|
assert 'interface=galaxy0\n' in config and 'channel=149\n' in config and 'wpa=2\n' in config
|
|
assert 'country_code' not in config
|
|
with pytest.raises(ValueError):
|
|
hostapd_config({**CONFIG, 'ssid': 'Galaxy\ninterface=wlan0'}, ('a', 149))
|
|
|
|
|
|
def test_controller_lifecycle_and_channel_recovery():
|
|
backend = backend_mock()
|
|
backend.channel.return_value = ('a', 149)
|
|
now = [100.]
|
|
controller = HotspotController(backend, clock=lambda: now[0])
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
assert controller.tick(CONFIG)['active']
|
|
backend.start.assert_called_once_with(CONFIG, ('a', 149))
|
|
backend.channel.return_value = ('a', 36)
|
|
assert controller.tick(CONFIG)['state'] == 'error' # rate limit restarts
|
|
now[0] += 16
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
backend.start.assert_called_with(CONFIG, ('a', 36))
|
|
backend.channel.return_value = None
|
|
assert controller.tick(CONFIG)['state'] == 'waiting'
|
|
assert controller.applied is None
|
|
assert controller.tick({**CONFIG, 'enabled': False})['state'] == 'disabled'
|
|
backend.stop.assert_called()
|
|
|
|
|
|
def test_controller_readiness_and_failed_child():
|
|
backend = backend_mock(ready=False)
|
|
backend.channel.return_value = ('a', 149)
|
|
now = [0]
|
|
controller = HotspotController(backend, clock=lambda: now[0])
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
assert controller.tick(CONFIG)['state'] == 'starting'
|
|
now[0] = 16
|
|
assert controller.tick(CONFIG)['state'] == 'error'
|
|
assert controller.applied is None
|
|
now[0] = 32
|
|
backend.start.side_effect = OSError('failed')
|
|
assert controller.tick(CONFIG)['state'] == 'error'
|
|
backend.stop.assert_called()
|
|
|
|
|
|
def test_backend_failure_rolls_back_only_owned_interface(tmp_path):
|
|
run = Mock(return_value=Mock(stdout='[]'))
|
|
interfaces = Mock()
|
|
backend = HotspotBackend(tmp_path, run=run, interfaces=interfaces)
|
|
backend.firewall = '/sbin/iptables-legacy'
|
|
def command(args, **kwargs):
|
|
if args[:3] == ['ip', 'address', 'add']:
|
|
raise OSError('failed')
|
|
return Mock(stdout='[]')
|
|
run.side_effect = command
|
|
with pytest.raises(OSError):
|
|
backend.start(CONFIG, ('a', 149))
|
|
interfaces.return_value.create.assert_called_once_with('galaxy0')
|
|
interfaces.return_value.delete.assert_called_once_with('galaxy0')
|
|
commands = [call.args[0] for call in run.call_args_list]
|
|
assert ['nmcli', 'device', 'set', 'galaxy0', 'managed', 'no'] in commands
|
|
assert all('wlan0' not in command and 'p2p0' not in command for command in commands)
|
|
assert not backend.owned
|
|
|
|
|
|
def test_backend_conflict_does_not_create(tmp_path):
|
|
interfaces = Mock()
|
|
backend = HotspotBackend(tmp_path, run=Mock(return_value=Mock(stdout='[{"dst":"172.31.0.0/16"}]')), interfaces=interfaces)
|
|
backend.firewall = '/sbin/iptables-legacy'
|
|
with pytest.raises(RuntimeError):
|
|
backend.start(CONFIG, ('a', 149))
|
|
interfaces.assert_not_called()
|
|
|
|
|
|
def test_netlink_encoding_and_refusal():
|
|
assert attributes(attribute(4, b'galaxy0\0')) == {4: b'galaxy0\0'}
|
|
with pytest.raises(ValueError):
|
|
attributes(b'\x03\x00\x01\x00')
|
|
wireless = WirelessInterfaces.__new__(WirelessInterfaces)
|
|
wireless.interfaces = Mock(return_value={'wlan0': {'type': 2, 'phy': 0, 'index': 1}, 'galaxy0': {'index': 7}})
|
|
wireless.query = Mock()
|
|
wireless.family = 22
|
|
with pytest.raises(RuntimeError):
|
|
wireless.create('galaxy0')
|
|
wireless.query.assert_not_called()
|
|
wireless.delete('galaxy0')
|
|
wireless.query.assert_called_once_with(22, 8, attribute(3, b'\x07\0\0\0'), ack=True)
|
|
|
|
|
|
def test_netlink_new_interface_payloads():
|
|
wireless = WirelessInterfaces.__new__(WirelessInterfaces)
|
|
wireless.interfaces = Mock(return_value={'wlan0': {'type': 2, 'phy': 0, 'index': 1}})
|
|
wireless.query = Mock()
|
|
wireless.family = 22
|
|
wireless.create('galaxy0')
|
|
ap = attribute(1, struct.pack('I', 0)) + attribute(4, b'galaxy0\0') + attribute(5, struct.pack('I', 3))
|
|
wireless.query.assert_called_with(22, 7, ap, ack=True)
|
|
wireless.create('aa0', iftype=2)
|
|
station = attribute(1, struct.pack('I', 0)) + attribute(4, b'aa0\0') + attribute(5, struct.pack('I', 2))
|
|
wireless.query.assert_called_with(22, 7, station, ack=True)
|