@@ -284,11 +284,10 @@ def hotspot_password(self) -> str:
284284 async def _async_initialize (self ) -> None :
285285 """Bootstrap the worker on the asyncio thread.
286286
287- Detects network interfaces, enforces the boot-time ethernet/Wi-Fi
288- mutual exclusion, activates any saved VLANs if ethernet is present,
289- triggers an initial Wi-Fi scan, and starts all D-Bus signal listeners.
290- Emits ``initialized`` when done (even on failure, so the manager can
291- unblock its caller).
287+ Detects network interfaces, activates any saved VLANs if ethernet is
288+ present, triggers an initial Wi-Fi scan, and starts all D-Bus signal
289+ listeners. Emits ``initialized`` when done (even on failure, so the
290+ manager can unblock its caller).
292291 """
293292 try :
294293 if not self ._system_bus :
@@ -297,7 +296,6 @@ async def _async_initialize(self) -> None:
297296
298297 self ._running = True
299298 await self ._detect_interfaces ()
300- await self ._enforce_boot_mutual_exclusion ()
301299
302300 if await self ._is_ethernet_connected ():
303301 await self ._activate_saved_vlans ()
@@ -367,31 +365,39 @@ async def _detect_interfaces(self) -> None:
367365 # Ethernet-only or Wi-Fi driver still loading — log but don't alarm.
368366 logger .warning ("No Wi-Fi interface detected; ethernet-only mode" )
369367
370- async def _enforce_boot_mutual_exclusion (self ) -> None :
371- """Disable Wi-Fi at boot if ethernet is already connected.
368+ async def _set_wired_profiles_autoconnect (self , enabled : bool ) -> None :
369+ """Persist autoconnect on every wired profile; Device.Autoconnect dies on NM restart."""
370+ try :
371+ paths = await self ._nm_settings ().list_connections ()
372+ for path , settings in await self ._gather_settings (list (paths )):
373+ conn = settings .get ("connection" , {})
374+ if conn .get ("type" , (None , "" ))[1 ] != "802-3-ethernet" :
375+ continue
376+ if bool (conn .get ("autoconnect" , ("b" , True ))[1 ]) == enabled :
377+ continue
378+ props = {k : dict (v ) for k , v in settings .items ()}
379+ props ["connection" ]["autoconnect" ] = ("b" , enabled )
380+ props ["connection" ].pop ("timestamp" , None )
381+ await self ._conn_settings (path ).update (props )
382+ logger .info ("Wired profile %s autoconnect -> %s" , path , enabled )
383+ except Exception as exc :
384+ logger .warning ("Wired profile autoconnect (%s) failed: %s" , enabled , exc )
385+
386+ async def _ensure_wired_autoconnect (self ) -> None :
387+ """Re-arm wired autoconnect on both the device and the saved profiles.
372388
373- Prevents the device from simultaneously using both interfaces at
374- startup. If ethernet is active and the Wi-Fi radio is on, the Wi-Fi
375- device is disconnected and the radio is disabled, then we wait up to
376- 8 s for the radio to confirm it is off. Failures are logged but not
377- propagated — a non-fatal best-effort action at boot.
389+ Called only when the user asks for ethernet, so autoconnect staying off
390+ keeps meaning "user turned it off". Best-effort: never propagates.
378391 """
392+ if not self ._primary_wired_path :
393+ return
394+ await self ._set_wired_profiles_autoconnect (True )
379395 try :
380- if not await self ._is_ethernet_connected ():
381- return
382- if not await self ._nm ().wireless_enabled :
383- return
384- logger .info ("Boot: ethernet active + Wi-Fi enabled — disabling Wi-Fi" )
385- if self ._primary_wifi_path :
386- try :
387- await self ._wifi ().disconnect ()
388- except Exception as exc :
389- logger .debug ("Pre-radio-disable disconnect ignored: %s" , exc )
390- await self ._nm ().wireless_enabled .set_async (False )
391- await self ._wait_for_wifi_radio (False , timeout = 8.0 )
392- self ._is_hotspot_active = False
396+ wired = self ._generic (self ._primary_wired_path )
397+ if not await wired .autoconnect :
398+ await wired .autoconnect .set_async (True )
393399 except Exception as exc :
394- logger .warning ( "Boot mutual exclusion failed (non-fatal) : %s" , exc )
400+ logger .debug ( "Device autoconnect re-arm ignored : %s" , exc )
395401
396402 async def _start_signal_listeners (self ) -> None :
397403 """Create persistent proxies and spawn all D-Bus signal listeners.
@@ -1295,9 +1301,7 @@ async def _add_network_impl(
12951301 if not self ._primary_wifi_path or not self ._system_bus :
12961302 return ConnectionResult (False , "No Wi-Fi interface" , "no_interface" )
12971303
1298- if await self ._is_known (ssid ):
1299- await self ._delete_network_impl (ssid )
1300- self ._invalidate_saved_cache ()
1304+ backup = await self ._backup_and_drop_existing (ssid )
13011305
13021306 try :
13031307 await self ._wifi ().request_scan ({})
@@ -1337,15 +1341,7 @@ async def _add_network_impl(
13371341 try :
13381342 await self ._nm ().activate_connection (conn_path )
13391343 if not await self ._wait_for_connection (ssid , timeout = _WIFI_CONNECT_TIMEOUT ):
1340- await self ._delete_network_impl (ssid )
1341- self ._invalidate_saved_cache ()
1342- return ConnectionResult (
1343- False ,
1344- f"Authentication failed for '{ ssid } '.\n "
1345- "The saved profile has been removed.\n "
1346- "Please check the password and try again." ,
1347- "auth_failed" ,
1348- )
1344+ return await self ._rollback_failed_add (ssid , backup )
13491345 return ConnectionResult (True , f"Network '{ ssid } ' added and connecting" )
13501346 except Exception as act_err :
13511347 logger .warning ("Activate after add failed: %s" , act_err )
@@ -1360,13 +1356,65 @@ async def _reload_connections(self) -> None:
13601356 except Exception as reload_err :
13611357 logger .debug ("reload_connections non-fatal: %s" , reload_err )
13621358
1359+ async def _backup_profile (self , ssid : str ) -> dict | None :
1360+ """Snapshot a saved profile's settings plus secrets so it can be re-added."""
1361+ conn_path = await self ._get_connection_path (ssid )
1362+ if not conn_path :
1363+ return None
1364+ try :
1365+ cs = self ._conn_settings (conn_path )
1366+ settings = dict (await cs .get_settings ())
1367+ await self ._merge_wifi_secrets (cs , settings )
1368+ settings .get ("connection" , {}).pop ("timestamp" , None )
1369+ logger .debug ("backup_profile: '%s' sections=%s" , ssid , sorted (settings ))
1370+ return settings
1371+ except Exception as exc :
1372+ logger .warning ("backup_profile: could not snapshot '%s': %s" , ssid , exc )
1373+ return None
1374+
1375+ async def _restore_profile (self , ssid : str , settings : dict ) -> bool :
1376+ """Re-add a backed-up profile after a failed replacement; True when restored."""
1377+ try :
1378+ await self ._nm_settings ().add_connection (settings )
1379+ self ._invalidate_saved_cache ()
1380+ logger .info ("restore_profile: '%s' restored after failed add" , ssid )
1381+ return True
1382+ except Exception as exc :
1383+ logger .error ("restore_profile: could not restore '%s': %s" , ssid , exc )
1384+ return False
1385+
1386+ async def _rollback_failed_add (
1387+ self , ssid : str , backup : dict | None
1388+ ) -> ConnectionResult :
1389+ """Delete the profile that never activated and restore *backup* if there is one."""
1390+ logger .warning ("add_network: '%s' never activated, rolling back" , ssid )
1391+ await self ._delete_network_impl (ssid )
1392+ self ._invalidate_saved_cache ()
1393+ if backup and await self ._restore_profile (ssid , backup ):
1394+ return ConnectionResult (
1395+ False ,
1396+ f"Could not connect to '{ ssid } '.\n "
1397+ "The previously saved password was kept.\n "
1398+ "Please check the password and try again." ,
1399+ "auth_failed" ,
1400+ )
1401+ return ConnectionResult (
1402+ False ,
1403+ f"Authentication failed for '{ ssid } '.\n "
1404+ "The saved profile has been removed.\n "
1405+ "Please check the password and try again." ,
1406+ "auth_failed" ,
1407+ )
1408+
13631409 async def _backup_and_drop_existing (self , ssid : str ) -> dict | None :
1364- """Return the scanned AP properties for *ssid*, or None if it is not visible."""
1365- ap_paths = await self ._wifi ().get_all_access_points ()
1366- for _ap_path , props in await self ._gather_ap_properties (ap_paths ):
1367- if self ._decode_ssid (props .get ("ssid" , b"" )) == ssid :
1368- return props
1369- return None
1410+ """Back up and delete a saved profile for *ssid* so it can be re-added cleanly."""
1411+ if not await self ._is_known (ssid ):
1412+ return None
1413+ backup = await self ._backup_profile (ssid )
1414+ logger .info ("add_network: replacing saved '%s' (backup=%s)" , ssid , bool (backup ))
1415+ await self ._delete_network_impl (ssid )
1416+ self ._invalidate_saved_cache ()
1417+ return backup
13701418
13711419 async def _async_connect_network (self , ssid : str ) -> None :
13721420 """Activate an existing saved Wi-Fi profile and emit connection_result."""
@@ -1592,16 +1640,13 @@ async def _update_network_impl(
15921640 return ConnectionResult (False , str (exc ), "update_failed" )
15931641
15941642 async def _async_set_wifi_enabled (self , enabled : bool ) -> None :
1595- """Enable or disable the Wi-Fi radio, handling ethernet mutual exclusion ."""
1643+ """Enable or disable the Wi-Fi radio. Ethernet is left untouched ."""
15961644 try :
15971645 if not self ._system_bus :
15981646 return
15991647 if not enabled :
16001648 self ._is_hotspot_active = False
16011649
1602- if enabled and await self ._is_ethernet_connected ():
1603- await self ._async_disconnect_ethernet ()
1604-
16051650 current = await self ._nm ().wireless_enabled
16061651 if current != enabled :
16071652 if not enabled :
@@ -1651,24 +1696,18 @@ async def _async_disconnect_ethernet(self) -> None:
16511696 logger .error ("Failed to disconnect ethernet: %s" , exc )
16521697
16531698 async def _async_connect_ethernet (self ) -> None :
1654- """Disable Wi-Fi/hotspot, activate the wired device, and restore saved VLANs."""
1699+ """Activate the wired device and restore saved VLANs.
1700+
1701+ Mechanism only: the one-link-at-a-time policy lives in the UI toggles.
1702+ """
16551703 if not self ._primary_wired_path :
16561704 self .error_occurred .emit ("connect_ethernet" , "No wired device found" )
16571705 return
16581706 try :
16591707 if self ._is_hotspot_active :
16601708 await self ._async_toggle_hotspot (False )
16611709
1662- if self ._primary_wifi_path :
1663- try :
1664- await self ._wifi ().disconnect ()
1665- except Exception as exc :
1666- logger .debug ("Pre-VLAN disconnect ignored: %s" , exc )
1667- await asyncio .sleep (0.5 )
1668-
1669- if await self ._nm ().wireless_enabled :
1670- await self ._nm ().wireless_enabled .set_async (False )
1671- await self ._wait_for_wifi_radio (False , timeout = 8.0 )
1710+ await self ._ensure_wired_autoconnect ()
16721711
16731712 await self ._nm ().activate_connection ("/" , self ._primary_wired_path , "/" )
16741713 await asyncio .sleep (1.5 )
0 commit comments