repeater/internal/wifi/wpasupplicant/backend.go
Pierre-Olivier Mercier 77370eff19 wpa_supplicant: Reuse existing networks and preserve saved ones on connect
Connect() called AddNetwork unconditionally, creating duplicate entries for
the same SSID, and SelectNetwork's side-effect of disabling all other
networks was being persisted by SaveConfig — making previously saved
networks appear erased. Disconnect() also removed the current network from
the config. Now reuse an existing network entry when the SSID matches,
re-enable other networks after SelectNetwork, and keep entries on
disconnect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:25:30 +08:00

301 lines
7.4 KiB
Go

package wpasupplicant
import (
"fmt"
"time"
"github.com/godbus/dbus/v5"
"github.com/nemunaire/repeater/internal/wifi/backend"
)
// WPABackend implements the WiFiBackend interface for wpa_supplicant
type WPABackend struct {
conn *dbus.Conn
wpasupplicant dbus.BusObject
iface *WPAInterface
signalMonitor *SignalMonitor
interfaceName string
currentNetwork dbus.ObjectPath
}
// NewWPABackend creates a new wpa_supplicant backend instance
func NewWPABackend() *WPABackend {
return &WPABackend{}
}
// Initialize initializes the wpa_supplicant backend with the given interface name
func (b *WPABackend) Initialize(interfaceName string) error {
b.interfaceName = interfaceName
var err error
// Connect to D-Bus
b.conn, err = dbus.SystemBus()
if err != nil {
return fmt.Errorf("failed to connect to D-Bus: %v", err)
}
// Get wpa_supplicant root object
b.wpasupplicant = b.conn.Object(Service, dbus.ObjectPath(RootPath))
// Get interface path for the given interface name
interfacePath, err := b.getInterfacePath(interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface for %s: %v", interfaceName, err)
}
b.iface = NewWPAInterface(b.conn, interfacePath)
return nil
}
// getInterfacePath gets or creates the wpa_supplicant Interface object path
func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, error) {
var interfacePath dbus.ObjectPath
// Try to get existing interface
err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath)
if err == nil {
return interfacePath, nil
}
// Interface doesn't exist, create it
args := map[string]dbus.Variant{
"Ifname": dbus.MakeVariant(interfaceName),
}
err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath)
if err != nil {
return "", fmt.Errorf("failed to create interface: %v", err)
}
return interfacePath, nil
}
// Close closes the D-Bus connection
func (b *WPABackend) Close() error {
if b.conn != nil {
b.conn.Close()
}
return nil
}
// ScanNetworks triggers a network scan
func (b *WPABackend) ScanNetworks() error {
err := b.iface.Scan("active")
if err != nil {
return fmt.Errorf("failed to trigger scan: %v", err)
}
return nil
}
// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
func (b *WPABackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
// Get BSS list
bssPaths, err := b.iface.GetBSSs()
if err != nil {
return nil, fmt.Errorf("failed to get BSSs: %v", err)
}
var networks []backend.BackendNetwork
seenSSIDs := make(map[string]bool)
// Iterate through BSSs and collect network info
for _, bssPath := range bssPaths {
bss := NewBSS(b.conn, bssPath)
props, err := bss.GetProperties()
if err != nil {
continue
}
ssid := string(props.SSID)
if ssid == "" || seenSSIDs[ssid] {
continue
}
seenSSIDs[ssid] = true
// Get BSSID string
bssidStr, err := bss.GetBSSIDString()
if err != nil {
bssidStr = ""
}
// Convert to backend-agnostic format
backendNet := backend.BackendNetwork{
SSID: ssid,
SignalDBm: props.Signal,
SecurityType: props.DetermineSecurityType(),
BSSID: bssidStr,
Frequency: props.Frequency,
}
networks = append(networks, backendNet)
}
// Sort by signal strength (descending)
// Note: This is a simple bubble sort for demonstration
// In production, use sort.Slice
for i := 0; i < len(networks); i++ {
for j := i + 1; j < len(networks); j++ {
if networks[j].SignalDBm > networks[i].SignalDBm {
networks[i], networks[j] = networks[j], networks[i]
}
}
}
return networks, nil
}
// IsScanning checks if a scan is currently in progress
func (b *WPABackend) IsScanning() (bool, error) {
return b.iface.GetScanning()
}
// Connect connects to a WiFi network
func (b *WPABackend) Connect(ssid, password string) error {
// Look up existing network with the same SSID to avoid creating duplicates
existingNetworks, err := b.iface.GetNetworks()
if err != nil {
return fmt.Errorf("failed to list configured networks: %v", err)
}
var networkPath dbus.ObjectPath
createdNew := false
for _, p := range existingNetworks {
net := NewNetwork(b.conn, p)
netSSID, err := net.GetSSID()
if err != nil || netSSID != ssid {
continue
}
networkPath = p
break
}
if networkPath == "" {
// Create network configuration
config := make(map[string]interface{})
config["ssid"] = ssid // Raw SSID string, no quotes
if password != "" {
// For WPA/WPA2-PSK networks
config["psk"] = password // Raw password string, no quotes
} else {
// For open networks
config["key_mgmt"] = "NONE"
}
networkPath, err = b.iface.AddNetwork(config)
if err != nil {
return fmt.Errorf("failed to add network: %v", err)
}
createdNew = true
}
// Store current network path
b.currentNetwork = networkPath
// Select (connect to) the network. Note: SelectNetwork disables every
// other configured network as a side-effect.
err = b.iface.SelectNetwork(networkPath)
if err != nil {
if createdNew {
b.iface.RemoveNetwork(networkPath)
}
return fmt.Errorf("failed to select network: %v", err)
}
// Re-enable previously configured networks so SelectNetwork's side-effect
// doesn't mark them all as disabled in the persisted config.
for _, p := range existingNetworks {
if p == networkPath {
continue
}
if err := b.iface.EnableNetwork(p); err != nil {
fmt.Printf("Warning: failed to re-enable network %s: %v\n", p, err)
}
}
// Save the configuration to persist it across reboots
if err := b.iface.SaveConfig(); err != nil {
// Log warning but don't fail - connection still works
fmt.Printf("Warning: failed to save config: %v\n", err)
}
return nil
}
// Disconnect disconnects from the current WiFi network
func (b *WPABackend) Disconnect() error {
// Disconnect from current network
if err := b.iface.Disconnect(); err != nil {
return fmt.Errorf("failed to disconnect: %v", err)
}
b.currentNetwork = ""
return nil
}
// GetConnectionState returns the current WiFi connection state
func (b *WPABackend) GetConnectionState() (backend.ConnectionState, error) {
state, err := b.iface.GetState()
if err != nil {
return backend.StateDisconnected, err
}
return mapWPAState(state), nil
}
// GetConnectedSSID returns the SSID of the currently connected network
func (b *WPABackend) GetConnectedSSID() string {
// Get current BSS
bssPath, err := b.iface.GetCurrentBSS()
if err != nil || bssPath == "/" {
return ""
}
// Get BSS object
bss := NewBSS(b.conn, bssPath)
ssid, err := bss.GetSSIDString()
if err != nil {
return ""
}
return ssid
}
// StartEventMonitoring starts monitoring WiFi events
func (b *WPABackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
// Create signal monitor
b.signalMonitor = NewSignalMonitor(b.conn, b.iface)
// Start monitoring
return b.signalMonitor.Start(callbacks)
}
// StopEventMonitoring stops monitoring WiFi events
func (b *WPABackend) StopEventMonitoring() {
if b.signalMonitor != nil {
b.signalMonitor.Stop()
}
}
// Wait for scan to complete (helper method)
func (b *WPABackend) waitForScanComplete(timeout time.Duration) error {
start := time.Now()
for {
if time.Since(start) > timeout {
return fmt.Errorf("scan timeout")
}
scanning, err := b.iface.GetScanning()
if err != nil {
return err
}
if !scanning {
return nil
}
time.Sleep(100 * time.Millisecond)
}
}