Add websocket wifi updates

This commit is contained in:
nemunaire 2026-01-01 17:42:53 +07:00
commit 1477d909b0
11 changed files with 755 additions and 10 deletions

View file

@ -4,7 +4,9 @@ const appState = {
hotspotEnabled: true,
autoScrollLogs: true,
ws: null,
wifiWs: null,
reconnectAttempts: 0,
wifiReconnectAttempts: 0,
maxReconnectAttempts: 5,
connectedSSID: null,
networks: [],
@ -28,8 +30,9 @@ async function initializeApp() {
loadLogs()
]);
// Set up WebSocket for real-time logs
// Set up WebSockets for real-time updates
connectWebSocket();
connectWifiWebSocket();
// Start periodic updates
startPeriodicUpdates();
@ -72,8 +75,20 @@ async function scanWifi() {
showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`);
} catch (error) {
console.error('Error scanning WiFi:', error);
wifiList.innerHTML = '<div class="wifi-item loading"><span style="color: var(--danger-color);">Erreur lors du scan</span></div>';
showToast('error', 'Erreur', 'Échec du scan WiFi');
// Fallback to cached networks
try {
const fallbackResponse = await fetch('/api/wifi/networks');
const cachedNetworks = await fallbackResponse.json();
appState.networks = cachedNetworks;
displayWifiNetworks(cachedNetworks);
showToast('warning', 'Scan échoué', `Affichage des réseaux en cache (${cachedNetworks.length})`);
} catch (fallbackError) {
console.error('Error loading cached networks:', fallbackError);
wifiList.innerHTML = '<div class="wifi-item loading"><span style="color: var(--danger-color);">Erreur lors du scan</span></div>';
showToast('error', 'Erreur', 'Échec du scan WiFi');
}
} finally {
if (scanBtn) {
scanBtn.disabled = false;
@ -491,6 +506,108 @@ function connectWebSocket() {
}
}
function connectWifiWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/wifi`;
try {
appState.wifiWs = new WebSocket(wsUrl);
appState.wifiWs.onopen = function() {
console.log('WiFi WebSocket connected');
appState.wifiReconnectAttempts = 0;
};
appState.wifiWs.onmessage = function(event) {
try {
const msg = JSON.parse(event.data);
handleWifiEvent(msg);
} catch (error) {
console.error('Error parsing WiFi WebSocket message:', error);
}
};
appState.wifiWs.onerror = function(error) {
console.error('WiFi WebSocket error:', error);
};
appState.wifiWs.onclose = function() {
console.log('WiFi WebSocket disconnected');
// Attempt to reconnect
if (appState.wifiReconnectAttempts < appState.maxReconnectAttempts) {
appState.wifiReconnectAttempts++;
setTimeout(connectWifiWebSocket, 5000);
}
};
} catch (error) {
console.error('Error creating WiFi WebSocket:', error);
}
}
function handleWifiEvent(event) {
console.log('WiFi event received:', event.type, event);
switch (event.type) {
case 'scan_update':
handleScanUpdate(event.data);
break;
case 'state_change':
handleStateChange(event.data);
break;
case 'signal_update':
handleSignalUpdate(event.data);
break;
default:
console.warn('Unknown WiFi event type:', event.type);
}
}
function handleScanUpdate(data) {
// Update the network list in real-time
appState.networks = data.networks;
displayWifiNetworks(data.networks);
console.log(`Scan update: ${data.networks.length} network(s) found`);
}
function handleStateChange(data) {
// Update WiFi status badge
const wifiStatus = document.getElementById('wifiStatus');
const wifiDot = wifiStatus.querySelector('.status-dot');
const wifiText = wifiStatus.querySelector('.status-text');
if (data.state === 'connected') {
wifiDot.className = 'status-dot active';
wifiText.textContent = `Connecté: ${data.ssid}`;
appState.connectedSSID = data.ssid;
// Refresh network list to show connected network
if (appState.networks.length > 0) {
displayWifiNetworks(appState.networks);
}
} else if (data.state === 'disconnected') {
wifiDot.className = 'status-dot offline';
wifiText.textContent = 'Déconnecté';
appState.connectedSSID = null;
// Refresh network list to remove connected highlighting
if (appState.networks.length > 0) {
displayWifiNetworks(appState.networks);
}
} else if (data.state === 'connecting') {
wifiDot.className = 'status-dot';
wifiText.textContent = `Connexion à ${data.ssid}...`;
}
console.log(`WiFi state changed: ${data.previous_state}${data.state}`, data.ssid);
}
function handleSignalUpdate(data) {
// Update signal strength display if needed
console.log(`Signal update for ${data.ssid}: ${data.signal}/5 (${data.dbm} dBm)`);
// Could update the network list to reflect new signal strength
}
// ===== Utility Functions =====
function generateSignalBars(strength) {

View file

@ -12,6 +12,18 @@ import (
"github.com/nemunaire/repeater/internal/wifi"
)
// GetWiFiNetworks returns cached WiFi networks without scanning
func GetWiFiNetworks(c *gin.Context) {
networks, err := wifi.GetCachedNetworks()
if err != nil {
logging.AddLog("WiFi", "Erreur lors de la récupération des réseaux: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des réseaux"})
return
}
c.JSON(http.StatusOK, networks)
}
// ScanWiFi handles WiFi network scanning
func ScanWiFi(c *gin.Context) {
networks, err := wifi.ScanNetworks()

View file

@ -0,0 +1,30 @@
package handlers
import (
"log"
"github.com/gin-gonic/gin"
"github.com/nemunaire/repeater/internal/wifi"
)
// WebSocketWifi handles WebSocket connections for real-time WiFi events
func WebSocketWifi(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("Erreur WebSocket WiFi: %v", err)
return
}
defer conn.Close()
// Register client
wifi.RegisterWebSocketClient(conn)
defer wifi.UnregisterWebSocketClient(conn)
// Keep connection alive
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}

View file

@ -24,6 +24,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
// WiFi endpoints
wifi := api.Group("/wifi")
{
wifi.GET("/networks", handlers.GetWiFiNetworks)
wifi.GET("/scan", handlers.ScanWiFi)
wifi.POST("/connect", handlers.ConnectWiFi)
wifi.POST("/disconnect", handlers.DisconnectWiFi)
@ -53,8 +54,9 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F
api.DELETE("/logs", handlers.ClearLogs)
}
// WebSocket endpoint
// WebSocket endpoints
r.GET("/ws/logs", handlers.WebSocketLogs)
r.GET("/ws/wifi", handlers.WebSocketWifi)
// Serve static files
sub, err := fs.Sub(assets, "static")

View file

@ -53,6 +53,12 @@ func (a *App) Initialize(cfg *config.Config) error {
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 periodic tasks
go a.periodicStatusUpdate()
go a.periodicDeviceUpdate()
@ -71,6 +77,7 @@ func (a *App) Run(addr string) error {
// Shutdown gracefully shuts down the application
func (a *App) Shutdown() {
wifi.StopEventMonitoring()
wifi.Close()
logging.AddLog("Système", "Application arrêtée")
}

View file

@ -0,0 +1,140 @@
package wifi
import (
"log"
"sync"
"github.com/gorilla/websocket"
"github.com/nemunaire/repeater/internal/models"
)
// WifiBroadcaster manages WebSocket clients and broadcasts WiFi events
type WifiBroadcaster struct {
clients map[*websocket.Conn]bool
clientsMu sync.RWMutex
// State deduplication
lastState string
lastSSID string
lastNetworks []models.WiFiNetwork
stateMu sync.RWMutex
}
// NewWifiBroadcaster creates a new WiFi broadcaster
func NewWifiBroadcaster() *WifiBroadcaster {
return &WifiBroadcaster{
clients: make(map[*websocket.Conn]bool),
}
}
// RegisterClient registers a new WebSocket client
func (wb *WifiBroadcaster) RegisterClient(conn *websocket.Conn) {
wb.clientsMu.Lock()
wb.clients[conn] = true
wb.clientsMu.Unlock()
// Send initial state to the new client
wb.sendInitialState(conn)
}
// UnregisterClient removes a WebSocket client
func (wb *WifiBroadcaster) UnregisterClient(conn *websocket.Conn) {
wb.clientsMu.Lock()
delete(wb.clients, conn)
wb.clientsMu.Unlock()
}
// sendInitialState sends the current WiFi state to a newly connected client
func (wb *WifiBroadcaster) sendInitialState(conn *websocket.Conn) {
wb.stateMu.RLock()
lastState := wb.lastState
lastSSID := wb.lastSSID
lastNetworks := make([]models.WiFiNetwork, len(wb.lastNetworks))
copy(lastNetworks, wb.lastNetworks)
wb.stateMu.RUnlock()
// Send last known state if available
if lastState != "" {
event := NewStateChangeEvent(lastState, lastSSID, "")
conn.WriteJSON(event)
}
// Send last known network list if available
if len(lastNetworks) > 0 {
event := NewScanUpdateEvent(lastNetworks)
conn.WriteJSON(event)
}
}
// BroadcastScanUpdate broadcasts a scan update event to all clients
func (wb *WifiBroadcaster) BroadcastScanUpdate(networks []models.WiFiNetwork) {
// Check for changes to avoid duplicate broadcasts
wb.stateMu.Lock()
if networksEqual(wb.lastNetworks, networks) {
wb.stateMu.Unlock()
return
}
wb.lastNetworks = make([]models.WiFiNetwork, len(networks))
copy(wb.lastNetworks, networks)
wb.stateMu.Unlock()
event := NewScanUpdateEvent(networks)
wb.broadcast(event)
}
// BroadcastStateChange broadcasts a state change event to all clients
func (wb *WifiBroadcaster) BroadcastStateChange(state, ssid string) {
// Check for changes to avoid duplicate broadcasts
wb.stateMu.Lock()
if wb.lastState == state && wb.lastSSID == ssid {
wb.stateMu.Unlock()
return
}
previousState := wb.lastState
wb.lastState = state
wb.lastSSID = ssid
wb.stateMu.Unlock()
event := NewStateChangeEvent(state, ssid, previousState)
wb.broadcast(event)
}
// BroadcastSignalUpdate broadcasts a signal update event to all clients
func (wb *WifiBroadcaster) BroadcastSignalUpdate(ssid string, signal, dbm int) {
event := NewSignalUpdateEvent(ssid, signal, dbm)
wb.broadcast(event)
}
// broadcast sends an event to all connected clients
func (wb *WifiBroadcaster) broadcast(event WifiEvent) {
// Get list of clients with read lock
wb.clientsMu.RLock()
clients := make([]*websocket.Conn, 0, len(wb.clients))
for client := range wb.clients {
clients = append(clients, client)
}
wb.clientsMu.RUnlock()
// Broadcast to all clients
for _, client := range clients {
err := client.WriteJSON(event)
if err != nil {
log.Printf("Erreur lors de l'envoi WebSocket WiFi: %v", err)
client.Close()
wb.UnregisterClient(client)
}
}
}
// networksEqual compares two network slices for equality
func networksEqual(a, b []models.WiFiNetwork) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].SSID != b[i].SSID || a[i].Signal != b[i].Signal {
return false
}
}
return true
}

70
internal/wifi/events.go Normal file
View file

@ -0,0 +1,70 @@
package wifi
import (
"time"
"github.com/nemunaire/repeater/internal/models"
)
// WifiEvent represents a WiFi event to be sent over WebSocket
type WifiEvent struct {
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data interface{} `json:"data"`
}
// ScanUpdateData contains network list update information
type ScanUpdateData struct {
Networks []models.WiFiNetwork `json:"networks"`
}
// StateChangeData contains connection state change information
type StateChangeData struct {
State string `json:"state"`
SSID string `json:"ssid,omitempty"`
PreviousState string `json:"previous_state,omitempty"`
}
// SignalUpdateData contains signal strength update information
type SignalUpdateData struct {
SSID string `json:"ssid"`
Signal int `json:"signal"` // 1-5 scale
DBm int `json:"dbm"` // Raw dBm value
}
// NewScanUpdateEvent creates a new scan update event
func NewScanUpdateEvent(networks []models.WiFiNetwork) WifiEvent {
return WifiEvent{
Type: "scan_update",
Timestamp: time.Now(),
Data: ScanUpdateData{
Networks: networks,
},
}
}
// NewStateChangeEvent creates a new state change event
func NewStateChangeEvent(state, ssid, previousState string) WifiEvent {
return WifiEvent{
Type: "state_change",
Timestamp: time.Now(),
Data: StateChangeData{
State: state,
SSID: ssid,
PreviousState: previousState,
},
}
}
// NewSignalUpdateEvent creates a new signal update event
func NewSignalUpdateEvent(ssid string, signal, dbm int) WifiEvent {
return WifiEvent{
Type: "signal_update",
Timestamp: time.Now(),
Data: SignalUpdateData{
SSID: ssid,
Signal: signal,
DBm: dbm,
},
}
}

View file

@ -0,0 +1,223 @@
package iwd
import (
"log"
"sync"
"github.com/godbus/dbus/v5"
)
// SignalMonitor monitors D-Bus signals from iwd
type SignalMonitor struct {
conn *dbus.Conn
station *Station
// Signal channel
signalChan chan *dbus.Signal
// Callbacks
onStateChange func(state StationState, ssid string)
onScanComplete func()
// Control
stopChan chan struct{}
mu sync.RWMutex
running bool
// State tracking
lastScanning bool
}
// NewSignalMonitor creates a new signal monitor
func NewSignalMonitor(conn *dbus.Conn, station *Station) *SignalMonitor {
return &SignalMonitor{
conn: conn,
station: station,
signalChan: make(chan *dbus.Signal, 100),
stopChan: make(chan struct{}),
}
}
// OnStateChange registers a callback for state changes
func (sm *SignalMonitor) OnStateChange(callback func(state StationState, ssid string)) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.onStateChange = callback
}
// OnScanComplete registers a callback for scan completion
func (sm *SignalMonitor) OnScanComplete(callback func()) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.onScanComplete = callback
}
// Start begins monitoring D-Bus signals
func (sm *SignalMonitor) Start() error {
sm.mu.Lock()
if sm.running {
sm.mu.Unlock()
return nil
}
sm.running = true
sm.mu.Unlock()
// Subscribe to PropertiesChanged signals for Station interface
stationPath := sm.station.GetPath()
// Add signal match for PropertiesChanged on Station interface
matchOptions := []dbus.MatchOption{
dbus.WithMatchObjectPath(stationPath),
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
dbus.WithMatchMember("PropertiesChanged"),
}
if err := sm.conn.AddMatchSignal(matchOptions...); err != nil {
sm.mu.Lock()
sm.running = false
sm.mu.Unlock()
return err
}
// Register signal channel
sm.conn.Signal(sm.signalChan)
// Get initial scanning state
scanning, err := sm.station.IsScanning()
if err == nil {
sm.lastScanning = scanning
}
// Start monitoring goroutine
go sm.monitor()
log.Printf("D-Bus signal monitoring started for station %s", stationPath)
return nil
}
// Stop stops monitoring D-Bus signals
func (sm *SignalMonitor) Stop() {
sm.mu.Lock()
if !sm.running {
sm.mu.Unlock()
return
}
sm.running = false
sm.mu.Unlock()
// Signal stop
close(sm.stopChan)
// Remove signal channel
sm.conn.RemoveSignal(sm.signalChan)
log.Printf("D-Bus signal monitoring stopped")
}
// monitor is the main signal processing loop
func (sm *SignalMonitor) monitor() {
for {
select {
case sig := <-sm.signalChan:
sm.handleSignal(sig)
case <-sm.stopChan:
return
}
}
}
// handleSignal processes a D-Bus signal
func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) {
// Only process PropertiesChanged signals
if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" {
return
}
// Verify signal is from Station interface
if len(sig.Body) < 2 {
return
}
interfaceName, ok := sig.Body[0].(string)
if !ok || interfaceName != StationInterface {
return
}
// Parse changed properties
changedProps, ok := sig.Body[1].(map[string]dbus.Variant)
if !ok {
return
}
// Check for State property change
if stateVariant, ok := changedProps["State"]; ok {
if state, ok := stateVariant.Value().(string); ok {
sm.handleStateChange(StationState(state))
}
}
// Check for Scanning property change
if scanningVariant, ok := changedProps["Scanning"]; ok {
if scanning, ok := scanningVariant.Value().(bool); ok {
sm.handleScanningChange(scanning)
}
}
// Check for ConnectedNetwork property change
if _, ok := changedProps["ConnectedNetwork"]; ok {
// Network connection changed, trigger state update
sm.handleConnectionChange()
}
}
// handleStateChange processes a state change
func (sm *SignalMonitor) handleStateChange(state StationState) {
sm.mu.RLock()
callback := sm.onStateChange
sm.mu.RUnlock()
if callback == nil {
return
}
// Get connected SSID if connected
ssid := ""
if state == StateConnected {
network, err := sm.station.GetConnectedNetwork()
if err == nil {
props, err := network.GetProperties()
if err == nil {
ssid = props.Name
}
}
}
callback(state, ssid)
}
// handleScanningChange processes scanning state changes
func (sm *SignalMonitor) handleScanningChange(scanning bool) {
// Detect scan completion (transition from true to false)
if sm.lastScanning && !scanning {
sm.mu.RLock()
callback := sm.onScanComplete
sm.mu.RUnlock()
if callback != nil {
callback()
}
}
sm.lastScanning = scanning
}
// handleConnectionChange processes connection changes
func (sm *SignalMonitor) handleConnectionChange() {
// Get current state and trigger state change callback
state, err := sm.station.GetState()
if err != nil {
return
}
sm.handleStateChange(state)
}

View file

@ -135,3 +135,8 @@ func (s *Station) GetConnectedNetwork() (*Network, error) {
return NewNetwork(s.conn, path), nil
}
// GetPath returns the D-Bus object path for this station
func (s *Station) GetPath() dbus.ObjectPath {
return s.path
}

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/godbus/dbus/v5"
"github.com/gorilla/websocket"
"github.com/nemunaire/repeater/internal/models"
"github.com/nemunaire/repeater/internal/wifi/iwd"
)
@ -16,12 +17,14 @@ const (
)
var (
wlanInterface string
dbusConn *dbus.Conn
iwdManager *iwd.Manager
station *iwd.Station
agent *iwd.Agent
agentManager *iwd.AgentManager
wlanInterface string
dbusConn *dbus.Conn
iwdManager *iwd.Manager
station *iwd.Station
agent *iwd.Agent
agentManager *iwd.AgentManager
eventMonitor *iwd.SignalMonitor
wifiBroadcaster *WifiBroadcaster
)
// Initialize initializes the WiFi service with iwd D-Bus connection
@ -68,6 +71,48 @@ func Close() {
}
}
// GetCachedNetworks returns previously discovered networks without triggering a scan
func GetCachedNetworks() ([]models.WiFiNetwork, error) {
// Get ordered networks without scanning
networkInfos, err := station.GetOrderedNetworks()
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
}
var networks []models.WiFiNetwork
seenSSIDs := make(map[string]bool)
for _, netInfo := range networkInfos {
network := iwd.NewNetwork(dbusConn, netInfo.Path)
props, err := network.GetProperties()
if err != nil {
continue
}
if props.Name == "" || seenSSIDs[props.Name] {
continue
}
seenSSIDs[props.Name] = true
wifiNet := models.WiFiNetwork{
SSID: props.Name,
Signal: signalToStrength(int(netInfo.Signal) / 100),
Security: mapSecurityType(props.Type),
BSSID: generateSyntheticBSSID(props.Name),
Channel: 0,
}
networks = append(networks, wifiNet)
}
// Sort by signal strength (descending)
sort.Slice(networks, func(i, j int) bool {
return networks[i].Signal > networks[j].Signal
})
return networks, nil
}
// ScanNetworks scans for available WiFi networks
func ScanNetworks() ([]models.WiFiNetwork, error) {
// Check if already scanning
@ -120,6 +165,11 @@ func ScanNetworks() ([]models.WiFiNetwork, error) {
return networks[i].Signal > networks[j].Signal
})
// Broadcast to WebSocket clients if available
if wifiBroadcaster != nil {
wifiBroadcaster.BroadcastScanUpdate(networks)
}
return networks, nil
}
@ -191,6 +241,59 @@ func GetConnectedSSID() string {
return props.Name
}
// StartEventMonitoring initializes D-Bus signal monitoring and WebSocket broadcasting
func StartEventMonitoring() error {
// Initialize broadcaster
wifiBroadcaster = NewWifiBroadcaster()
// Create signal monitor
eventMonitor = iwd.NewSignalMonitor(dbusConn, station)
// Register callbacks
eventMonitor.OnStateChange(handleStateChange)
eventMonitor.OnScanComplete(handleScanComplete)
// Start monitoring
return eventMonitor.Start()
}
// StopEventMonitoring stops D-Bus signal monitoring
func StopEventMonitoring() {
if eventMonitor != nil {
eventMonitor.Stop()
}
}
// RegisterWebSocketClient registers a new WebSocket client for WiFi events
func RegisterWebSocketClient(conn *websocket.Conn) {
if wifiBroadcaster != nil {
wifiBroadcaster.RegisterClient(conn)
}
}
// UnregisterWebSocketClient removes a WebSocket client
func UnregisterWebSocketClient(conn *websocket.Conn) {
if wifiBroadcaster != nil {
wifiBroadcaster.UnregisterClient(conn)
}
}
// handleStateChange is called when WiFi connection state changes
func handleStateChange(newState iwd.StationState, connectedSSID string) {
if wifiBroadcaster != nil {
wifiBroadcaster.BroadcastStateChange(string(newState), connectedSSID)
}
}
// handleScanComplete is called when a WiFi scan completes
func handleScanComplete() {
// Get updated network list
networks, err := GetCachedNetworks()
if err == nil && wifiBroadcaster != nil {
wifiBroadcaster.BroadcastScanUpdate(networks)
}
}
// mapSecurityType maps iwd security types to display format
func mapSecurityType(iwdType string) string {
switch iwdType {

View file

@ -28,6 +28,42 @@ tags:
description: System logs and real-time monitoring
paths:
/api/wifi/networks:
get:
tags:
- WiFi
summary: Get discovered WiFi networks
description: |
Returns the list of WiFi networks from the last scan without triggering a new scan.
Returns an empty list if no scan has been performed yet.
operationId: getWiFiNetworks
responses:
'200':
description: List of discovered networks
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WiFiNetwork'
example:
- ssid: "Hotel-Guest"
signal: 5
security: "WPA2"
channel: 6
bssid: "aa:bb:cc:dd:ee:ff"
- ssid: "Public-WiFi"
signal: 3
security: "Open"
channel: 11
bssid: "11:22:33:44:55:66"
'500':
description: Error retrieving networks
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/wifi/scan:
get:
tags: