repeater/internal/app/app.go
Pierre-Olivier Mercier 950f73371c station: Surface signal, traffic and connection time in API and UI
Hostapd already parsed signal strength and rx/tx counters but the
station -> ConnectedDevice conversion threw them away. Add signalDbm,
rxBytes, txBytes and connectedAt to the OpenAPI schema and the
ConnectedDevice model, and centralise the conversion in
station.ToConnectedDevice so handlers, the periodic refresh and the
event callbacks all serialise the same shape.

Two follow-on bugs surfaced while wiring this up:

- The hostapd backend only stored station entries on first contact.
  Subsequent polls were dropped, so signal and byte counters never
  refreshed. Reconcile updates in checkStationChanges.
- ConnectedAt was reset to time.Now() on every conversion. Track
  FirstSeen on HostapdStation when the station joins, and preserve
  the timestamp across periodic refreshes in app.go so the UI's
  "connected since" badge is stable.

Frontend gains a metrics row on each device card with signal bars,
total traffic and a live duration. Falls back gracefully when a
backend (DHCP, ARP) doesn't expose these fields.
2026-05-01 22:26:43 +08:00

326 lines
8.7 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
// 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
}
// 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:
}
a.StatusMutex.Lock()
a.Status.Connected = wifi.IsConnected()
a.Status.ConnectionState = wifi.GetConnectionState()
a.Status.ConnectedSSID = wifi.GetConnectedSSID()
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
}
}
}