When the configured Ethernet interface holds a DHCP-assigned IPv4 at
startup, the app now skips wifi.Initialize / StartEventMonitoring and
guards every wifi.* wrapper against a nil backend. This prevents D-Bus
calls to fi.w1.wpa_supplicant1 from re-activating the daemon via
dbus-activation, honoring the "do nothing" intent of the Ethernet path.
The probed state is exposed in SystemStatus and rendered in the header
as a third pill ("Ethernet · <IP>"); a new "disabled" connectionState
covers the WiFi pill in this mode.
361 lines
9.8 KiB
Go
361 lines
9.8 KiB
Go
package app
|
|
|
|
import (
|
|
"embed"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nemunaire/repeater/internal/api"
|
|
"github.com/nemunaire/repeater/internal/config"
|
|
"github.com/nemunaire/repeater/internal/hotspot"
|
|
"github.com/nemunaire/repeater/internal/logging"
|
|
"github.com/nemunaire/repeater/internal/models"
|
|
"github.com/nemunaire/repeater/internal/station"
|
|
"github.com/nemunaire/repeater/internal/station/backend"
|
|
"github.com/nemunaire/repeater/internal/syslog"
|
|
"github.com/nemunaire/repeater/internal/wifi"
|
|
)
|
|
|
|
// App represents the application
|
|
type App struct {
|
|
Status models.SystemStatus
|
|
StatusMutex sync.RWMutex
|
|
StartTime time.Time
|
|
Assets embed.FS
|
|
Config *config.Config
|
|
SyslogTailer *syslog.SyslogTailer
|
|
|
|
stopCh chan struct{}
|
|
stopOnce sync.Once
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// New creates a new application instance
|
|
func New(assets embed.FS) *App {
|
|
return &App{
|
|
Status: models.SystemStatus{
|
|
Connected: false,
|
|
ConnectionState: "disconnected",
|
|
ConnectedSSID: "",
|
|
HotspotStatus: nil,
|
|
ConnectedCount: 0,
|
|
DataUsage: 0.0,
|
|
Uptime: 0,
|
|
},
|
|
StartTime: time.Now(),
|
|
Assets: assets,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Initialize initializes the application
|
|
func (a *App) Initialize(cfg *config.Config) error {
|
|
// Store config reference
|
|
a.Config = cfg
|
|
|
|
// 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()
|
|
|
|
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
|
|
}
|
|
} else {
|
|
log.Printf("Skipping WiFi backend init: Ethernet uplink active on %s", eth.Interface)
|
|
}
|
|
|
|
// Initialize station backend
|
|
stationConfig := backend.BackendConfig{
|
|
InterfaceName: cfg.HotspotInterface,
|
|
ARPTablePath: cfg.ARPTablePath,
|
|
DHCPLeasesPath: cfg.DHCPLeasesPath,
|
|
HostapdInterface: cfg.HotspotInterface,
|
|
}
|
|
if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil {
|
|
log.Printf("Warning: Station backend initialization failed: %v", err)
|
|
// Don't fail - will continue without station discovery
|
|
} else {
|
|
// Start event monitoring for station events
|
|
if err := station.StartEventMonitoring(backend.EventCallbacks{
|
|
OnStationConnected: a.handleStationConnected,
|
|
OnStationDisconnected: a.handleStationDisconnected,
|
|
OnStationUpdated: a.handleStationUpdated,
|
|
}); err != nil {
|
|
log.Printf("Warning: Station event monitoring failed: %v", err)
|
|
// Don't fail - polling fallback still works
|
|
}
|
|
}
|
|
|
|
// Start syslog tailing if enabled
|
|
if cfg.SyslogEnabled {
|
|
a.SyslogTailer = syslog.NewSyslogTailer(
|
|
cfg.SyslogPath,
|
|
cfg.SyslogFilter,
|
|
cfg.SyslogSource,
|
|
)
|
|
if err := a.SyslogTailer.Start(); err != nil {
|
|
log.Printf("Warning: Failed to start syslog tailing: %v", err)
|
|
// Don't fail - app continues without syslog
|
|
}
|
|
}
|
|
|
|
// Start periodic tasks
|
|
a.wg.Add(2)
|
|
go a.periodicStatusUpdate()
|
|
go a.periodicDeviceUpdate()
|
|
|
|
logging.AddLog("Système", "Application initialisée")
|
|
return nil
|
|
}
|
|
|
|
// Run starts the HTTP server
|
|
func (a *App) Run(addr string) error {
|
|
router := api.SetupRouter(&a.Status, &a.StatusMutex, a.Config, a.Assets)
|
|
|
|
logging.AddLog("Système", "Serveur API démarré sur "+addr)
|
|
return router.Run(addr)
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the application. Idempotent — main wires
|
|
// it both to a signal handler and a defer, and the second call must be a
|
|
// no-op (channel close panics otherwise; backend Close paths assume single
|
|
// invocation).
|
|
func (a *App) Shutdown() {
|
|
a.stopOnce.Do(func() {
|
|
// Signal periodic loops to exit, then wait for them so we don't
|
|
// race with backends being closed below.
|
|
close(a.stopCh)
|
|
a.wg.Wait()
|
|
|
|
// Stop syslog tailing if running
|
|
if a.SyslogTailer != nil {
|
|
a.SyslogTailer.Stop()
|
|
}
|
|
|
|
// Stop station monitoring and close backend
|
|
station.StopEventMonitoring()
|
|
station.Close()
|
|
|
|
wifi.StopEventMonitoring()
|
|
wifi.Close()
|
|
logging.AddLog("Système", "Application arrêtée")
|
|
})
|
|
}
|
|
|
|
// getSystemUptime reads system uptime from /proc/uptime
|
|
func getSystemUptime() int64 {
|
|
data, err := os.ReadFile("/proc/uptime")
|
|
if err != nil {
|
|
log.Printf("Error reading /proc/uptime: %v", err)
|
|
return 0
|
|
}
|
|
|
|
fields := strings.Fields(string(data))
|
|
if len(fields) == 0 {
|
|
return 0
|
|
}
|
|
|
|
uptime, err := strconv.ParseFloat(fields[0], 64)
|
|
if err != nil {
|
|
log.Printf("Error parsing uptime: %v", err)
|
|
return 0
|
|
}
|
|
|
|
return int64(uptime)
|
|
}
|
|
|
|
// getInterfaceBytes reads rx and tx bytes for a network interface
|
|
func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) {
|
|
rxPath := "/sys/class/net/" + interfaceName + "/statistics/rx_bytes"
|
|
txPath := "/sys/class/net/" + interfaceName + "/statistics/tx_bytes"
|
|
|
|
// Read RX bytes
|
|
rxData, err := os.ReadFile(rxPath)
|
|
if err != nil {
|
|
log.Printf("Error reading rx_bytes for %s: %v", interfaceName, err)
|
|
} else {
|
|
rxBytes, _ = strconv.ParseInt(strings.TrimSpace(string(rxData)), 10, 64)
|
|
}
|
|
|
|
// Read TX bytes
|
|
txData, err := os.ReadFile(txPath)
|
|
if err != nil {
|
|
log.Printf("Error reading tx_bytes for %s: %v", interfaceName, err)
|
|
} else {
|
|
txBytes, _ = strconv.ParseInt(strings.TrimSpace(string(txData)), 10, 64)
|
|
}
|
|
|
|
return rxBytes, txBytes
|
|
}
|
|
|
|
// periodicStatusUpdate updates WiFi connection status periodically
|
|
func (a *App) periodicStatusUpdate() {
|
|
defer a.wg.Done()
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-a.stopCh:
|
|
return
|
|
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()
|
|
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
|
|
a.Status.HotspotStatus = hotspot.GetDetailedStatus()
|
|
|
|
// Get network data usage for WiFi interface
|
|
if a.Config != nil {
|
|
rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface)
|
|
// Convert to MB and sum rx + tx
|
|
a.Status.DataUsage = float64(rxBytes+txBytes) / (1024 * 1024)
|
|
}
|
|
|
|
a.StatusMutex.Unlock()
|
|
}
|
|
}
|
|
|
|
// periodicDeviceUpdate updates connected devices list periodically
|
|
func (a *App) periodicDeviceUpdate() {
|
|
defer a.wg.Done()
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-a.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
|
|
devices, err := station.GetStations()
|
|
if err != nil {
|
|
log.Printf("Error getting connected devices: %v", err)
|
|
}
|
|
|
|
a.StatusMutex.Lock()
|
|
// Preserve ConnectedAt across refreshes so the UI's "connected since"
|
|
// timestamp doesn't reset every poll. Backends that don't track a
|
|
// real connection time return time.Now() each call.
|
|
previous := make(map[string]time.Time, len(a.Status.ConnectedDevices))
|
|
for _, d := range a.Status.ConnectedDevices {
|
|
if !d.ConnectedAt.IsZero() {
|
|
previous[d.MAC] = d.ConnectedAt
|
|
}
|
|
}
|
|
for i := range devices {
|
|
if t, ok := previous[devices[i].MAC]; ok {
|
|
devices[i].ConnectedAt = t
|
|
}
|
|
}
|
|
a.Status.ConnectedDevices = devices
|
|
a.Status.ConnectedCount = len(devices)
|
|
a.StatusMutex.Unlock()
|
|
}
|
|
}
|
|
|
|
// handleStationConnected handles station connection events
|
|
func (a *App) handleStationConnected(st backend.Station) {
|
|
a.StatusMutex.Lock()
|
|
defer a.StatusMutex.Unlock()
|
|
|
|
device := station.ToConnectedDevice(st)
|
|
|
|
// Check if device already exists
|
|
found := false
|
|
for i, d := range a.Status.ConnectedDevices {
|
|
if d.MAC == device.MAC {
|
|
a.Status.ConnectedDevices[i] = device
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add new device if not found
|
|
if !found {
|
|
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device)
|
|
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
|
|
logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")")
|
|
}
|
|
}
|
|
|
|
// handleStationDisconnected handles station disconnection events
|
|
func (a *App) handleStationDisconnected(mac string) {
|
|
a.StatusMutex.Lock()
|
|
defer a.StatusMutex.Unlock()
|
|
|
|
// Remove device from list
|
|
for i, d := range a.Status.ConnectedDevices {
|
|
if d.MAC == mac {
|
|
a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...)
|
|
a.Status.ConnectedCount = len(a.Status.ConnectedDevices)
|
|
logging.AddLog("Stations", "Device disconnected: "+mac)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleStationUpdated handles station update events
|
|
func (a *App) handleStationUpdated(st backend.Station) {
|
|
a.StatusMutex.Lock()
|
|
defer a.StatusMutex.Unlock()
|
|
|
|
// Update existing device. Preserve the original ConnectedAt so the
|
|
// device card doesn't reset its "connected since" badge each time the
|
|
// IP or signal updates.
|
|
for i, d := range a.Status.ConnectedDevices {
|
|
if d.MAC == st.MAC {
|
|
updated := station.ToConnectedDevice(st)
|
|
if !d.ConnectedAt.IsZero() {
|
|
updated.ConnectedAt = d.ConnectedAt
|
|
}
|
|
a.Status.ConnectedDevices[i] = updated
|
|
break
|
|
}
|
|
}
|
|
}
|