diff --git a/openpilot/starpilot/system/starport/car_profiles.py b/openpilot/starpilot/system/starport/car_profiles.py index f04b7b4a9d..d61dcb08c1 100644 --- a/openpilot/starpilot/system/starport/car_profiles.py +++ b/openpilot/starpilot/system/starport/car_profiles.py @@ -2,13 +2,14 @@ import re DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False, - "bootstrap_experiments": False, "stay_while_projecting": False} + "bootstrap_experiments": False, "stay_while_projecting": False, "dhcp_patience_s": 0.0} FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year") RULES: list[dict] = [ {"id": "aawireless", "label": "AAWireless", "match": {}, - "tuning": {"max_window": 30, "phone_services": True, "phone_frames": True, "stay_while_projecting": True}}, + "tuning": {"max_window": 30, "phone_services": True, "phone_frames": True, "stay_while_projecting": True, + "dhcp_patience_s": 6.0}}, {"id": "unofficial", "label": "Unofficial dongle", "match": {}, "tuning": {"bootstrap_experiments": True}}, ] diff --git a/openpilot/starpilot/system/starport/network.py b/openpilot/starpilot/system/starport/network.py index 688fdb0c5c..9d1aec73f1 100644 --- a/openpilot/starpilot/system/starport/network.py +++ b/openpilot/starpilot/system/starport/network.py @@ -40,7 +40,9 @@ NM_AP_IFACE = "org.freedesktop.NetworkManager.AccessPoint" DEVICE_TYPE_WIFI = 2 DEVICE_STATE_DISCONNECTED = 30 DEVICE_STATE_PREPARE = 40 +DEVICE_STATE_IP_CONFIG = 70 DEVICE_STATE_ACTIVATED = 100 +DHCP_RESTARTS = 3 DEVICE_STATES_NOT_MANAGED = (10, 20) # NM_DEVICE_STATE_UNMANAGED, NM_DEVICE_STATE_UNAVAILABLE DEVICE_READY_TIMEOUT_S = 5.0 # A shared interface can be briefly unmanaged (for example while the Wi-Fi mode is being switched). Bugsink showed 19 joins @@ -152,6 +154,7 @@ class NetworkLease: self.local_ip = "" self.device_ready = False self.autoconnect_paused = False + self.dhcp_patience = 0.0 self.marker = AUTOCONNECT_MARKER self._lock = threading.Lock() @@ -206,13 +209,15 @@ class NetworkLease: with self._lock: self._prepare_device(cancelled) - def acquire(self, credentials: WifiCredentials, timeout: float = JOIN_TIMEOUT_S, cancelled: Callable[[], bool] = lambda: False) -> str: + def acquire(self, credentials: WifiCredentials, timeout: float = JOIN_TIMEOUT_S, cancelled: Callable[[], bool] = lambda: False, + dhcp_patience: float = 0.0) -> str: """Join the projection network and return the comma's IPv4 address on it. The BSSID the car reports is tried first (fast, unambiguous); if that attempt fails, one retry matches the SSID alone in case the reported BSSID belongs to a different radio of the head unit. """ + self.dhcp_patience = dhcp_patience attempts = [credentials] if credentials.bssid: attempts.append(replace(credentials, bssid="")) @@ -246,6 +251,22 @@ class NetworkLease: self.active_path = self.connection_path = "" def _acquire_once(self, credentials: WifiCredentials, timeout: float, cancelled: Callable[[], bool]) -> str: + deadline = time.monotonic() + timeout + restarts = 0 + while True: + self._activate(credentials, cancelled) + if self._wait_activated(deadline, timeout, cancelled, restarts < DHCP_RESTARTS): + break + restarts += 1 + self.log("wifi_dhcp_restart", interface=self.interface, patience_s=self.dhcp_patience, restarts=restarts) + self._drop_attempt() + self.local_ip = self._ipv4() + if self.dedicated: + self._check_overlap() + self.log("wifi_joined", interface=self.interface, local_ip=self.local_ip, **self._radio()) + return self.local_ip + + def _activate(self, credentials: WifiCredentials, cancelled: Callable[[], bool]) -> None: with self._lock: if not self._get(NM_PATH, NM_IFACE, "WirelessEnabled"): raise NetworkError("Wi-Fi is turned off on the comma") @@ -271,7 +292,9 @@ class NetworkLease: {"persist": ("s", "volatile")}), timeout=15.0) self.connection_path, self.active_path = body[0], body[1] - deadline = time.monotonic() + timeout + def _wait_activated(self, deadline: float, timeout: float, cancelled: Callable[[], bool], may_restart: bool) -> bool: + """True once active; False when DHCP sat unanswered past dhcp_patience and the join should start over.""" + ip_config_since = 0.0 while True: if cancelled(): raise NetworkError("cancelled") @@ -280,17 +303,20 @@ class NetworkLease: except NetworkError as error: raise NetworkError("The projection network connection was removed (wrong key or out of range?)") from error if state == ACTIVE_STATE_ACTIVATED: - break + return True if state == ACTIVE_STATE_DEACTIVATED: raise NetworkError("NetworkManager could not join the projection network") - if time.monotonic() >= deadline: + now = time.monotonic() + if now >= deadline: raise JoinTimeout(f"Timed out joining the projection network after {timeout:.0f} s") + if may_restart and self.dhcp_patience > 0: + if self._get(self.device_path, NM_DEVICE_IFACE, "State") == DEVICE_STATE_IP_CONFIG: + ip_config_since = ip_config_since or now + if now - ip_config_since >= self.dhcp_patience: + return False + else: + ip_config_since = 0.0 time.sleep(0.25) - self.local_ip = self._ipv4() - if self.dedicated: - self._check_overlap() - self.log("wifi_joined", interface=self.interface, local_ip=self.local_ip, **self._radio()) - return self.local_ip def _frequency(self, path: str) -> int: access_point = self._get(path, NM_WIRELESS_IFACE, "ActiveAccessPoint") diff --git a/openpilot/starpilot/system/starport/supervisor.py b/openpilot/starpilot/system/starport/supervisor.py index c013900af4..9fe387df7d 100644 --- a/openpilot/starpilot/system/starport/supervisor.py +++ b/openpilot/starpilot/system/starport/supervisor.py @@ -1487,7 +1487,9 @@ class Supervisor: # a car that once took 70 s to be ready gets more than the standard wait for the Wi-Fi handshake patience=max(strategy.patience, self._ladder.ready_patience(address, 20.0))) self._set(state="wifi_start") - result = boot.run(lambda credentials: lease.acquire(credentials, cancelled=boot.join_is_cancelled), cancelled=self._stop.is_set) + patience = car_profiles.tuning(config)["dhcp_patience_s"] + join_options = {"dhcp_patience": patience} if patience else {} + result = boot.run(lambda credentials: lease.acquire(credentials, cancelled=boot.join_is_cancelled, **join_options), cancelled=self._stop.is_set) except Exception as error: if _stray_link(error): raise StrayLinkError(f"Bluetooth link to the car is stuck: {error}") from error # says nothing about the cached channel diff --git a/openpilot/starpilot/system/starport/tests/test_car_profiles.py b/openpilot/starpilot/system/starport/tests/test_car_profiles.py index 853f7cbcaa..06f58ba792 100644 --- a/openpilot/starpilot/system/starport/tests/test_car_profiles.py +++ b/openpilot/starpilot/system/starport/tests/test_car_profiles.py @@ -40,7 +40,7 @@ def test_aawireless_tuning_applies_only_when_chosen(): chosen = config(car_profile_override="aawireless", receiver_name="AAWireless-4F2A") assert car_profiles.resolve(chosen) == ("aawireless", "override") assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True, - "bootstrap_experiments": False, "stay_while_projecting": True} + "bootstrap_experiments": False, "stay_while_projecting": True, "dhcp_patience_s": 6.0} assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless" for other in (config(receiver_name="AAWireless-4F2A"), config(car_profile_override="default")): assert car_profiles.tuning(other) == car_profiles.DEFAULTS diff --git a/openpilot/starpilot/system/starport/tests/test_starport.py b/openpilot/starpilot/system/starport/tests/test_starport.py index 3b0835d1f5..c571f5e683 100644 --- a/openpilot/starpilot/system/starport/tests/test_starport.py +++ b/openpilot/starpilot/system/starport/tests/test_starport.py @@ -1012,8 +1012,9 @@ def test_supervisor_stop_while_waiting_for_car(identity, tmp_path, monkeypatch): # ------------------------------------------------------------------- network class FakeNetworkManager: - def __init__(self, fail_with_bssid=False): + def __init__(self, fail_with_bssid=False, stalled_dhcp=0): self.fail_with_bssid = fail_with_bssid + self.stalled_dhcp = stalled_dhcp self.active_device_connection = "/active/home" self.states = {} self.added = [] @@ -1031,6 +1032,8 @@ class FakeNetworkManager: active = f"/active/aa{len(self.added)}" locked = "bssid" in settings["802-11-wireless"] self.states[active] = network.ACTIVE_STATE_DEACTIVATED if (locked and self.fail_with_bssid) else network.ACTIVE_STATE_ACTIVATED + if len(self.added) <= self.stalled_dhcp: + self.states[active] = 1 # activating: the access point never answers DHCP self.active_device_connection = active return [f"/settings/aa{len(self.added)}", active] if member == "GetSettings": @@ -1043,6 +1046,8 @@ class FakeNetworkManager: values = {"WirelessEnabled": True, "DeviceType": 2, "Interface": "wlan0", "ActiveConnection": self.active_device_connection, "Connection": "/settings/home", "Ip4Config": "/ip4/1", "Autoconnect": self.autoconnect, "ActiveAccessPoint": "/ap/1", "Frequency": 5745} + if name == "State" and interface.endswith("Device"): + return 70 if name == "State": return self.states.get(path, 4) if name == "AddressData": @@ -1113,6 +1118,22 @@ def test_network_lease_waits_for_a_slow_honda_access_point(monkeypatch): assert budgets == [("AA:BB:CC:DD:EE:FF", 90.0), ("", 30.0)] +def test_network_lease_restarts_a_join_whose_dhcp_goes_unanswered(): + nm = FakeNetworkManager(stalled_dhcp=2) + lease = make_lease(nm) + assert lease.acquire(credentials(), timeout=10.0, dhcp_patience=0.3) == "192.168.50.23" + assert len(nm.added) == 3 + + +def test_network_lease_waits_for_dhcp_without_patience(): + from openpilot.starpilot.system.starport import network + nm = FakeNetworkManager(stalled_dhcp=2) + lease = make_lease(nm) + with pytest.raises(network.JoinTimeout): + lease.acquire(credentials(), timeout=1.0) + assert len(nm.added) == 2 # only the SSID-only retry, never a DHCP restart + + def test_network_lease_does_not_undo_user_network_change(): nm = FakeNetworkManager() lease = make_lease(nm)