nmrs 2.3.0

A Rust library for NetworkManager over D-Bus
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
use futures_timer::Delay;
use log::{debug, error, info, warn};
use std::collections::HashMap;
use zbus::Connection;
use zvariant::OwnedObjectPath;

use crate::Result;
use crate::api::builders::wifi::{build_ethernet_connection, build_wifi_connection};
use crate::api::models::{ConnectionError, ConnectionOptions, TimeoutConfig, WifiSecurity};
use crate::core::connection_settings::{delete_connection, get_saved_connection_path};
use crate::core::state_wait::{wait_for_connection_activation, wait_for_device_disconnect};
use crate::dbus::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWiredProxy, NMWirelessProxy};
use crate::monitoring::info::current_ssid;
use crate::monitoring::transport::ActiveTransport;
use crate::monitoring::wifi::Wifi;
use crate::types::constants::{device_state, device_type, timeouts};
use crate::util::utils::{decode_ssid_or_empty, nm_proxy};
use crate::util::validation::{validate_ssid, validate_wifi_security};

/// Decision on whether to reuse a saved connection or create a fresh one.
enum SavedDecision {
    /// Reuse the saved connection at this path.
    UseSaved(OwnedObjectPath),
    /// Delete any saved connection and create a new one with fresh credentials.
    RebuildFresh,
}

/// Connects to a Wi-Fi network.
///
/// This is the main entry point for establishing a Wi-Fi connection. The flow:
/// 1. Check for an existing saved connection for this SSID
/// 2. Decide whether to reuse it or create fresh (based on credentials)
/// 3. Find the Wi-Fi device and target access point
/// 4. Either activate the saved connection or create and activate a new one
/// 5. Wait for the connection to reach the activated state
///
/// If a saved connection exists but fails, it will be deleted and a fresh
/// connection will be attempted with the provided credentials.
pub(crate) async fn connect(
    conn: &Connection,
    ssid: &str,
    creds: WifiSecurity,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    // Validate inputs before attempting connection
    validate_ssid(ssid)?;
    validate_wifi_security(&creds)?;

    debug!(
        "Connecting to '{}' | secured={} is_psk={} is_eap={}",
        ssid,
        creds.secured(),
        creds.is_psk(),
        creds.is_eap()
    );

    let nm = NMProxy::new(conn).await?;

    let saved_raw = get_saved_connection_path(conn, ssid).await?;
    let decision = decide_saved_connection(saved_raw, &creds)?;

    let wifi_device = find_wifi_device(conn, &nm).await?;
    debug!("Found WiFi device: {}", wifi_device.as_str());

    let wifi = NMWirelessProxy::builder(conn)
        .path(wifi_device.clone())?
        .build()
        .await?;

    if let Some(active) = Wifi::current(conn).await {
        debug!("Currently connected to: {active}");
        if active == ssid {
            debug!("Already connected to {active}, skipping connect()");
            return Ok(());
        }
    } else {
        debug!("Not currently connected to any network");
    }

    let specific_object = scan_and_resolve_ap(conn, &wifi, ssid).await?;

    match decision {
        SavedDecision::UseSaved(saved) => {
            ensure_disconnected(conn, &nm, &wifi_device, timeout_config).await?;
            connect_via_saved(
                conn,
                &nm,
                &wifi_device,
                &specific_object,
                &creds,
                saved,
                timeout_config,
            )
            .await?;
        }
        SavedDecision::RebuildFresh => {
            build_and_activate_new(
                conn,
                &nm,
                &wifi_device,
                &specific_object,
                ssid,
                creds,
                timeout_config,
            )
            .await?;
        }
    }

    // Connection activation is now handled within connect_via_saved() and
    // build_and_activate_new() using signal-based monitoring
    info!("Successfully connected to '{ssid}'");

    Ok(())
}

/// Connects to a wired (Ethernet) device.
///
/// This is the main entry point for establishing a wired connection. The flow:
/// 1. Find a wired device
/// 2. Check for an existing saved connection
/// 3. Either activate the saved connection or create and activate a new one
/// 4. Wait for the connection to reach the activated state
///
/// Ethernet connections are typically simpler than Wi-Fi - no scanning or
/// access points needed. The connection will activate when a cable is plugged in.
pub(crate) async fn connect_wired(
    conn: &Connection,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    debug!("Connecting to wired device");

    let nm = NMProxy::new(conn).await?;

    let wired_device = find_wired_device(conn, &nm).await?;
    debug!("Found wired device: {}", wired_device.as_str());

    // Check if already connected
    let dev = NMDeviceProxy::builder(conn)
        .path(wired_device.clone())?
        .build()
        .await?;
    let current_state = dev.state().await?;
    if current_state == device_state::ACTIVATED {
        debug!("Wired device already activated, skipping connect()");
        return Ok(());
    }

    // Check for saved connection (by interface name)
    let interface = dev.interface().await?;
    let saved = get_saved_connection_path(conn, &interface).await?;

    // For Ethernet, we use "/" as the specific_object (no access point needed)
    let specific_object = OwnedObjectPath::default();

    match saved {
        Some(saved_path) => {
            debug!("Activating saved wired connection: {}", saved_path.as_str());
            let active_conn = nm
                .activate_connection(saved_path, wired_device.clone(), specific_object)
                .await?;
            let timeout = timeout_config.map(|c| c.connection_timeout);
            wait_for_connection_activation(conn, &active_conn, timeout).await?;
        }
        None => {
            debug!("No saved connection found, creating new wired connection");
            let opts = ConnectionOptions {
                autoconnect: true,
                autoconnect_priority: None,
                autoconnect_retries: None,
            };

            let settings = build_ethernet_connection(&interface, &opts);
            let (_, active_conn) = nm
                .add_and_activate_connection(settings, wired_device.clone(), specific_object)
                .await?;
            let timeout = timeout_config.map(|c| c.connection_timeout);
            wait_for_connection_activation(conn, &active_conn, timeout).await?;
        }
    }

    if let Ok(wired) = NMWiredProxy::builder(conn)
        .path(wired_device.clone())?
        .build()
        .await
        && let Ok(speed) = wired.speed().await
    {
        info!("Connected to wired device at {speed} Mb/s");
    }

    info!("Successfully connected to wired device");
    Ok(())
}

/// Generic function to forget (delete) connections by name and optionally by device type.
///
/// This handles disconnection if currently active, then deletes the connection profile(s).
/// Can be used for WiFi, Bluetooth, or any NetworkManager connection type.
///
/// # Arguments
///
/// * `conn` - D-Bus connection
/// * `name` - Connection name/identifier to forget
/// * `device_filter` - Optional device type filter (e.g., `Some(device_type::BLUETOOTH)`)
///
/// # Returns
///
/// Returns `Ok(())` if at least one connection was deleted successfully.
/// Returns `NoSavedConnection` if no matching connections were found.
pub(crate) async fn forget_by_name_and_type(
    conn: &Connection,
    name: &str,
    device_filter: Option<u32>,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    use std::collections::HashMap;
    use zvariant::{OwnedObjectPath, Value};

    // Validate SSID
    validate_ssid(name)?;

    debug!(
        "Starting forget operation for: {name} (device filter: {:?})",
        device_filter
    );

    let nm = NMProxy::new(conn).await?;

    // Disconnect if currently active
    let devices = nm.get_devices().await?;
    for dev_path in &devices {
        let dev = NMDeviceProxy::builder(conn)
            .path(dev_path.clone())?
            .build()
            .await?;

        let dev_type = dev.device_type().await?;

        // Skip if device type doesn't match our filter
        if let Some(filter) = device_filter
            && dev_type != filter
        {
            continue;
        }

        // Handle WiFi-specific disconnect logic
        if dev_type == device_type::WIFI {
            let wifi = NMWirelessProxy::builder(conn)
                .path(dev_path.clone())?
                .build()
                .await?;
            if let Ok(ap_path) = wifi.active_access_point().await
                && ap_path.as_str() != "/"
            {
                let ap = NMAccessPointProxy::builder(conn)
                    .path(ap_path.clone())?
                    .build()
                    .await?;
                if let Ok(bytes) = ap.ssid().await
                    && decode_ssid_or_empty(&bytes) == name
                {
                    debug!("Disconnecting from active WiFi network: {name}");
                    if let Err(e) = disconnect_wifi_and_wait(conn, dev_path, timeout_config).await {
                        warn!("Disconnect wait failed: {e}");
                        let final_state = dev.state().await?;
                        if final_state != device_state::DISCONNECTED
                            && final_state != device_state::UNAVAILABLE
                        {
                            error!(
                                "Device still connected (state: {final_state}), cannot safely delete"
                            );
                            return Err(ConnectionError::Stuck(format!(
                                "disconnect failed, device in state {final_state}"
                            )));
                        }
                        debug!("Device confirmed disconnected, proceeding with deletion");
                    }
                    debug!("WiFi disconnect phase completed");
                }
            }
        }
        // Handle Bluetooth-specific disconnect logic
        else if dev_type == device_type::BLUETOOTH {
            // Check if this Bluetooth device is currently active
            let state = dev.state().await?;
            if state != device_state::DISCONNECTED && state != device_state::UNAVAILABLE {
                debug!("Disconnecting from active Bluetooth device: {name}");
                if let Err(e) = crate::core::bluetooth::disconnect_bluetooth_and_wait(
                    conn,
                    dev_path,
                    timeout_config,
                )
                .await
                {
                    warn!("Bluetooth disconnect failed: {e}");
                    let final_state = dev.state().await?;
                    if final_state != device_state::DISCONNECTED
                        && final_state != device_state::UNAVAILABLE
                    {
                        error!(
                            "Bluetooth device still connected (state: {final_state}), cannot safely delete"
                        );
                        return Err(ConnectionError::Stuck(format!(
                            "disconnect failed, device in state {final_state}"
                        )));
                    }
                }
                debug!("Bluetooth disconnect phase completed");
            }
        }
    }

    // Delete connection profiles (generic, works for all types)
    debug!("Starting connection deletion phase...");

    let settings = nm_proxy(
        conn,
        "/org/freedesktop/NetworkManager/Settings",
        "org.freedesktop.NetworkManager.Settings",
    )
    .await?;

    let list_reply = settings.call_method("ListConnections", &()).await?;
    let conns: Vec<OwnedObjectPath> = list_reply.body().deserialize()?;

    let mut deleted_count = 0;

    for cpath in conns {
        let cproxy = nm_proxy(
            conn,
            cpath.clone(),
            "org.freedesktop.NetworkManager.Settings.Connection",
        )
        .await?;

        if let Ok(msg) = cproxy.call_method("GetSettings", &()).await {
            let body = msg.body();
            let settings_map: HashMap<String, HashMap<String, Value>> = body.deserialize()?;

            let mut should_delete = false;

            // Match by connection ID (works for all connection types)
            if let Some(conn_sec) = settings_map.get("connection")
                && let Some(Value::Str(id)) = conn_sec.get("id")
                && id.as_str() == name
            {
                should_delete = true;
                debug!("Found connection by ID: {id}");
            }

            // Additional WiFi-specific matching by SSID
            if let Some(wifi_sec) = settings_map.get("802-11-wireless")
                && let Some(Value::Array(arr)) = wifi_sec.get("ssid")
            {
                let mut raw = Vec::new();
                for v in arr.iter() {
                    if let Ok(b) = u8::try_from(v.clone()) {
                        raw.push(b);
                    }
                }
                if decode_ssid_or_empty(&raw) == name {
                    should_delete = true;
                    debug!("Found WiFi connection by SSID match");
                }
            }

            // Matching by bdaddr for Bluetooth connections
            if let Some(bt_sec) = settings_map.get("bluetooth")
                && let Some(Value::Str(bdaddr)) = bt_sec.get("bdaddr")
                && bdaddr.as_str() == name
            {
                should_delete = true;
                debug!("Found Bluetooth connection by bdaddr match");
            }

            if let Some(wsec) = settings_map.get("802-11-wireless-security") {
                let missing_psk = !wsec.contains_key("psk");
                let empty_psk = matches!(wsec.get("psk"), Some(Value::Str(s)) if s.is_empty());

                if (missing_psk || empty_psk) && should_delete {
                    debug!("Connection has missing/empty PSK, will delete");
                }
            }

            if should_delete {
                match cproxy.call_method("Delete", &()).await {
                    Ok(_) => {
                        deleted_count += 1;
                        debug!("Deleted connection: {}", cpath.as_str());
                    }
                    Err(e) => {
                        warn!("Failed to delete connection {}: {}", cpath.as_str(), e);
                    }
                }
            }
        }
    }

    if deleted_count > 0 {
        info!("Successfully deleted {deleted_count} connection(s) for '{name}'");
        Ok(())
    } else {
        debug!("No saved connections found for '{name}'");

        // For Bluetooth, it's normal to have no NetworkManager connection profile if the device is only paired in BlueZ.
        if device_filter == Some(device_type::BLUETOOTH) {
            debug!(
                "Bluetooth device '{name}' has no NetworkManager connection profile (device may only be paired in BlueZ)"
            );
            Ok(())
        } else {
            Ok(())
        }
    }
}

/// Disconnects a Wi-Fi device and waits for it to reach disconnected state.
///
/// Calls the Disconnect method on the device and waits for the `StateChanged`
/// signal to indicate the device has reached Disconnected or Unavailable state.
/// This is more efficient than polling and responds immediately when the
/// device disconnects.
pub(crate) async fn disconnect_wifi_and_wait(
    conn: &Connection,
    dev_path: &OwnedObjectPath,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    let dev = NMDeviceProxy::builder(conn)
        .path(dev_path.clone())?
        .build()
        .await?;

    // Check if already disconnected
    let current_state = dev.state().await?;
    if current_state == device_state::DISCONNECTED || current_state == device_state::UNAVAILABLE {
        debug!("Device already disconnected");
        return Ok(());
    }

    let raw = nm_proxy(
        conn,
        dev_path.clone(),
        "org.freedesktop.NetworkManager.Device",
    )
    .await?;

    debug!("Sending disconnect request");
    match raw.call_method("Disconnect", &()).await {
        Ok(_) => debug!("Disconnect method called successfully"),
        Err(e) => warn!(
            "Disconnect method call failed (device may already be disconnected): {}",
            e
        ),
    }

    // Wait for disconnect using signal-based monitoring
    let timeout = timeout_config.map(|c| c.disconnect_timeout);
    wait_for_device_disconnect(&dev, timeout).await?;

    // Brief stabilization delay
    Delay::new(timeouts::stabilization_delay()).await;

    Ok(())
}

/// Finds a network device by its type.
///
/// Iterates through all devices managed by NetworkManager
/// and returns the path of the first device matching the specified type.
/// Returns an appropriate error if no matching device is found.
async fn find_device_by_type(
    conn: &Connection,
    nm: &NMProxy<'_>,
    device_type_id: u32,
) -> Result<OwnedObjectPath> {
    let devices = nm.get_devices().await?;

    for dp in devices {
        let dev = NMDeviceProxy::builder(conn)
            .path(dp.clone())?
            .build()
            .await?;
        if dev.device_type().await? == device_type_id {
            return Ok(dp);
        }
    }

    match device_type_id {
        device_type::WIFI => Err(ConnectionError::NoWifiDevice),
        device_type::ETHERNET => Err(ConnectionError::NoWiredDevice),
        _ => Err(ConnectionError::NoWifiDevice),
    }
}

pub(crate) async fn find_wired_device(
    conn: &Connection,
    nm: &NMProxy<'_>,
) -> Result<OwnedObjectPath> {
    find_device_by_type(conn, nm, device_type::ETHERNET).await
}

async fn find_wifi_device(conn: &Connection, nm: &NMProxy<'_>) -> Result<OwnedObjectPath> {
    find_device_by_type(conn, nm, device_type::WIFI).await
}

/// Finds an access point by SSID.
///
/// Searches through all visible access points on the wireless device
/// and returns the path of the first one matching the target SSID.
/// Returns `NotFound` if no matching access point is visible.
async fn find_ap(
    conn: &Connection,
    wifi: &NMWirelessProxy<'_>,
    target_ssid: &str,
) -> Result<OwnedObjectPath> {
    let access_points = wifi.access_points().await?;

    for ap_path in access_points {
        let ap = NMAccessPointProxy::builder(conn)
            .path(ap_path.clone())?
            .build()
            .await?;

        let ssid_bytes = ap.ssid().await?;
        let ssid = decode_ssid_or_empty(&ssid_bytes);

        if ssid == target_ssid {
            return Ok(ap_path);
        }
    }

    Err(ConnectionError::NotFound)
}

/// Ensures the Wi-Fi device is disconnected before attempting a new connection.
///
/// If currently connected to any network, deactivates all active connections
/// and waits for the device to reach disconnected state.
async fn ensure_disconnected(
    conn: &Connection,
    nm: &NMProxy<'_>,
    wifi_device: &OwnedObjectPath,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    if let Some(active) = Wifi::current(conn).await {
        debug!("Disconnecting from {active}");

        if let Ok(conns) = nm.active_connections().await {
            for conn_path in conns {
                match nm.deactivate_connection(conn_path.clone()).await {
                    Ok(_) => debug!("Connection deactivated during cleanup"),
                    Err(e) => warn!("Failed to deactivate connection during cleanup: {}", e),
                }
            }
        }

        disconnect_wifi_and_wait(conn, wifi_device, timeout_config).await?;
    }

    Ok(())
}

/// Attempts to connect using a saved connection profile.
///
/// Activates the saved connection and monitors the activation state using
/// D-Bus signals. If activation fails (device disconnects or enters failed
/// state), deletes the saved connection and creates a fresh one with the
/// provided credentials.
///
/// This handles cases where saved passwords are outdated or corrupted.
async fn connect_via_saved(
    conn: &Connection,
    nm: &NMProxy<'_>,
    wifi_device: &OwnedObjectPath,
    ap: &OwnedObjectPath,
    creds: &WifiSecurity,
    saved: OwnedObjectPath,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    debug!("Activating saved connection: {}", saved.as_str());

    match nm
        .activate_connection(saved.clone(), wifi_device.clone(), ap.clone())
        .await
    {
        Ok(active_conn) => {
            debug!(
                "activate_connection() succeeded, active connection: {}",
                active_conn.as_str()
            );

            // Wait for connection activation using signal-based monitoring
            let timeout = timeout_config.map(|c| c.connection_timeout);
            match wait_for_connection_activation(conn, &active_conn, timeout).await {
                Ok(()) => {
                    debug!("Saved connection activated successfully");
                }
                Err(e) => {
                    warn!("Saved connection activation failed: {e}");
                    warn!("Deleting saved connection and retrying with fresh credentials");

                    match nm.deactivate_connection(active_conn.clone()).await {
                        Ok(_) => debug!("Connection deactivated during cleanup"),
                        Err(e) => warn!("Failed to deactivate connection during cleanup: {}", e),
                    }
                    match delete_connection(conn, saved.clone()).await {
                        Ok(_) => debug!("Saved connection deleted"),
                        Err(e) => warn!("Failed to delete saved connection during recovery: {}", e),
                    }

                    let opts = ConnectionOptions {
                        autoconnect: true,
                        autoconnect_priority: None,
                        autoconnect_retries: None,
                    };

                    let settings = build_wifi_connection(ap.as_str(), creds, &opts);

                    debug!("Creating fresh connection with corrected settings");
                    let (_, new_active_conn) = nm
                        .add_and_activate_connection(settings, wifi_device.clone(), ap.clone())
                        .await
                        .map_err(|e| {
                            error!("Fresh connection also failed: {e}");
                            e
                        })?;

                    // Wait for the fresh connection to activate
                    let timeout = timeout_config.map(|c| c.connection_timeout);
                    wait_for_connection_activation(conn, &new_active_conn, timeout).await?;
                }
            }
        }

        Err(e) => {
            warn!("activate_connection() failed: {e}");
            warn!("Saved connection may be corrupted, deleting and retrying with fresh connection");

            match delete_connection(conn, saved.clone()).await {
                Ok(_) => debug!("Saved connection deleted"),
                Err(e) => warn!("Failed to delete saved connection during recovery: {}", e),
            }

            let opts = ConnectionOptions {
                autoconnect: true,
                autoconnect_priority: None,
                autoconnect_retries: None,
            };

            let settings = build_wifi_connection(ap.as_str(), creds, &opts);

            let (_, active_conn) = nm
                .add_and_activate_connection(settings, wifi_device.clone(), ap.clone())
                .await
                .map_err(|e| {
                    error!("Fresh connection also failed: {e}");
                    e
                })?;

            // Wait for the fresh connection to activate
            let timeout = timeout_config.map(|c| c.connection_timeout);
            wait_for_connection_activation(conn, &active_conn, timeout).await?;
        }
    }

    Ok(())
}

/// Creates a new connection profile and activates it.
///
/// Builds connection settings from the provided credentials, ensures the
/// device is disconnected, then calls AddAndActivateConnection to create
/// and activate the connection in one step. Monitors activation using
/// D-Bus signals for immediate feedback on success or failure.
async fn build_and_activate_new(
    conn: &Connection,
    nm: &NMProxy<'_>,
    wifi_device: &OwnedObjectPath,
    ap: &OwnedObjectPath,
    ssid: &str,
    creds: WifiSecurity,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    let opts = ConnectionOptions {
        autoconnect: true,
        autoconnect_retries: None,
        autoconnect_priority: None,
    };

    let settings = build_wifi_connection(ssid, &creds, &opts);

    debug!("Creating new connection, settings: \n{settings:#?}");

    ensure_disconnected(conn, nm, wifi_device, timeout_config).await?;

    let (_, active_conn) = match nm
        .add_and_activate_connection(settings, wifi_device.clone(), ap.clone())
        .await
    {
        Ok(paths) => {
            debug!(
                "add_and_activate_connection() succeeded, active connection: {}",
                paths.1.as_str()
            );
            paths
        }
        Err(e) => {
            error!("add_and_activate_connection() failed: {e}");
            return Err(e.into());
        }
    };

    debug!("Waiting for connection activation using signal monitoring...");

    // Wait for connection activation using the ActiveConnection signals
    let timeout = timeout_config.map(|c| c.connection_timeout);
    wait_for_connection_activation(conn, &active_conn, timeout).await?;

    info!("Connection to '{ssid}' activated successfully");

    Ok(())
}

/// Triggers a Wi-Fi scan and finds the target access point.
///
/// Requests a scan, waits briefly for results, then searches for an
/// access point matching the target SSID. The wait time is shorter than
/// polling-based approaches since we just need the scan to populate
/// initial results.
async fn scan_and_resolve_ap(
    conn: &Connection,
    wifi: &NMWirelessProxy<'_>,
    ssid: &str,
) -> Result<OwnedObjectPath> {
    match wifi.request_scan(HashMap::new()).await {
        Ok(_) => debug!("Scan requested successfully"),
        Err(e) => warn!("Scan request failed: {e}"),
    }

    // Brief wait for scan results to populate
    Delay::new(timeouts::scan_wait()).await;
    debug!("Scan wait complete");

    let ap = find_ap(conn, wifi, ssid).await?;
    debug!("Matched target SSID '{ssid}'");
    Ok(ap)
}

/// Decides whether to use a saved connection or create a fresh one.
///
/// Decision logic:
/// - If a saved connection exists and credentials are empty PSK, use saved
///   (user wants to connect with stored password)
/// - If a saved connection exists but new PSK credentials provided, rebuild
///   (user is updating the password)
/// - If no saved connection and PSK is empty, error (can't connect without password)
/// - Otherwise, create a fresh connection
fn decide_saved_connection(
    saved: Option<OwnedObjectPath>,
    creds: &WifiSecurity,
) -> Result<SavedDecision> {
    match saved {
        Some(_) if matches!(creds, WifiSecurity::WpaPsk { psk } if !psk.trim().is_empty()) => {
            Ok(SavedDecision::RebuildFresh)
        }

        Some(path) => Ok(SavedDecision::UseSaved(path)),

        None if matches!(creds, WifiSecurity::WpaPsk { psk } if psk.trim().is_empty()) => {
            Err(ConnectionError::MissingPassword)
        }

        None => Ok(SavedDecision::RebuildFresh),
    }
}

/// Checks if currently connected to the specified SSID.
///
/// If already connected, returns true. Otherwise, returns false.
/// This can be used to skip redundant connection attempts.
pub(crate) async fn is_connected(conn: &Connection, ssid: &str) -> Result<bool> {
    if let Some(active) = current_ssid(conn).await {
        debug!("Currently connected to: {active}");
        if active == ssid {
            debug!("Already connected to {active}");
            return Ok(true);
        }
    } else {
        debug!("Not currently connected to any network");
    }
    Ok(false)
}

/// Disconnects from the currently active network.
///
/// This finds the current active WiFi connection and deactivates it,
/// then waits for the device to reach disconnected state.
///
/// Returns `Ok(())` if disconnected successfully or if no active connection exists.
pub(crate) async fn disconnect(
    conn: &Connection,
    timeout_config: Option<TimeoutConfig>,
) -> Result<()> {
    let nm = NMProxy::new(conn).await?;

    let wifi_device = match find_wifi_device(conn, &nm).await {
        Ok(dev) => dev,
        Err(ConnectionError::NoWifiDevice) => {
            debug!("No WiFi device found");
            return Ok(());
        }
        Err(e) => return Err(e),
    };

    let dev = NMDeviceProxy::builder(conn)
        .path(wifi_device.clone())?
        .build()
        .await?;

    let current_state = dev.state().await?;
    if current_state == device_state::DISCONNECTED || current_state == device_state::UNAVAILABLE {
        debug!("Device already disconnected");
        return Ok(());
    }

    if let Ok(conns) = nm.active_connections().await {
        for conn_path in conns {
            match nm.deactivate_connection(conn_path.clone()).await {
                Ok(_) => debug!("Connection deactivated"),
                Err(e) => warn!("Failed to deactivate connection: {}", e),
            }
        }
    }

    disconnect_wifi_and_wait(conn, &wifi_device, timeout_config).await?;

    info!("Disconnected from network");
    Ok(())
}

/// Finds a device by its interface name.
///
/// Returns the device path if found, or an error if not found.
pub(crate) async fn get_device_by_interface(
    conn: &Connection,
    interface_name: &str,
) -> Result<OwnedObjectPath> {
    let nm = NMProxy::new(conn).await?;
    let devices = nm.get_devices().await?;

    for dev_path in devices {
        let dev = NMDeviceProxy::builder(conn)
            .path(dev_path.clone())?
            .build()
            .await?;

        if let Ok(iface) = dev.interface().await
            && iface == interface_name
        {
            debug!("Found device with interface: {}", interface_name);
            return Ok(dev_path);
        }
    }

    Err(ConnectionError::NotFound)
}