diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js
index d2a0012..75240c1 100644
--- a/cmd/repeater/static/app.js
+++ b/cmd/repeater/static/app.js
@@ -273,6 +273,10 @@ function updateStatusDisplay(status) {
wifiDot.className = 'status-dot active';
wifiText.textContent = `Roaming: ${status.connectedSSID}`;
break;
+ case 'disabled':
+ wifiDot.className = 'status-dot offline';
+ wifiText.textContent = 'WiFi désactivé';
+ break;
case 'disconnected':
default:
wifiDot.className = 'status-dot offline';
@@ -280,6 +284,16 @@ function updateStatusDisplay(status) {
break;
}
+ // Update Ethernet uplink badge
+ const ethernetStatus = document.getElementById('ethernetStatus');
+ const ethernetText = ethernetStatus.querySelector('.status-text');
+ if (status.ethernetStatus?.active) {
+ ethernetText.textContent = `Ethernet · ${status.ethernetStatus.ipv4 || status.ethernetStatus.interface}`;
+ ethernetStatus.hidden = false;
+ } else {
+ ethernetStatus.hidden = true;
+ }
+
// Update hotspot status badge
const hotspotStatus = document.getElementById('hotspotStatus');
const hotspotDot = hotspotStatus.querySelector('.status-dot');
diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html
index 203e607..b0f96b8 100644
--- a/cmd/repeater/static/index.html
+++ b/cmd/repeater/static/index.html
@@ -25,6 +25,10 @@
Disconnected
+
+
+ Ethernet
+
Hotspot actif
diff --git a/internal/app/app.go b/internal/app/app.go
index 8c3d315..b7106a3 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -57,20 +57,28 @@ func (a *App) Initialize(cfg *config.Config) error {
// Store config reference
a.Config = cfg
- // If Ethernet uplink is not already providing connectivity (no DHCP lease
- // on the configured interface), bring up wpa_supplicant so the WiFi
- // backend has something to talk to.
- ensureUplink(cfg.EthernetInterface)
+ // Decide whether the Ethernet uplink already provides connectivity. When
+ // it does we deliberately skip every wpa_supplicant interaction below —
+ // the daemon is dbus-activatable, so any call into the WiFi backend
+ // would re-spawn it and undo this choice.
+ eth := ensureUplink(cfg.EthernetInterface)
+ a.StatusMutex.Lock()
+ a.Status.EthernetStatus = eth
+ a.StatusMutex.Unlock()
- // Initialize WiFi backend
- if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
- return err
- }
+ if !eth.Active {
+ // Initialize WiFi backend
+ if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil {
+ return err
+ }
- // Start WiFi event monitoring
- if err := wifi.StartEventMonitoring(); err != nil {
- log.Printf("Warning: WiFi event monitoring failed: %v", err)
- // Don't fail - polling fallback still works
+ // Start WiFi event monitoring
+ if err := wifi.StartEventMonitoring(); err != nil {
+ log.Printf("Warning: WiFi event monitoring failed: %v", err)
+ // Don't fail - polling fallback still works
+ }
+ } else {
+ log.Printf("Skipping WiFi backend init: Ethernet uplink active on %s", eth.Interface)
}
// Initialize station backend
@@ -210,10 +218,32 @@ func (a *App) periodicStatusUpdate() {
case <-ticker.C:
}
+ var eth *models.EthernetStatus
+ if a.Config != nil {
+ if e, err := probeEthernet(a.Config.EthernetInterface); err == nil {
+ eth = e
+ }
+ }
+
a.StatusMutex.Lock()
- a.Status.Connected = wifi.IsConnected()
- a.Status.ConnectionState = wifi.GetConnectionState()
- a.Status.ConnectedSSID = wifi.GetConnectedSSID()
+ if eth != nil {
+ a.Status.EthernetStatus = eth
+ }
+
+ // Skip every wifi.* call when the Ethernet uplink is the chosen
+ // path: the WiFi backend isn't initialized in that mode and the
+ // wrappers would otherwise return zero values; either way we don't
+ // want to risk dbus-activating wpa_supplicant from this hot loop.
+ ethActive := a.Status.EthernetStatus != nil && a.Status.EthernetStatus.Active
+ if !ethActive {
+ a.Status.Connected = wifi.IsConnected()
+ a.Status.ConnectionState = wifi.GetConnectionState()
+ a.Status.ConnectedSSID = wifi.GetConnectedSSID()
+ } else {
+ a.Status.Connected = false
+ a.Status.ConnectionState = "disabled"
+ a.Status.ConnectedSSID = ""
+ }
a.Status.Uptime = getSystemUptime()
// Get detailed hotspot status
diff --git a/internal/app/network.go b/internal/app/network.go
index b13dc85..557dffb 100644
--- a/internal/app/network.go
+++ b/internal/app/network.go
@@ -5,50 +5,64 @@ import (
"log"
"os/exec"
"strings"
+
+ "github.com/nemunaire/repeater/internal/models"
)
-// hasDHCPAddress reports whether iface holds an IPv4 address that ip(8) marks
-// as "dynamic" — the flag set on addresses with a finite valid_lft, which is
-// how DHCP-assigned leases appear (static addresses are "permanent").
-func hasDHCPAddress(iface string) (bool, error) {
+// probeEthernet reports the wired uplink state of iface by parsing
+// `ip -4 -o addr show dev `. An IPv4 line carrying the "dynamic" flag
+// (set by ip(8) on addresses with a finite valid_lft) is treated as a
+// DHCP-assigned lease — that's the signal we use to decide whether the box
+// already has connectivity through Ethernet.
+func probeEthernet(iface string) (*models.EthernetStatus, error) {
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "dev", iface).Output()
if err != nil {
- return false, fmt.Errorf("ip addr show %s: %w", iface, err)
+ return nil, fmt.Errorf("ip addr show %s: %w", iface, err)
}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
+ var addr string
hasInet := false
hasDynamic := false
- for _, f := range fields {
+ for i, f := range fields {
switch f {
case "inet":
hasInet = true
+ if i+1 < len(fields) {
+ addr = fields[i+1]
+ }
case "dynamic":
hasDynamic = true
}
}
if hasInet && hasDynamic {
- return true, nil
+ if slash := strings.IndexByte(addr, '/'); slash >= 0 {
+ addr = addr[:slash]
+ }
+ return &models.EthernetStatus{Active: true, Interface: iface, IPv4: addr}, nil
}
}
- return false, nil
+ return &models.EthernetStatus{Active: false, Interface: iface}, nil
}
-// ensureUplink boots the WiFi uplink only if Ethernet is not already providing
-// connectivity: when iface has no DHCP-assigned IPv4, start wpa_supplicant so
-// the WiFi backend can take over.
-func ensureUplink(iface string) {
- has, err := hasDHCPAddress(iface)
+// ensureUplink returns the current Ethernet status, and starts wpa_supplicant
+// when no DHCP-assigned address is present. When Ethernet is already
+// providing connectivity the WiFi backend is left alone — and crucially the
+// caller must avoid any D-Bus call to fi.w1.wpa_supplicant1, which would
+// otherwise re-activate the daemon via dbus-activation.
+func ensureUplink(iface string) *models.EthernetStatus {
+ eth, err := probeEthernet(iface)
if err != nil {
- log.Printf("Could not probe %s for DHCP address (%v); starting wpa_supplicant as fallback", iface, err)
- } else if has {
- log.Printf("DHCP address present on %s, leaving wpa_supplicant alone", iface)
- return
- } else {
- log.Printf("No DHCP address on %s, starting wpa_supplicant", iface)
+ log.Printf("Could not probe %s (%v); starting wpa_supplicant as fallback", iface, err)
+ eth = &models.EthernetStatus{Active: false, Interface: iface}
}
-
+ if eth.Active {
+ log.Printf("DHCP address %s on %s, leaving wpa_supplicant alone", eth.IPv4, iface)
+ return eth
+ }
+ log.Printf("No DHCP address on %s, starting wpa_supplicant", iface)
if out, err := exec.Command("service", "wpa_supplicant", "start").CombinedOutput(); err != nil {
log.Printf("Failed to start wpa_supplicant: %v: %s", err, strings.TrimSpace(string(out)))
}
+ return eth
}
diff --git a/internal/models/models.go b/internal/models/models.go
index 138aeb1..3925a68 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -31,6 +31,14 @@ type HotspotConfig struct {
Channel int `json:"channel"`
}
+// EthernetStatus represents the state of the wired uplink interface.
+// Active is true when the interface holds a DHCP-assigned IPv4 address.
+type EthernetStatus struct {
+ Active bool `json:"active"`
+ Interface string `json:"interface"`
+ IPv4 string `json:"ipv4,omitempty"`
+}
+
// HotspotStatus represents detailed hotspot status
type HotspotStatus struct {
State string `json:"state"` // ENABLED, DISABLED, etc.
@@ -49,6 +57,7 @@ type SystemStatus struct {
ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
ConnectedSSID string `json:"connectedSSID"`
HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
+ EthernetStatus *EthernetStatus `json:"ethernetStatus,omitempty"` // Wired uplink state, when probed
ConnectedCount int `json:"connectedCount"`
DataUsage float64 `json:"dataUsage"`
Uptime int64 `json:"uptime"`
diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go
index dac04c3..9365987 100644
--- a/internal/wifi/wifi.go
+++ b/internal/wifi/wifi.go
@@ -1,6 +1,7 @@
package wifi
import (
+ "errors"
"fmt"
"sort"
"strings"
@@ -16,6 +17,13 @@ var (
wifiBroadcaster *WifiBroadcaster
)
+// errWifiDisabled is returned by every wifi.* wrapper when no backend has
+// been initialized. This happens when the application chose not to start
+// wpa_supplicant because the Ethernet uplink is already providing
+// connectivity — touching the D-Bus interface in that mode would re-activate
+// the daemon via dbus-activation, defeating the intent.
+var errWifiDisabled = errors.New("wifi backend disabled (Ethernet uplink active)")
+
// Initialize initializes the WiFi service with the specified backend
func Initialize(interfaceName string, backendName string) error {
// Create the appropriate backend using the factory
@@ -38,6 +46,9 @@ func Close() {
// GetCachedNetworks returns previously discovered networks without triggering a scan
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
+ if wifiBackend == nil {
+ return nil, errWifiDisabled
+ }
// Get ordered networks from backend
backendNetworks, err := wifiBackend.GetOrderedNetworks()
if err != nil {
@@ -67,6 +78,9 @@ func GetCachedNetworks() ([]models.WiFiNetwork, error) {
// ScanNetworks scans for available WiFi networks
func ScanNetworks() ([]models.WiFiNetwork, error) {
+ if wifiBackend == nil {
+ return nil, errWifiDisabled
+ }
// Check if already scanning
scanning, err := wifiBackend.IsScanning()
if err == nil && scanning {
@@ -114,6 +128,9 @@ func ScanNetworks() ([]models.WiFiNetwork, error) {
// Connect connects to a WiFi network
func Connect(ssid, password string) error {
+ if wifiBackend == nil {
+ return errWifiDisabled
+ }
// Use backend to connect
if err := wifiBackend.Connect(ssid, password); err != nil {
return err
@@ -132,11 +149,17 @@ func Connect(ssid, password string) error {
// Disconnect disconnects from the current WiFi network
func Disconnect() error {
+ if wifiBackend == nil {
+ return errWifiDisabled
+ }
return wifiBackend.Disconnect()
}
// IsConnected checks if WiFi is connected
func IsConnected() bool {
+ if wifiBackend == nil {
+ return false
+ }
state, err := wifiBackend.GetConnectionState()
if err != nil {
return false
@@ -146,11 +169,17 @@ func IsConnected() bool {
// GetConnectedSSID returns the SSID of the currently connected network
func GetConnectedSSID() string {
+ if wifiBackend == nil {
+ return ""
+ }
return wifiBackend.GetConnectedSSID()
}
// GetConnectionState returns the current WiFi connection state
func GetConnectionState() string {
+ if wifiBackend == nil {
+ return string(backend.StateDisconnected)
+ }
state, err := wifiBackend.GetConnectionState()
if err != nil {
return string(backend.StateDisconnected)
@@ -160,6 +189,9 @@ func GetConnectionState() string {
// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
func StartEventMonitoring() error {
+ if wifiBackend == nil {
+ return nil
+ }
// Initialize broadcaster
wifiBroadcaster = NewWifiBroadcaster()
diff --git a/openapi.yaml b/openapi.yaml
index f3132c4..a6bfa9b 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -369,6 +369,26 @@ components:
- ssid
- password
+ EthernetStatus:
+ type: object
+ description: State of the wired uplink interface (probed via ip(8))
+ properties:
+ active:
+ type: boolean
+ description: True when the interface holds a DHCP-assigned IPv4
+ example: true
+ interface:
+ type: string
+ description: Probed interface name
+ example: "eth0"
+ ipv4:
+ type: string
+ description: DHCP-assigned IPv4 address (empty when no lease)
+ example: "192.168.1.42"
+ required:
+ - active
+ - interface
+
HotspotStatus:
type: object
description: Detailed hotspot status from hostapd_cli
@@ -480,13 +500,14 @@ components:
example: true
connectionState:
type: string
- description: Current WiFi connection state
+ description: Current WiFi connection state ("disabled" when Ethernet uplink is active and the WiFi backend is intentionally not initialized)
enum:
- connected
- disconnected
- connecting
- disconnecting
- roaming
+ - disabled
example: "connected"
connectedSSID:
type: string
@@ -497,6 +518,11 @@ components:
- $ref: '#/components/schemas/HotspotStatus'
nullable: true
description: Detailed hotspot status (null if hotspot is not running)
+ ethernetStatus:
+ allOf:
+ - $ref: '#/components/schemas/EthernetStatus'
+ nullable: true
+ description: Wired uplink state (null when not yet probed)
connectedCount:
type: integer
description: Number of devices connected to hotspot