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 } } }