diff --git a/.gitignore b/.gitignore
index e147207..ea33d90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
-repeater
\ No newline at end of file
+internal/api/routes.gen.go
+internal/api/types.gen.go
+/repeater
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2d57547
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+.PHONY: build run clean install test
+
+BINARY_NAME=repeater
+CMD_PATH=./cmd/repeater
+BUILD_DIR=.
+
+build:
+ go build -v -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_PATH)
+
+run: build
+ sudo ./$(BINARY_NAME)
+
+clean:
+ go clean
+ rm -f $(BUILD_DIR)/$(BINARY_NAME)
+
+install: build
+ sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/
+
+test:
+ go test -v ./...
+
+tidy:
+ go mod tidy
+
+fmt:
+ go fmt ./...
+
+vet:
+ go vet ./...
+
+all: fmt vet build
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..54691f9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# Travel Router Control
+
+A Go application for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. The router can operate as a WiFi repeater, connecting to upstream networks while providing a hotspot for client devices.
+
+## Features
+
+- WiFi network scanning and connection management
+- Hotspot (access point) configuration and control
+- Connected device monitoring
+- Real-time system logs via WebSocket
+- RESTful API following OpenAPI 3.0 specification
+- Web interface for easy management
+
+## Architecture
+
+The application follows a clean architecture pattern:
+
+```
+.
+├── cmd/
+│ └── repeater/ # Application entry point
+│ ├── main.go
+│ └── static/ # Embedded web assets
+├── internal/
+│ ├── api/ # HTTP API layer
+│ │ ├── router.go # Gin router setup
+│ │ └── handlers/ # HTTP handlers
+│ ├── app/ # Application logic & lifecycle
+│ ├── device/ # Device management
+│ ├── hotspot/ # Hotspot control
+│ ├── logging/ # Logging system
+│ ├── models/ # Data structures
+│ └── wifi/ # WiFi operations (wpa_supplicant via D-Bus)
+├── openapi.yaml # API specification
+└── go.mod
+```
+
+## Building
+
+```bash
+go build -o repeater ./cmd/repeater
+```
+
+## Running
+
+```bash
+sudo ./repeater
+```
+
+The application requires root privileges to:
+- Access D-Bus system bus for wpa_supplicant
+- Control systemd services (hostapd)
+- Read DHCP leases and ARP tables
+
+The server will start on port 8080.
+
+## API Endpoints
+
+### WiFi Operations
+- `GET /api/wifi/scan` - Scan for available networks
+- `POST /api/wifi/connect` - Connect to a network
+- `POST /api/wifi/disconnect` - Disconnect from current network
+
+### Hotspot Operations
+- `POST /api/hotspot/config` - Configure hotspot settings
+- `POST /api/hotspot/toggle` - Enable/disable hotspot
+
+### Device Management
+- `GET /api/devices` - Get connected devices
+
+### System
+- `GET /api/status` - Get system status
+- `GET /api/logs` - Get system logs
+- `DELETE /api/logs` - Clear logs
+
+### WebSocket
+- `GET /ws/logs` - Real-time log streaming
+
+See `openapi.yaml` for complete API documentation.
+
+## Configuration
+
+The application uses the following system resources:
+
+- **WiFi Interface**: `wlan0` (for upstream connection)
+- **AP Interface**: `wlan1` (for hotspot)
+- **Hostapd Config**: `/etc/hostapd/hostapd.conf`
+- **WPA Supplicant Config**: `/etc/wpa_supplicant/wpa_supplicant.conf`
+
+These can be modified in the respective package constants.
+
+## Dependencies
+
+- **Gin**: HTTP web framework
+- **godbus**: D-Bus client for wpa_supplicant control
+- **gorilla/websocket**: WebSocket support
+- **wpa_supplicant**: WiFi connection management
+- **hostapd**: Hotspot functionality
+
+## License
+
+MIT
diff --git a/cmd/repeater/main.go b/cmd/repeater/main.go
new file mode 100644
index 0000000..3fd1e66
--- /dev/null
+++ b/cmd/repeater/main.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "embed"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/nemunaire/repeater/internal/app"
+ "github.com/nemunaire/repeater/internal/config"
+)
+
+//go:embed all:static
+var assets embed.FS
+
+func main() {
+ // Load and parse options
+ cfg, err := config.ConsolidateConfig()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Create application instance
+ application := app.New(assets)
+
+ // Initialize the application
+ if err := application.Initialize(cfg); err != nil {
+ log.Fatalf("Failed to initialize application: %v", err)
+ }
+ defer application.Shutdown()
+
+ // Handle graceful shutdown
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ log.Println("Shutting down gracefully...")
+ application.Shutdown()
+ os.Exit(0)
+ }()
+
+ // Start the server
+ if err := application.Run(cfg.Bind); err != nil {
+ log.Fatalf("Failed to start server: %v", err)
+ }
+}
diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js
new file mode 100644
index 0000000..a7c367d
--- /dev/null
+++ b/cmd/repeater/static/app.js
@@ -0,0 +1,791 @@
+// Application state
+const appState = {
+ selectedWifi: null,
+ hotspotEnabled: true,
+ autoScrollLogs: true,
+ ws: null,
+ wifiWs: null,
+ reconnectAttempts: 0,
+ wifiReconnectAttempts: 0,
+ maxReconnectAttempts: 5,
+ connectedSSID: null,
+ connectionState: 'disconnected',
+ networks: [],
+ uptime: 0,
+ uptimeInterval: null
+};
+
+// Initialize the application
+document.addEventListener('DOMContentLoaded', function() {
+ initializeApp();
+});
+
+async function initializeApp() {
+ console.log('Initializing Travel Router Control Panel...');
+
+ // Load initial data
+ await Promise.all([
+ loadStatus(),
+ scanWifi(),
+ loadDevices(),
+ loadLogs()
+ ]);
+
+ // Set up WebSockets for real-time updates
+ connectWebSocket();
+ connectWifiWebSocket();
+
+ // Start periodic updates
+ startPeriodicUpdates();
+
+ // Start uptime counter
+ startUptimeCounter();
+}
+
+// ===== API Functions =====
+
+async function loadStatus() {
+ try {
+ const response = await fetch('/api/status');
+ const status = await response.json();
+
+ updateStatusDisplay(status);
+ return status;
+ } catch (error) {
+ console.error('Error loading status:', error);
+ showToast('error', 'Erreur', 'Impossible de charger le statut');
+ }
+}
+
+async function scanWifi() {
+ const wifiList = document.getElementById('wifiList');
+ const scanBtn = document.querySelector('[onclick="scanWifi()"]');
+
+ if (scanBtn) {
+ scanBtn.disabled = true;
+ }
+
+ wifiList.innerHTML = '
Recherche des réseaux disponibles...
';
+
+ try {
+ const response = await fetch('/api/wifi/scan');
+ const networks = await response.json();
+
+ appState.networks = networks;
+ displayWifiNetworks(networks);
+ showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`);
+ } catch (error) {
+ console.error('Error scanning WiFi:', error);
+
+ // 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 = 'Erreur lors du scan
';
+ showToast('error', 'Erreur', 'Échec du scan WiFi');
+ }
+ } finally {
+ if (scanBtn) {
+ scanBtn.disabled = false;
+ }
+ }
+}
+
+async function connectToWifi() {
+ if (!appState.selectedWifi) {
+ showToast('warning', 'Attention', 'Veuillez sélectionner un réseau WiFi');
+ return;
+ }
+
+ const password = document.getElementById('wifiPassword').value;
+
+ // Password requirement disabled
+ // if (appState.selectedWifi.security !== 'Open' && !password) {
+ // showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau');
+ // return;
+ // }
+
+ showLoading(true);
+
+ try {
+ const response = await fetch('/api/wifi/connect', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ssid: appState.selectedWifi.ssid,
+ password: password
+ })
+ });
+
+ const result = await response.json();
+
+ if (response.ok) {
+ showToast('success', 'Connecté', `Connexion établie avec ${appState.selectedWifi.ssid}`);
+ document.getElementById('wifiPassword').value = '';
+ await loadStatus();
+ } else {
+ throw new Error(result.error || 'Connection failed');
+ }
+ } catch (error) {
+ console.error('Error connecting to WiFi:', error);
+ showToast('error', 'Erreur', 'Échec de la connexion: ' + error.message);
+ } finally {
+ showLoading(false);
+ }
+}
+
+async function disconnectWifi() {
+ showLoading(true);
+
+ try {
+ const response = await fetch('/api/wifi/disconnect', {
+ method: 'POST'
+ });
+
+ if (response.ok) {
+ showToast('success', 'Déconnecté', 'Déconnexion WiFi réussie');
+ await loadStatus();
+ // Force refresh WiFi list to remove green highlighting
+ if (appState.networks.length > 0) {
+ displayWifiNetworks(appState.networks);
+ }
+ } else {
+ throw new Error('Disconnection failed');
+ }
+ } catch (error) {
+ console.error('Error disconnecting WiFi:', error);
+ showToast('error', 'Erreur', 'Échec de la déconnexion');
+ } finally {
+ showLoading(false);
+ }
+}
+
+async function toggleHotspot() {
+ const toggle = document.getElementById('hotspotToggle');
+ const enabled = toggle.checked;
+
+ showLoading(true);
+
+ try {
+ const response = await fetch('/api/hotspot/toggle', {
+ method: 'POST'
+ });
+
+ const result = await response.json();
+
+ if (response.ok) {
+ appState.hotspotEnabled = result.enabled;
+ toggle.checked = result.enabled;
+ showToast('success', 'Hotspot', result.enabled ? 'Hotspot activé' : 'Hotspot désactivé');
+ await loadStatus();
+ } else {
+ toggle.checked = !enabled;
+ throw new Error('Toggle failed');
+ }
+ } catch (error) {
+ console.error('Error toggling hotspot:', error);
+ toggle.checked = !enabled;
+ showToast('error', 'Erreur', 'Échec du basculement');
+ } finally {
+ showLoading(false);
+ }
+}
+
+async function loadDevices() {
+ try {
+ const response = await fetch('/api/devices');
+ const devices = await response.json();
+
+ displayDevices(devices);
+ document.getElementById('deviceCount').textContent = devices.length;
+ } catch (error) {
+ console.error('Error loading devices:', error);
+ const devicesList = document.getElementById('devicesList');
+ devicesList.innerHTML = '';
+ }
+}
+
+async function loadLogs() {
+ try {
+ const response = await fetch('/api/logs');
+ const logs = await response.json();
+
+ const logContainer = document.getElementById('logContainer');
+ logContainer.innerHTML = '';
+
+ if (logs.length === 0) {
+ logContainer.innerHTML = '';
+ } else {
+ logs.forEach(log => addLogEntry(log));
+ }
+ } catch (error) {
+ console.error('Error loading logs:', error);
+ }
+}
+
+async function clearLogs() {
+ try {
+ const response = await fetch('/api/logs', { method: 'DELETE' });
+
+ if (response.ok) {
+ const logContainer = document.getElementById('logContainer');
+ logContainer.innerHTML = '';
+ showToast('success', 'Logs', 'Logs effacés');
+ }
+ } catch (error) {
+ console.error('Error clearing logs:', error);
+ showToast('error', 'Erreur', 'Échec de la suppression des logs');
+ }
+}
+
+// ===== Display Functions =====
+
+function updateStatusDisplay(status) {
+ // Update WiFi status badge
+ const wifiStatus = document.getElementById('wifiStatus');
+ const wifiDot = wifiStatus.querySelector('.status-dot');
+ const wifiText = wifiStatus.querySelector('.status-text');
+
+ // Use connectionState for more detailed status
+ const state = status.connectionState || (status.connected ? 'connected' : 'disconnected');
+ appState.connectionState = state;
+
+ switch (state) {
+ case 'connected':
+ wifiDot.className = 'status-dot active';
+ wifiText.textContent = `Connecté: ${status.connectedSSID}`;
+ break;
+ case 'connecting':
+ wifiDot.className = 'status-dot connecting';
+ wifiText.textContent = status.connectedSSID ? `Connexion à ${status.connectedSSID}...` : 'Connexion...';
+ break;
+ case 'disconnecting':
+ wifiDot.className = 'status-dot disconnecting';
+ wifiText.textContent = 'Déconnexion...';
+ break;
+ case 'roaming':
+ wifiDot.className = 'status-dot active';
+ wifiText.textContent = `Roaming: ${status.connectedSSID}`;
+ break;
+ case 'disconnected':
+ default:
+ wifiDot.className = 'status-dot offline';
+ wifiText.textContent = 'Déconnecté';
+ break;
+ }
+
+ // Update hotspot status badge
+ const hotspotStatus = document.getElementById('hotspotStatus');
+ const hotspotDot = hotspotStatus.querySelector('.status-dot');
+ const hotspotText = hotspotStatus.querySelector('.status-text');
+ const hotspotToggle = document.getElementById('hotspotToggle');
+
+ const isHotspotEnabled = status.hotspotStatus && status.hotspotStatus.state === 'ENABLED';
+
+ if (isHotspotEnabled) {
+ hotspotDot.className = 'status-dot active';
+ const numStations = status.hotspotStatus.numStations || 0;
+ hotspotText.textContent = `Hotspot actif (${numStations} client${numStations > 1 ? 's' : ''})`;
+ hotspotToggle.checked = true;
+ } else {
+ hotspotDot.className = 'status-dot offline';
+ hotspotText.textContent = 'Hotspot inactif';
+ hotspotToggle.checked = false;
+ }
+
+ appState.hotspotEnabled = isHotspotEnabled;
+
+ // Update hotspot details if available
+ updateHotspotDetails(status.hotspotStatus);
+
+ // Check if connectedSSID or state changed and refresh WiFi list if needed
+ const prevSSID = appState.connectedSSID;
+ const prevState = appState.connectionState;
+ appState.connectedSSID = status.connectedSSID;
+
+ const connectedChanged = prevSSID !== status.connectedSSID || prevState !== state;
+
+ if (connectedChanged && appState.networks.length > 0) {
+ displayWifiNetworks(appState.networks);
+ }
+
+ // Update stats
+ document.getElementById('connectedDevices').textContent = status.connectedCount;
+ document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`;
+
+ // Update uptime in state (will be incremented by the interval)
+ appState.uptime = status.uptime;
+ document.getElementById('uptime').textContent = formatUptime(status.uptime);
+
+ document.getElementById('currentNetwork').textContent = status.connectedSSID || '-';
+}
+
+function displayWifiNetworks(networks) {
+ const wifiList = document.getElementById('wifiList');
+ wifiList.innerHTML = '';
+
+ if (networks.length === 0) {
+ wifiList.innerHTML = 'Aucun réseau trouvé
';
+ return;
+ }
+
+ networks.forEach(network => {
+ const wifiItem = document.createElement('div');
+ wifiItem.className = 'wifi-item';
+
+ // Mark the network based on connection state
+ if (appState.connectedSSID && network.ssid === appState.connectedSSID) {
+ switch (appState.connectionState) {
+ case 'connected':
+ case 'roaming':
+ wifiItem.classList.add('connected');
+ break;
+ case 'connecting':
+ wifiItem.classList.add('connecting');
+ break;
+ case 'disconnecting':
+ wifiItem.classList.add('disconnecting');
+ break;
+ }
+ }
+
+ wifiItem.onclick = () => selectWifi(network, wifiItem);
+
+ wifiItem.innerHTML = `
+
+
${escapeHtml(network.ssid)}
+
+ ${escapeHtml(network.security)}
+ Canal ${escapeHtml(String(network.channel))}
+ ${escapeHtml(network.bssid)}
+
+
+
+ ${generateSignalBars(network.signal)}
+
+ `;
+
+ wifiList.appendChild(wifiItem);
+ });
+}
+
+function selectWifi(network, element) {
+ document.querySelectorAll('.wifi-item').forEach(item => item.classList.remove('selected'));
+ element.classList.add('selected');
+ appState.selectedWifi = network;
+}
+
+function displayDevices(devices) {
+ const devicesList = document.getElementById('devicesList');
+ devicesList.innerHTML = '';
+
+ if (devices.length === 0) {
+ devicesList.innerHTML = `
+
+
+
Aucun appareil connecté
+
+ `;
+ return;
+ }
+
+ devices.forEach(device => {
+ const deviceCard = document.createElement('div');
+ deviceCard.className = 'device-card';
+
+ deviceCard.innerHTML = `
+ ${getDeviceIcon(device.type)}
+ ${escapeHtml(device.name)}
+ ${device.type}
+
+
${device.ip}
+
${device.mac}
+
+ `;
+
+ devicesList.appendChild(deviceCard);
+ });
+}
+
+function addLogEntry(log) {
+ const logContainer = document.getElementById('logContainer');
+
+ // Remove placeholder if it exists
+ const placeholder = logContainer.querySelector('.log-placeholder');
+ if (placeholder) {
+ placeholder.remove();
+ }
+
+ const logEntry = document.createElement('div');
+ logEntry.className = 'log-entry';
+
+ const timestamp = new Date(log.timestamp).toLocaleTimeString('fr-FR');
+
+ const timestampSpan = document.createElement('span');
+ timestampSpan.className = 'log-timestamp';
+ timestampSpan.textContent = timestamp;
+
+ const sourceSpan = document.createElement('span');
+ sourceSpan.className = 'log-source';
+ sourceSpan.textContent = `[${log.source}]`;
+
+ const messageSpan = document.createElement('span');
+ messageSpan.className = 'log-message';
+ messageSpan.textContent = log.message;
+
+ logEntry.appendChild(timestampSpan);
+ logEntry.appendChild(sourceSpan);
+ logEntry.appendChild(messageSpan);
+
+ logContainer.appendChild(logEntry);
+
+ if (appState.autoScrollLogs) {
+ logContainer.scrollTop = logContainer.scrollHeight;
+ }
+}
+
+function createHotspotInfoItem(label, value) {
+ const item = document.createElement('div');
+ item.className = 'hotspot-info-item';
+
+ const labelSpan = document.createElement('span');
+ labelSpan.className = 'info-label';
+ labelSpan.textContent = label + ':';
+
+ const valueSpan = document.createElement('span');
+ valueSpan.className = 'info-value';
+ valueSpan.textContent = value;
+
+ item.appendChild(labelSpan);
+ item.appendChild(valueSpan);
+
+ return item;
+}
+
+function updateHotspotDetails(hotspotStatus) {
+ const detailsContainer = document.getElementById('hotspotDetails');
+ if (!detailsContainer) return;
+
+ // Clear existing content
+ detailsContainer.innerHTML = '';
+
+ if (!hotspotStatus || hotspotStatus.state !== 'ENABLED') {
+ detailsContainer.appendChild(createHotspotInfoItem('État', 'Inactif'));
+ return;
+ }
+
+ detailsContainer.appendChild(createHotspotInfoItem('État', hotspotStatus.state));
+
+ if (hotspotStatus.ssid) {
+ detailsContainer.appendChild(createHotspotInfoItem('SSID', hotspotStatus.ssid));
+ }
+
+ if (hotspotStatus.channel) {
+ detailsContainer.appendChild(createHotspotInfoItem('Canal', `${hotspotStatus.channel} (${hotspotStatus.frequency} MHz)`));
+ }
+
+ if (hotspotStatus.numStations !== undefined) {
+ detailsContainer.appendChild(createHotspotInfoItem('Clients connectés', hotspotStatus.numStations.toString()));
+ }
+
+ if (hotspotStatus.bssid) {
+ detailsContainer.appendChild(createHotspotInfoItem('BSSID', hotspotStatus.bssid));
+ }
+}
+
+// ===== WebSocket Functions =====
+
+function connectWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
+
+ try {
+ appState.ws = new WebSocket(wsUrl);
+
+ appState.ws.onopen = function() {
+ console.log('WebSocket connected');
+ appState.reconnectAttempts = 0;
+ };
+
+ appState.ws.onmessage = function(event) {
+ try {
+ const log = JSON.parse(event.data);
+ addLogEntry(log);
+ } catch (error) {
+ console.error('Error parsing WebSocket message:', error);
+ }
+ };
+
+ appState.ws.onerror = function(error) {
+ console.error('WebSocket error:', error);
+ };
+
+ appState.ws.onclose = function() {
+ console.log('WebSocket disconnected');
+
+ // Attempt to reconnect
+ if (appState.reconnectAttempts < appState.maxReconnectAttempts) {
+ appState.reconnectAttempts++;
+ setTimeout(connectWebSocket, 5000);
+ }
+ };
+ } catch (error) {
+ console.error('Error creating WebSocket:', error);
+ }
+}
+
+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');
+
+ // Update state in appState
+ appState.connectionState = data.state;
+
+ switch (data.state) {
+ case '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);
+ }
+ break;
+
+ case 'connecting':
+ wifiDot.className = 'status-dot connecting';
+ wifiText.textContent = data.ssid ? `Connexion à ${data.ssid}...` : 'Connexion...';
+
+ // Refresh network list to show connecting state
+ if (appState.networks.length > 0) {
+ displayWifiNetworks(appState.networks);
+ }
+ break;
+
+ case 'disconnecting':
+ wifiDot.className = 'status-dot disconnecting';
+ wifiText.textContent = 'Déconnexion...';
+
+ // Refresh network list to show disconnecting state
+ if (appState.networks.length > 0) {
+ displayWifiNetworks(appState.networks);
+ }
+ break;
+
+ case 'roaming':
+ wifiDot.className = 'status-dot active';
+ wifiText.textContent = `Roaming: ${data.ssid}`;
+ appState.connectedSSID = data.ssid;
+ break;
+
+ case '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);
+ }
+ break;
+
+ default:
+ console.warn('Unknown WiFi state:', data.state);
+ break;
+ }
+
+ 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) {
+ const bars = [];
+ for (let i = 1; i <= 4; i++) {
+ const active = i <= strength ? 'active' : '';
+ bars.push(``);
+ }
+ return `${bars.join('')}
`;
+}
+
+function getDeviceIcon(type) {
+ const icons = {
+ mobile: ``,
+ laptop: ``,
+ tablet: ``,
+ desktop: ``,
+ unknown: ``
+ };
+ return icons[type] || icons.unknown;
+}
+
+function formatUptime(seconds) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function showToast(type, title, message) {
+ const toast = document.getElementById('toast');
+ const toastIcon = document.getElementById('toastIcon');
+ const toastTitle = document.getElementById('toastTitle');
+ const toastMessage = document.getElementById('toastMessage');
+
+ const icons = {
+ success: '',
+ error: '',
+ warning: '',
+ info: ''
+ };
+
+ toast.className = `toast ${type}`;
+ toastIcon.innerHTML = icons[type];
+ toastTitle.textContent = title;
+ toastMessage.textContent = message;
+
+ setTimeout(() => toast.classList.add('show'), 10);
+
+ setTimeout(() => toast.classList.remove('show'), 5000);
+}
+
+function hideToast() {
+ document.getElementById('toast').classList.remove('show');
+}
+
+function showLoading(show) {
+ const overlay = document.getElementById('loadingOverlay');
+ if (show) {
+ overlay.classList.add('show');
+ } else {
+ overlay.classList.remove('show');
+ }
+}
+
+function toggleAutoScroll() {
+ appState.autoScrollLogs = !appState.autoScrollLogs;
+ const btn = document.getElementById('autoScrollBtn');
+
+ if (appState.autoScrollLogs) {
+ btn.style.opacity = '1';
+ showToast('info', 'Auto-scroll', 'Auto-scroll activé');
+ } else {
+ btn.style.opacity = '0.5';
+ showToast('info', 'Auto-scroll', 'Auto-scroll désactivé');
+ }
+}
+
+// ===== Periodic Updates =====
+
+function startPeriodicUpdates() {
+ // Update status every 5 seconds
+ setInterval(() => {
+ loadStatus();
+ }, 5000);
+
+ // Update devices every 10 seconds
+ setInterval(() => {
+ loadDevices();
+ }, 10000);
+}
+
+function startUptimeCounter() {
+ // Clear any existing interval
+ if (appState.uptimeInterval) {
+ clearInterval(appState.uptimeInterval);
+ }
+
+ // Increment uptime every second
+ appState.uptimeInterval = setInterval(() => {
+ appState.uptime++;
+ document.getElementById('uptime').textContent = formatUptime(appState.uptime);
+ }, 1000);
+}
diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html
new file mode 100644
index 0000000..d53a645
--- /dev/null
+++ b/cmd/repeater/static/index.html
@@ -0,0 +1,231 @@
+
+
+
+
+
+ Travel Router Control Panel
+
+
+
+
+
+
+
+
+
+
+
0
+
Appareils connectés
+
+
+
+
+
+
0 MB
+
Données utilisées
+
+
+
+
+
+
00:00:00
+
Temps de fonctionnement
+
+
+
+
+
+
+
+
+
+
+
+
+
+ État:
+ Chargement...
+
+
+
+
+
+
+
+
+
+
+
+
+
Aucun appareil connecté
+
+
+
+
+
+
+
+
+
+
+
Aucun log disponible
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css
new file mode 100644
index 0000000..b518696
--- /dev/null
+++ b/cmd/repeater/static/style.css
@@ -0,0 +1,878 @@
+/* CSS Variables for theming */
+:root {
+ --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ --secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ --success-color: #10b981;
+ --danger-color: #ef4444;
+ --warning-color: #f59e0b;
+ --info-color: #3b82f6;
+ --background: #f3f4f6;
+ --card-background: #ffffff;
+ --text-primary: #1f2937;
+ --text-secondary: #6b7280;
+ --border-color: #e5e7eb;
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background: var(--primary-gradient);
+ min-height: 100vh;
+ padding: 1.5rem;
+ color: var(--text-primary);
+}
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(20px);
+ border-radius: var(--radius-xl);
+ padding: 2rem;
+ box-shadow: var(--shadow-xl);
+}
+
+/* Header Styles */
+.header {
+ margin-bottom: 2rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 2px solid var(--border-color);
+}
+
+.header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.header h1 {
+ color: var(--text-primary);
+ font-size: 2rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.logo-icon {
+ color: #667eea;
+}
+
+.connection-info {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border-radius: 9999px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ background: var(--background);
+ border: 1px solid var(--border-color);
+}
+
+.status-dot {
+ width: 0.625rem;
+ height: 0.625rem;
+ border-radius: 50%;
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.status-dot.active {
+ background: var(--success-color);
+}
+
+.status-dot.offline {
+ background: var(--text-secondary);
+ animation: none;
+}
+
+.status-dot.connecting {
+ background: var(--warning-color);
+ animation: blink 1s ease-in-out infinite;
+}
+
+.status-dot.disconnecting {
+ background: var(--warning-color);
+ animation: blink 1s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+/* Stats Grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--card-background);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ box-shadow: var(--shadow-md);
+ border: 1px solid var(--border-color);
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ transition: all 0.3s ease;
+ max-width: 100%;
+}
+
+.stat-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.stat-icon {
+ width: 3rem;
+ height: 3rem;
+ border-radius: var(--radius-md);
+ background: var(--primary-gradient);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.stat-icon svg {
+ color: white;
+}
+
+.stat-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.stat-value {
+ font-size: 1.875rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1;
+ margin-bottom: 0.25rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stat-label {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+/* Grid Layout */
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+/* Card Styles */
+.card {
+ background: var(--card-background);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ box-shadow: var(--shadow-md);
+ border: 1px solid var(--border-color);
+ transition: all 0.3s ease;
+}
+
+.card:hover {
+ box-shadow: var(--shadow-lg);
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.card-header h2 {
+ color: var(--text-primary);
+ font-size: 1.25rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+}
+
+.icon {
+ color: #667eea;
+}
+
+/* Form Elements */
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+.form-group label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ color: var(--text-primary);
+ font-weight: 500;
+ font-size: 0.875rem;
+}
+
+.input-icon {
+ color: var(--text-secondary);
+}
+
+.form-group input,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ font-size: 0.9375rem;
+ transition: all 0.2s ease;
+ background: var(--card-background);
+ color: var(--text-primary);
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.9375rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ width: 100%;
+ color: white;
+}
+
+.btn svg {
+ flex-shrink: 0;
+}
+
+.btn-primary {
+ background: var(--primary-gradient);
+}
+
+.btn-primary:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
+}
+
+.btn-secondary {
+ background: var(--text-secondary);
+}
+
+.btn-secondary:hover {
+ background: #4b5563;
+ transform: translateY(-1px);
+}
+
+.btn-danger {
+ background: var(--danger-color);
+}
+
+.btn-danger:hover {
+ background: #dc2626;
+ transform: translateY(-1px);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-icon {
+ background: transparent;
+ border: none;
+ padding: 0.5rem;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ color: var(--text-secondary);
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.btn-icon:hover {
+ background: var(--background);
+ color: var(--text-primary);
+}
+
+.button-group {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+}
+
+/* Toggle Switch */
+.toggle-switch {
+ position: relative;
+ display: inline-block;
+ width: 50px;
+ height: 26px;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-switch label {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--text-secondary);
+ transition: 0.3s;
+ border-radius: 34px;
+}
+
+.toggle-switch label:before {
+ position: absolute;
+ content: "";
+ height: 20px;
+ width: 20px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: 0.3s;
+ border-radius: 50%;
+}
+
+.toggle-switch input:checked + label {
+ background: var(--success-color);
+}
+
+.toggle-switch input:checked + label:before {
+ transform: translateX(24px);
+}
+
+/* WiFi List */
+.wifi-list {
+ max-height: 320px;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--background);
+}
+
+.wifi-item {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--card-background);
+}
+
+.wifi-item:hover {
+ background: var(--background);
+}
+
+.wifi-item:last-child {
+ border-bottom: none;
+}
+
+.wifi-item.selected {
+ background: #eff6ff;
+ border-left: 4px solid #667eea;
+}
+
+.wifi-item.connected {
+ background: #d1fae5;
+ border-left-color: var(--success-color) !important;
+}
+
+.wifi-item.connected .wifi-ssid {
+ color: var(--success-color);
+ font-weight: 700;
+}
+
+.wifi-item.connecting {
+ background: #fef3c7;
+ border-left-color: var(--warning-color) !important;
+ animation: pulse-item 1.5s ease-in-out infinite;
+}
+
+.wifi-item.connecting .wifi-ssid {
+ color: var(--warning-color);
+ font-weight: 700;
+}
+
+.wifi-item.disconnecting {
+ background: #fee2e2;
+ border-left-color: #dc2626 !important;
+ animation: pulse-item 1.5s ease-in-out infinite;
+}
+
+.wifi-item.disconnecting .wifi-ssid {
+ color: #dc2626;
+ font-weight: 700;
+}
+
+@keyframes pulse-item {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.wifi-item.loading {
+ justify-content: center;
+ color: var(--text-secondary);
+ cursor: default;
+}
+
+.wifi-info {
+ flex: 1;
+}
+
+.wifi-ssid {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+}
+
+.wifi-details {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+ display: flex;
+ gap: 1rem;
+}
+
+.wifi-signal {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.signal-bars {
+ display: flex;
+ gap: 2px;
+ align-items: flex-end;
+ height: 16px;
+}
+
+.signal-bar {
+ width: 3px;
+ background: var(--border-color);
+ border-radius: 2px;
+}
+
+.signal-bar:nth-child(1) { height: 25%; }
+.signal-bar:nth-child(2) { height: 50%; }
+.signal-bar:nth-child(3) { height: 75%; }
+.signal-bar:nth-child(4) { height: 100%; }
+
+.signal-bar.active {
+ background: var(--success-color);
+}
+
+/* Devices */
+.devices-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 1rem;
+ min-height: 200px;
+}
+
+.device-card {
+ background: var(--background);
+ border-radius: var(--radius-md);
+ padding: 1.25rem;
+ text-align: center;
+ border: 1px solid var(--border-color);
+ transition: all 0.2s ease;
+}
+
+.device-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.device-icon {
+ width: 48px;
+ height: 48px;
+ margin: 0 auto 0.75rem;
+ color: #667eea;
+}
+
+.device-name {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+ font-size: 0.9375rem;
+}
+
+.device-type {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.5rem;
+}
+
+.device-info {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+
+.device-placeholder {
+ grid-column: 1 / -1;
+ text-align: center;
+ padding: 3rem 1rem;
+ color: var(--text-secondary);
+}
+
+.device-placeholder svg {
+ margin-bottom: 1rem;
+}
+
+.device-count {
+ background: var(--primary-gradient);
+ color: white;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+/* Logs */
+.log-container {
+ background: #1f2937;
+ color: #10b981;
+ padding: 1rem;
+ border-radius: var(--radius-md);
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Consolas', 'Courier New', monospace;
+ font-size: 0.8125rem;
+ max-height: 400px;
+ overflow-y: auto;
+ line-height: 1.6;
+ min-height: 200px;
+}
+
+.log-entry {
+ margin-bottom: 0.5rem;
+ display: flex;
+ gap: 0.75rem;
+}
+
+.log-timestamp {
+ color: #6b7280;
+ flex-shrink: 0;
+}
+
+.log-source {
+ color: #3b82f6;
+ font-weight: 600;
+ flex-shrink: 0;
+ min-width: 80px;
+}
+
+.log-message {
+ color: #d1d5db;
+}
+
+.log-controls {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.log-placeholder {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: #6b7280;
+}
+
+.log-placeholder svg {
+ margin-bottom: 1rem;
+}
+
+/* Toast Notifications */
+.toast {
+ position: fixed;
+ top: 1.5rem;
+ right: 1.5rem;
+ max-width: 400px;
+ background: white;
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-xl);
+ padding: 1rem;
+ display: flex;
+ gap: 0.75rem;
+ align-items: start;
+ transform: translateX(calc(100% + 2rem));
+ transition: transform 0.3s ease;
+ z-index: 1000;
+ border: 1px solid var(--border-color);
+}
+
+.toast.show {
+ transform: translateX(0);
+}
+
+.toast-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-md);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.toast.success .toast-icon {
+ background: #d1fae5;
+ color: var(--success-color);
+}
+
+.toast.error .toast-icon {
+ background: #fee2e2;
+ color: var(--danger-color);
+}
+
+.toast.warning .toast-icon {
+ background: #fef3c7;
+ color: var(--warning-color);
+}
+
+.toast.info .toast-icon {
+ background: #dbeafe;
+ color: var(--info-color);
+}
+
+.toast-content {
+ flex: 1;
+}
+
+.toast-title {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+}
+
+.toast-message {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.toast-close {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 0.25rem;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.toast-close:hover {
+ background: var(--background);
+ color: var(--text-primary);
+}
+
+/* Loading Overlay */
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 2000;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.loading-overlay.show {
+ display: flex;
+}
+
+.loading-overlay p {
+ color: white;
+ font-weight: 500;
+}
+
+.spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Scrollbar Styling */
+.wifi-list::-webkit-scrollbar,
+.log-container::-webkit-scrollbar {
+ width: 8px;
+}
+
+.wifi-list::-webkit-scrollbar-track,
+.log-container::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.wifi-list::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+.log-container::-webkit-scrollbar-thumb {
+ background: #374151;
+ border-radius: 4px;
+}
+
+.wifi-list::-webkit-scrollbar-thumb:hover,
+.log-container::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Hotspot Details */
+.hotspot-details {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ background: var(--background);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-color);
+}
+
+.hotspot-info-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.hotspot-info-item:last-child {
+ border-bottom: none;
+}
+
+.info-label {
+ font-weight: 500;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.info-value {
+ font-weight: 600;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+}
+
+/* Responsive Design */
+@media (max-width: 1024px) {
+ .grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ body {
+ padding: 1rem;
+ }
+
+ .container {
+ padding: 1.5rem;
+ }
+
+ .header h1 {
+ font-size: 1.5rem;
+ }
+
+ .header-content {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .grid {
+ grid-template-columns: 1fr;
+ }
+
+ .button-group {
+ grid-template-columns: 1fr;
+ }
+
+ .toast {
+ left: 1rem;
+ right: 1rem;
+ max-width: none;
+ }
+}
+
+@media (max-width: 480px) {
+ .stat-card {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .devices-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/generate.go b/generate.go
new file mode 100644
index 0000000..c01b94b
--- /dev/null
+++ b/generate.go
@@ -0,0 +1,4 @@
+package main
+
+//go:generate go tool oapi-codegen -config oapi-types.cfg.yaml openapi.yaml
+//go:generate go tool oapi-codegen -config oapi-gin.cfg.yaml openapi.yaml
diff --git a/go.mod b/go.mod
index 0f2fed9..5e17b30 100644
--- a/go.mod
+++ b/go.mod
@@ -1,9 +1,62 @@
-module git.nemunai.re/nemunaire/repeater
+module github.com/nemunaire/repeater
go 1.24.4
require (
+ github.com/getkin/kin-openapi v0.132.0
+ github.com/gin-gonic/gin v1.11.0
github.com/godbus/dbus/v5 v5.1.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
)
+
+require (
+ github.com/bytedance/sonic v1.14.0 // indirect
+ github.com/bytedance/sonic/loader v0.3.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.27.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
+ github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
+ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
+ github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/perimeterx/marshmallow v1.1.5 // indirect
+ github.com/quic-go/qpack v0.5.1 // indirect
+ github.com/quic-go/quic-go v0.54.0 // indirect
+ github.com/speakeasy-api/jsonpath v0.6.0 // indirect
+ github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.0 // indirect
+ github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
+ go.uber.org/mock v0.5.0 // indirect
+ golang.org/x/arch v0.20.0 // indirect
+ golang.org/x/crypto v0.40.0 // indirect
+ golang.org/x/mod v0.25.0 // indirect
+ golang.org/x/net v0.42.0 // indirect
+ golang.org/x/sync v0.16.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.27.0 // indirect
+ golang.org/x/tools v0.34.0 // indirect
+ google.golang.org/protobuf v1.36.9 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
diff --git a/go.sum b/go.sum
index 0546936..17956cd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,239 @@
+github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
+github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
+github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
+github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
+github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
+github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
+github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
+github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
+github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
+github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
+github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
+github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
+github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
+github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
+github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
+github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
+github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
+github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
+github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
+github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
+github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
+go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
+golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
+google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go
new file mode 100644
index 0000000..7c87a7c
--- /dev/null
+++ b/internal/api/handlers/handlers.go
@@ -0,0 +1,148 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "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/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()
+ if err != nil {
+ logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"})
+ return
+ }
+
+ logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés")
+ c.JSON(http.StatusOK, networks)
+}
+
+// ConnectWiFi handles WiFi connection requests
+func ConnectWiFi(c *gin.Context) {
+ var req models.WiFiConnectRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
+ return
+ }
+
+ logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID)
+
+ err := wifi.Connect(req.SSID, req.Password)
+ if err != nil {
+ logging.AddLog("WiFi", "Échec de connexion: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()})
+ return
+ }
+
+ logging.AddLog("WiFi", "Connexion réussie à "+req.SSID)
+ c.JSON(http.StatusOK, gin.H{"status": "success"})
+}
+
+// DisconnectWiFi handles WiFi disconnection
+func DisconnectWiFi(c *gin.Context) {
+ logging.AddLog("WiFi", "Tentative de déconnexion")
+
+ err := wifi.Disconnect()
+ if err != nil {
+ logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()})
+ return
+ }
+
+ logging.AddLog("WiFi", "Déconnexion réussie")
+ c.JSON(http.StatusOK, gin.H{"status": "success"})
+}
+
+// ConfigureHotspot handles hotspot configuration
+func ConfigureHotspot(c *gin.Context) {
+ var config models.HotspotConfig
+ if err := c.ShouldBindJSON(&config); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
+ return
+ }
+
+ err := hotspot.Configure(config)
+ if err != nil {
+ logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()})
+ return
+ }
+
+ logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID)
+ c.JSON(http.StatusOK, gin.H{"status": "success"})
+}
+
+// ToggleHotspot handles hotspot enable/disable
+func ToggleHotspot(c *gin.Context, status *models.SystemStatus) {
+ // Determine current state
+ isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED"
+
+ var err error
+ if !isEnabled {
+ err = hotspot.Start()
+ logging.AddLog("Hotspot", "Hotspot activé")
+ } else {
+ err = hotspot.Stop()
+ logging.AddLog("Hotspot", "Hotspot désactivé")
+ }
+
+ if err != nil {
+ logging.AddLog("Hotspot", "Erreur: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()})
+ return
+ }
+
+ // Update status immediately
+ status.HotspotStatus = hotspot.GetDetailedStatus()
+
+ c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled})
+}
+
+// GetDevices returns connected devices
+func GetDevices(c *gin.Context, cfg *config.Config) {
+ devices, err := station.GetStations()
+ if err != nil {
+ logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"})
+ return
+ }
+
+ c.JSON(http.StatusOK, devices)
+}
+
+// GetStatus returns system status
+func GetStatus(c *gin.Context, status *models.SystemStatus) {
+ c.JSON(http.StatusOK, status)
+}
+
+// GetLogs returns system logs
+func GetLogs(c *gin.Context) {
+ logs := logging.GetLogs()
+ c.JSON(http.StatusOK, logs)
+}
+
+// ClearLogs clears system logs
+func ClearLogs(c *gin.Context) {
+ logging.ClearLogs()
+ c.JSON(http.StatusOK, gin.H{"status": "success"})
+}
diff --git a/internal/api/handlers/websocket.go b/internal/api/handlers/websocket.go
new file mode 100644
index 0000000..87d2823
--- /dev/null
+++ b/internal/api/handlers/websocket.go
@@ -0,0 +1,38 @@
+package handlers
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/gorilla/websocket"
+ "github.com/nemunaire/repeater/internal/logging"
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
+}
+
+// WebSocketLogs handles WebSocket connections for real-time logs
+func WebSocketLogs(c *gin.Context) {
+ conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+ if err != nil {
+ log.Printf("Erreur WebSocket: %v", err)
+ return
+ }
+ defer conn.Close()
+
+ // Register client
+ logging.RegisterWebSocketClient(conn)
+ defer logging.UnregisterWebSocketClient(conn)
+
+ // Keep connection alive
+ for {
+ _, _, err := conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ }
+}
diff --git a/internal/api/handlers/websocket_wifi.go b/internal/api/handlers/websocket_wifi.go
new file mode 100644
index 0000000..ff8d132
--- /dev/null
+++ b/internal/api/handlers/websocket_wifi.go
@@ -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
+ }
+ }
+}
diff --git a/internal/api/router.go b/internal/api/router.go
new file mode 100644
index 0000000..956661e
--- /dev/null
+++ b/internal/api/router.go
@@ -0,0 +1,69 @@
+package api
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/nemunaire/repeater/internal/api/handlers"
+ "github.com/nemunaire/repeater/internal/config"
+ "github.com/nemunaire/repeater/internal/models"
+)
+
+// SetupRouter creates and configures the Gin router
+func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine {
+ // Set Gin to release mode (can be overridden with GIN_MODE env var)
+ gin.SetMode(gin.ReleaseMode)
+
+ r := gin.Default()
+
+ // API routes
+ api := r.Group("/api")
+ {
+ // 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)
+ }
+
+ // Hotspot endpoints
+ hotspot := api.Group("/hotspot")
+ {
+ hotspot.POST("/config", handlers.ConfigureHotspot)
+ hotspot.POST("/toggle", func(c *gin.Context) {
+ handlers.ToggleHotspot(c, status)
+ })
+ }
+
+ // Device endpoints
+ api.GET("/devices", func(c *gin.Context) {
+ handlers.GetDevices(c, cfg)
+ })
+
+ // Status endpoint
+ api.GET("/status", func(c *gin.Context) {
+ handlers.GetStatus(c, status)
+ })
+
+ // Log endpoints
+ api.GET("/logs", handlers.GetLogs)
+ api.DELETE("/logs", handlers.ClearLogs)
+ }
+
+ // WebSocket endpoints
+ r.GET("/ws/logs", handlers.WebSocketLogs)
+ r.GET("/ws/wifi", handlers.WebSocketWifi)
+
+ // Serve static files
+ sub, err := fs.Sub(assets, "static")
+ if err != nil {
+ panic("Unable to access static directory: " + err.Error())
+ }
+ r.NoRoute(gin.WrapH(http.FileServer(http.FS(sub))))
+
+ return r
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..3e55b49
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,287 @@
+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
+}
+
+// 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,
+ }
+}
+
+// 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
+ 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.Config, a.Assets)
+
+ logging.AddLog("Système", "Serveur API démarré sur "+addr)
+ return router.Run(addr)
+}
+
+// Shutdown gracefully shuts down the application
+func (a *App) Shutdown() {
+ // 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() {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ for range 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() {
+ ticker := time.NewTicker(10 * time.Second)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ devices, err := station.GetStations()
+ if err != nil {
+ log.Printf("Error getting connected devices: %v", err)
+ }
+
+ a.StatusMutex.Lock()
+ 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()
+
+ // Convert backend.Station to models.ConnectedDevice
+ device := models.ConnectedDevice{
+ Name: st.Hostname,
+ Type: st.Type,
+ MAC: st.MAC,
+ IP: st.IP,
+ }
+
+ // 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
+ for i, d := range a.Status.ConnectedDevices {
+ if d.MAC == st.MAC {
+ a.Status.ConnectedDevices[i] = models.ConnectedDevice{
+ Name: st.Hostname,
+ Type: st.Type,
+ MAC: st.MAC,
+ IP: st.IP,
+ }
+ break
+ }
+ }
+}
diff --git a/internal/config/cli.go b/internal/config/cli.go
new file mode 100644
index 0000000..49013b3
--- /dev/null
+++ b/internal/config/cli.go
@@ -0,0 +1,34 @@
+package config
+
+import (
+ "flag"
+)
+
+// declareFlags registers flags for the structure Options.
+func declareFlags(o *Config) {
+ flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket")
+ flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name")
+ flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name")
+ flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)")
+ flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'")
+ flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file")
+ flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file")
+ flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages")
+ flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file")
+ flag.Var(&StringArray{&o.SyslogFilter}, "daemon.info iwd:", "Filter string for syslog lines")
+ flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs")
+}
+
+// parseCLI parse the flags and treats extra args as configuration filename.
+func parseCLI(o *Config) error {
+ flag.Parse()
+
+ for _, conf := range flag.Args() {
+ err := parseFile(o, conf)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..3cd2fa7
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,106 @@
+package config
+
+import (
+ "flag"
+ "log"
+ "os"
+ "path"
+ "strings"
+)
+
+type Config struct {
+ Bind string
+ WifiInterface string
+ HotspotInterface string
+ WifiBackend string
+ StationBackend string // "arp", "dhcp", or "hostapd"
+ DHCPLeasesPath string
+ ARPTablePath string
+ SyslogEnabled bool
+ SyslogPath string
+ SyslogFilter []string
+ SyslogSource string
+}
+
+// ConsolidateConfig fills an Options struct by reading configuration from
+// config files, environment, then command line.
+//
+// Should be called only one time.
+func ConsolidateConfig() (opts *Config, err error) {
+ // Define defaults options
+ opts = &Config{
+ Bind: ":8080",
+ WifiInterface: "wlan0",
+ HotspotInterface: "wlan1",
+ DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases",
+ ARPTablePath: "/proc/net/arp",
+ SyslogEnabled: false,
+ SyslogPath: "/var/log/messages",
+ SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"},
+ SyslogSource: "iwd",
+ }
+
+ declareFlags(opts)
+
+ // Establish a list of possible configuration file locations
+ configLocations := []string{
+ "repeater.conf",
+ }
+
+ if home, err := os.UserConfigDir(); err == nil {
+ configLocations = append(configLocations, path.Join(home, "repeater", "repeater.conf"))
+ }
+
+ configLocations = append(configLocations, path.Join("etc", "repeater.conf"))
+
+ // If config file exists, read configuration from it
+ for _, filename := range configLocations {
+ if _, e := os.Stat(filename); !os.IsNotExist(e) {
+ log.Printf("Loading configuration from %s\n", filename)
+ err = parseFile(opts, filename)
+ if err != nil {
+ return
+ }
+ break
+ }
+ }
+
+ // Then, overwrite that by what is present in the environment
+ err = parseEnvironmentVariables(opts)
+ if err != nil {
+ return
+ }
+
+ // Finaly, command line takes precedence
+ err = parseCLI(opts)
+ if err != nil {
+ return
+ }
+
+ // Validate configuration
+ if opts.WifiBackend != "iwd" && opts.WifiBackend != "wpasupplicant" {
+ log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend)
+ }
+
+ if opts.StationBackend != "arp" && opts.StationBackend != "dhcp" && opts.StationBackend != "hostapd" {
+ log.Fatalf("station-backend must be set to 'arp', 'dhcp', or 'hostapd' (got: '%s')", opts.StationBackend)
+ }
+
+ return
+}
+
+// parseLine treats a config line and place the read value in the variable
+// declared to the corresponding flag.
+func parseLine(o *Config, line string) (err error) {
+ fields := strings.SplitN(line, "=", 2)
+ orig_key := strings.TrimSpace(fields[0])
+ value := strings.TrimSpace(fields[1])
+
+ key := strings.TrimPrefix(orig_key, "REPEATER_")
+ key = strings.Replace(key, "_", "-", -1)
+ key = strings.ToLower(key)
+
+ err = flag.Set(key, value)
+
+ return
+}
diff --git a/internal/config/custom.go b/internal/config/custom.go
new file mode 100644
index 0000000..f038b7e
--- /dev/null
+++ b/internal/config/custom.go
@@ -0,0 +1,45 @@
+package config
+
+import (
+ "fmt"
+ "net/url"
+)
+
+// StringArray is a custom type for handling multiple string values in flags.
+type StringArray struct {
+ Array *[]string
+}
+
+// String returns a string representation of the StringArray.
+func (i *StringArray) String() string {
+ return fmt.Sprintf("%v", i.Array)
+}
+
+// Set appends a new string value to the StringArray.
+func (i *StringArray) Set(value string) error {
+ *i.Array = append(*i.Array, value)
+
+ return nil
+}
+
+type URL struct {
+ URL *url.URL
+}
+
+func (i *URL) String() string {
+ if i.URL != nil {
+ return i.URL.String()
+ } else {
+ return ""
+ }
+}
+
+func (i *URL) Set(value string) error {
+ u, err := url.Parse(value)
+ if err != nil {
+ return err
+ }
+
+ *i.URL = *u
+ return nil
+}
diff --git a/internal/config/env.go b/internal/config/env.go
new file mode 100644
index 0000000..8888746
--- /dev/null
+++ b/internal/config/env.go
@@ -0,0 +1,21 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// parseEnvironmentVariables analyzes all the environment variables to find
+// each one starting by REPEATER_
+func parseEnvironmentVariables(o *Config) (err error) {
+ for _, line := range os.Environ() {
+ if strings.HasPrefix(line, "REPEATER_") {
+ err := parseLine(o, line)
+ if err != nil {
+ return fmt.Errorf("error in environment (%q): %w", line, err)
+ }
+ }
+ }
+ return
+}
diff --git a/internal/config/file.go b/internal/config/file.go
new file mode 100644
index 0000000..8ba6a10
--- /dev/null
+++ b/internal/config/file.go
@@ -0,0 +1,33 @@
+package config
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// parseFile opens the file at the given filename path, then treat each line
+// not starting with '#' as a configuration statement.
+func parseFile(o *Config, filename string) error {
+ fp, err := os.Open(filename)
+ if err != nil {
+ return err
+ }
+ defer fp.Close()
+
+ scanner := bufio.NewScanner(fp)
+ n := 0
+ for scanner.Scan() {
+ n += 1
+ line := strings.TrimSpace(scanner.Text())
+ if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
+ err := parseLine(o, line)
+ if err != nil {
+ return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/internal/hotspot/hotspot.go b/internal/hotspot/hotspot.go
new file mode 100644
index 0000000..793352f
--- /dev/null
+++ b/internal/hotspot/hotspot.go
@@ -0,0 +1,111 @@
+package hotspot
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+
+ "github.com/nemunaire/repeater/internal/models"
+)
+
+const (
+ AP_INTERFACE = "wlan1"
+ HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
+)
+
+// Configure updates the hotspot configuration
+func Configure(config models.HotspotConfig) error {
+ hostapdConfig := fmt.Sprintf(`interface=%s
+driver=nl80211
+ssid=%s
+hw_mode=g
+channel=%d
+wmm_enabled=0
+macaddr_acl=0
+auth_algs=1
+ignore_broadcast_ssid=0
+wpa=2
+wpa_passphrase=%s
+wpa_key_mgmt=WPA-PSK
+wpa_pairwise=TKIP
+rsn_pairwise=CCMP
+`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
+
+ return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
+}
+
+// Start starts the hotspot
+func Start() error {
+ cmd := exec.Command("/etc/init.d/hostapd", "start")
+ return cmd.Run()
+}
+
+// Stop stops the hotspot
+func Stop() error {
+ cmd := exec.Command("/etc/init.d/hostapd", "stop")
+ return cmd.Run()
+}
+
+// Status checks if the hotspot is running.
+// Returns nil if the service is running, or an error if it's stopped or crashed.
+func Status() error {
+ cmd := exec.Command("/etc/init.d/hostapd", "status")
+ return cmd.Run()
+}
+
+// GetDetailedStatus retrieves detailed status information from hostapd_cli.
+// Returns nil if hostapd is not running or if there's an error.
+func GetDetailedStatus() *models.HotspotStatus {
+ cmd := exec.Command("hostapd_cli", "status")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil
+ }
+
+ status := &models.HotspotStatus{}
+ lines := strings.Split(string(output), "\n")
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "Selected interface") {
+ continue
+ }
+
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ switch key {
+ case "state":
+ status.State = value
+ case "channel":
+ if ch, err := strconv.Atoi(value); err == nil {
+ status.Channel = ch
+ }
+ case "freq":
+ if freq, err := strconv.Atoi(value); err == nil {
+ status.Frequency = freq
+ }
+ case "ssid[0]":
+ status.SSID = value
+ case "bssid[0]":
+ status.BSSID = value
+ case "num_sta[0]":
+ if num, err := strconv.Atoi(value); err == nil {
+ status.NumStations = num
+ }
+ case "hw_mode":
+ status.HWMode = value
+ case "country_code":
+ status.CountryCode = value
+ }
+ }
+
+ return status
+}
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
new file mode 100644
index 0000000..064a813
--- /dev/null
+++ b/internal/logging/logging.go
@@ -0,0 +1,94 @@
+package logging
+
+import (
+ "log"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/nemunaire/repeater/internal/models"
+)
+
+var (
+ logEntries []models.LogEntry
+ logMutex sync.RWMutex
+ websocketClients = make(map[*websocket.Conn]bool)
+ clientsMutex sync.RWMutex
+)
+
+// AddLog adds a new log entry
+func AddLog(source, message string) {
+ logMutex.Lock()
+ entry := models.LogEntry{
+ Timestamp: time.Now(),
+ Source: source,
+ Message: message,
+ }
+ logEntries = append(logEntries, entry)
+
+ // Keep only the last 100 logs
+ if len(logEntries) > 100 {
+ logEntries = logEntries[len(logEntries)-100:]
+ }
+ logMutex.Unlock()
+
+ // Broadcast to WebSocket clients
+ broadcastToWebSockets(entry)
+
+ // Log to console
+ log.Printf("[%s] %s", source, message)
+}
+
+// GetLogs returns all log entries
+func GetLogs() []models.LogEntry {
+ logMutex.RLock()
+ defer logMutex.RUnlock()
+
+ logs := make([]models.LogEntry, len(logEntries))
+ copy(logs, logEntries)
+ return logs
+}
+
+// ClearLogs clears all log entries
+func ClearLogs() {
+ logMutex.Lock()
+ logEntries = []models.LogEntry{}
+ logMutex.Unlock()
+
+ AddLog("Système", "Logs effacés")
+}
+
+// RegisterWebSocketClient registers a new WebSocket client
+func RegisterWebSocketClient(conn *websocket.Conn) {
+ clientsMutex.Lock()
+ websocketClients[conn] = true
+ clientsMutex.Unlock()
+
+ // Send existing logs to the new client
+ logMutex.RLock()
+ for _, entry := range logEntries {
+ conn.WriteJSON(entry)
+ }
+ logMutex.RUnlock()
+}
+
+// UnregisterWebSocketClient removes a WebSocket client
+func UnregisterWebSocketClient(conn *websocket.Conn) {
+ clientsMutex.Lock()
+ delete(websocketClients, conn)
+ clientsMutex.Unlock()
+}
+
+// broadcastToWebSockets sends a log entry to all connected WebSocket clients
+func broadcastToWebSockets(entry models.LogEntry) {
+ clientsMutex.RLock()
+ defer clientsMutex.RUnlock()
+
+ for client := range websocketClients {
+ err := client.WriteJSON(entry)
+ if err != nil {
+ client.Close()
+ delete(websocketClients, client)
+ }
+ }
+}
diff --git a/internal/models/models.go b/internal/models/models.go
new file mode 100644
index 0000000..64d5c79
--- /dev/null
+++ b/internal/models/models.go
@@ -0,0 +1,71 @@
+package models
+
+import "time"
+
+// WiFiNetwork represents a discovered WiFi network
+type WiFiNetwork struct {
+ SSID string `json:"ssid"`
+ Signal int `json:"signal"`
+ Security string `json:"security"`
+ Channel int `json:"channel"`
+ BSSID string `json:"bssid"`
+}
+
+// ConnectedDevice represents a device connected to the hotspot
+type ConnectedDevice struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ MAC string `json:"mac"`
+ IP string `json:"ip"`
+}
+
+// HotspotConfig represents hotspot configuration
+type HotspotConfig struct {
+ SSID string `json:"ssid"`
+ Password string `json:"password"`
+ Channel int `json:"channel"`
+}
+
+// HotspotStatus represents detailed hotspot status
+type HotspotStatus struct {
+ State string `json:"state"` // ENABLED, DISABLED, etc.
+ SSID string `json:"ssid"` // Current SSID being broadcast
+ BSSID string `json:"bssid"` // MAC address of the AP
+ Channel int `json:"channel"` // Current channel
+ Frequency int `json:"frequency"` // Frequency in MHz
+ NumStations int `json:"numStations"` // Number of connected stations
+ HWMode string `json:"hwMode"` // Hardware mode (g, a, n, ac, etc.)
+ CountryCode string `json:"countryCode"` // Country code
+}
+
+// SystemStatus represents overall system status
+type SystemStatus struct {
+ Connected bool `json:"connected"`
+ ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming
+ ConnectedSSID string `json:"connectedSSID"`
+ HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status
+ ConnectedCount int `json:"connectedCount"`
+ DataUsage float64 `json:"dataUsage"`
+ Uptime int64 `json:"uptime"`
+ ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
+}
+
+// WiFiConnectRequest represents a request to connect to WiFi
+type WiFiConnectRequest struct {
+ SSID string `json:"ssid" binding:"required"`
+ Password string `json:"password"`
+}
+
+// LogEntry represents a system log entry
+type LogEntry struct {
+ Timestamp time.Time `json:"timestamp"`
+ Source string `json:"source"`
+ Message string `json:"message"`
+}
+
+// DHCPLease represents a DHCP lease entry
+type DHCPLease struct {
+ IP string
+ MAC string
+ Hostname string
+}
diff --git a/internal/station/arp/backend.go b/internal/station/arp/backend.go
new file mode 100644
index 0000000..5b4e975
--- /dev/null
+++ b/internal/station/arp/backend.go
@@ -0,0 +1,177 @@
+package arp
+
+import (
+ "sync"
+ "time"
+
+ "github.com/nemunaire/repeater/internal/station/backend"
+)
+
+// Backend implements StationBackend using ARP table discovery
+type Backend struct {
+ arpTablePath string
+ lastStations map[string]backend.Station // Key: MAC address
+ callbacks backend.EventCallbacks
+ stopChan chan struct{}
+ mu sync.RWMutex
+ running bool
+}
+
+// NewBackend creates a new ARP backend
+func NewBackend() *Backend {
+ return &Backend{
+ lastStations: make(map[string]backend.Station),
+ stopChan: make(chan struct{}),
+ }
+}
+
+// Initialize initializes the ARP backend
+func (b *Backend) Initialize(config backend.BackendConfig) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ b.arpTablePath = config.ARPTablePath
+ if b.arpTablePath == "" {
+ b.arpTablePath = "/proc/net/arp"
+ }
+
+ return nil
+}
+
+// Close cleans up backend resources
+func (b *Backend) Close() error {
+ b.StopEventMonitoring()
+ return nil
+}
+
+// GetStations returns all connected stations from ARP table
+func (b *Backend) GetStations() ([]backend.Station, error) {
+ b.mu.RLock()
+ arpTablePath := b.arpTablePath
+ b.mu.RUnlock()
+
+ arpEntries, err := parseARPTable(arpTablePath)
+ if err != nil {
+ return nil, err
+ }
+
+ var stations []backend.Station
+ for _, entry := range arpEntries {
+ // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM)
+ if entry.Flags == 2 || entry.Flags == 6 {
+ st := backend.Station{
+ MAC: entry.HWAddress.String(),
+ IP: entry.IP.String(),
+ Hostname: "", // No hostname available from ARP
+ Type: backend.GuessDeviceType("", entry.HWAddress.String()),
+ Signal: 0, // Not available from ARP
+ RxBytes: 0, // Not available from ARP
+ TxBytes: 0, // Not available from ARP
+ ConnectedAt: time.Now(),
+ }
+ stations = append(stations, st)
+ }
+ }
+
+ return stations, nil
+}
+
+// StartEventMonitoring starts monitoring for station events via polling
+func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ if b.running {
+ return nil
+ }
+
+ b.callbacks = callbacks
+ b.running = true
+
+ // Start polling goroutine
+ go b.pollLoop()
+
+ return nil
+}
+
+// StopEventMonitoring stops event monitoring
+func (b *Backend) StopEventMonitoring() {
+ b.mu.Lock()
+ if !b.running {
+ b.mu.Unlock()
+ return
+ }
+ b.running = false
+ b.mu.Unlock()
+
+ close(b.stopChan)
+}
+
+// SupportsRealTimeEvents returns false (ARP is polling-based)
+func (b *Backend) SupportsRealTimeEvents() bool {
+ return false
+}
+
+// pollLoop polls the ARP table and simulates events
+func (b *Backend) pollLoop() {
+ ticker := time.NewTicker(10 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ b.checkForChanges()
+ case <-b.stopChan:
+ return
+ }
+ }
+}
+
+// checkForChanges compares current state with last state and triggers callbacks
+func (b *Backend) checkForChanges() {
+ // Get current stations
+ current, err := b.GetStations()
+ if err != nil {
+ return
+ }
+
+ // Build map of current stations
+ currentMap := make(map[string]backend.Station)
+ for _, station := range current {
+ currentMap[station.MAC] = station
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ // Check for new stations (connected)
+ for mac, station := range currentMap {
+ if _, exists := b.lastStations[mac]; !exists {
+ // New station connected
+ if b.callbacks.OnStationConnected != nil {
+ go b.callbacks.OnStationConnected(station)
+ }
+ } else {
+ // Check for updates (IP change, etc.)
+ oldStation := b.lastStations[mac]
+ if oldStation.IP != station.IP || oldStation.Hostname != station.Hostname {
+ if b.callbacks.OnStationUpdated != nil {
+ go b.callbacks.OnStationUpdated(station)
+ }
+ }
+ }
+ }
+
+ // Check for disconnected stations
+ for mac := range b.lastStations {
+ if _, exists := currentMap[mac]; !exists {
+ // Station disconnected
+ if b.callbacks.OnStationDisconnected != nil {
+ go b.callbacks.OnStationDisconnected(mac)
+ }
+ }
+ }
+
+ // Update last state
+ b.lastStations = currentMap
+}
diff --git a/internal/station/arp/parser.go b/internal/station/arp/parser.go
new file mode 100644
index 0000000..ca40f01
--- /dev/null
+++ b/internal/station/arp/parser.go
@@ -0,0 +1,64 @@
+package arp
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strings"
+)
+
+// ARPEntry represents an entry in the ARP table
+type ARPEntry struct {
+ IP net.IP
+ HWType int
+ Flags int
+ HWAddress net.HardwareAddr
+ Mask string
+ Device string
+}
+
+// parseARPTable reads and parses ARP table from /proc/net/arp format
+func parseARPTable(path string) ([]ARPEntry, error) {
+ var entries []ARPEntry
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return entries, err
+ }
+
+ for _, line := range strings.Split(string(content), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) > 5 {
+ var entry ARPEntry
+
+ // Parse HWType (hex format)
+ if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil {
+ continue
+ }
+
+ // Parse Flags (hex format)
+ if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil {
+ continue
+ }
+
+ // Parse IP address
+ entry.IP = net.ParseIP(fields[0])
+ if entry.IP == nil {
+ continue
+ }
+
+ // Parse MAC address
+ entry.HWAddress, err = net.ParseMAC(fields[3])
+ if err != nil {
+ continue
+ }
+
+ entry.Mask = fields[4]
+ entry.Device = fields[5]
+
+ entries = append(entries, entry)
+ }
+ }
+
+ return entries, nil
+}
diff --git a/internal/station/backend/types.go b/internal/station/backend/types.go
new file mode 100644
index 0000000..9874d5c
--- /dev/null
+++ b/internal/station/backend/types.go
@@ -0,0 +1,99 @@
+package backend
+
+import (
+ "strings"
+ "time"
+)
+
+// StationBackend defines the interface for station/device discovery backends.
+// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery.
+type StationBackend interface {
+ // Initialize initializes the backend with the given configuration
+ Initialize(config BackendConfig) error
+
+ // Close cleans up backend resources
+ Close() error
+
+ // GetStations returns all currently connected stations
+ GetStations() ([]Station, error)
+
+ // StartEventMonitoring starts monitoring for station events
+ // Backends that don't support real-time events will poll and simulate events
+ StartEventMonitoring(callbacks EventCallbacks) error
+
+ // StopEventMonitoring stops event monitoring
+ StopEventMonitoring()
+
+ // SupportsRealTimeEvents returns true if backend supports real-time events (e.g., DBus)
+ // Returns false for polling-based backends (ARP, DHCP)
+ SupportsRealTimeEvents() bool
+}
+
+// Station represents a connected device in a backend-agnostic format
+type Station struct {
+ MAC string // Hardware MAC address (required, primary identifier)
+ IP string // IP address (may be empty for some backends initially)
+ Hostname string // Device hostname (may be empty)
+ Type string // Device type: "mobile", "laptop", "tablet", "unknown"
+ Signal int32 // Signal strength in dBm (0 if not available)
+ RxBytes uint64 // Received bytes (0 if not available)
+ TxBytes uint64 // Transmitted bytes (0 if not available)
+ ConnectedAt time.Time // When station connected (best effort)
+}
+
+// EventCallbacks defines callback functions for station events.
+// Backends call these when stations connect, disconnect, or update.
+type EventCallbacks struct {
+ // OnStationConnected is called when a new station connects
+ OnStationConnected func(station Station)
+
+ // OnStationDisconnected is called when a station disconnects
+ OnStationDisconnected func(mac string)
+
+ // OnStationUpdated is called when station information changes
+ // (e.g., IP discovered, signal strength changed)
+ OnStationUpdated func(station Station)
+}
+
+// BackendConfig provides configuration for backend initialization
+type BackendConfig struct {
+ // Common
+ InterfaceName string // Network interface (e.g., "wlan1")
+
+ // ARP-specific
+ ARPTablePath string // Path to /proc/net/arp (default: "/proc/net/arp")
+
+ // DHCP-specific
+ DHCPLeasesPath string // Path to DHCP leases file
+
+ // Hostapd-specific
+ HostapdInterface string // Hostapd interface name for DBus
+}
+
+// GuessDeviceType attempts to guess device type from hostname and MAC address
+func GuessDeviceType(hostname, mac string) string {
+ hostname = strings.ToLower(hostname)
+
+ if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
+ return "mobile"
+ } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
+ return "tablet"
+ } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
+ return "laptop"
+ }
+
+ // Guess by MAC prefix (OUI)
+ if len(mac) >= 8 {
+ macPrefix := strings.ToUpper(mac[:8])
+ switch macPrefix {
+ case "00:50:56", "00:0C:29", "00:05:69": // VMware
+ return "laptop"
+ case "08:00:27": // VirtualBox
+ return "laptop"
+ default:
+ return "mobile"
+ }
+ }
+
+ return "unknown"
+}
diff --git a/internal/station/dhcp/backend.go b/internal/station/dhcp/backend.go
new file mode 100644
index 0000000..54abf95
--- /dev/null
+++ b/internal/station/dhcp/backend.go
@@ -0,0 +1,184 @@
+package dhcp
+
+import (
+ "sync"
+ "time"
+
+ "github.com/nemunaire/repeater/internal/station/backend"
+)
+
+// Backend implements StationBackend using DHCP lease discovery
+type Backend struct {
+ dhcpLeasesPath string
+ lastStations map[string]backend.Station // Key: MAC address
+ callbacks backend.EventCallbacks
+ stopChan chan struct{}
+ mu sync.RWMutex
+ running bool
+}
+
+// NewBackend creates a new DHCP backend
+func NewBackend() *Backend {
+ return &Backend{
+ lastStations: make(map[string]backend.Station),
+ stopChan: make(chan struct{}),
+ }
+}
+
+// Initialize initializes the DHCP backend
+func (b *Backend) Initialize(config backend.BackendConfig) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ b.dhcpLeasesPath = config.DHCPLeasesPath
+ if b.dhcpLeasesPath == "" {
+ b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
+ }
+
+ return nil
+}
+
+// Close cleans up backend resources
+func (b *Backend) Close() error {
+ b.StopEventMonitoring()
+ return nil
+}
+
+// GetStations returns all connected stations from DHCP leases validated by ARP
+func (b *Backend) GetStations() ([]backend.Station, error) {
+ b.mu.RLock()
+ dhcpLeasesPath := b.dhcpLeasesPath
+ b.mu.RUnlock()
+
+ // Read DHCP leases
+ leases, err := parseDHCPLeases(dhcpLeasesPath)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get ARP information for validation
+ arpInfo, err := getARPInfo()
+ if err != nil {
+ return nil, err
+ }
+
+ var stations []backend.Station
+ for _, lease := range leases {
+ // Check if the device is still connected via ARP
+ if _, exists := arpInfo[lease.IP]; exists {
+ st := backend.Station{
+ MAC: lease.MAC,
+ IP: lease.IP,
+ Hostname: lease.Hostname,
+ Type: backend.GuessDeviceType(lease.Hostname, lease.MAC),
+ Signal: 0, // Not available from DHCP
+ RxBytes: 0, // Not available from DHCP
+ TxBytes: 0, // Not available from DHCP
+ ConnectedAt: time.Now(),
+ }
+ stations = append(stations, st)
+ }
+ }
+
+ return stations, nil
+}
+
+// StartEventMonitoring starts monitoring for station events via polling
+func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ if b.running {
+ return nil
+ }
+
+ b.callbacks = callbacks
+ b.running = true
+
+ // Start polling goroutine
+ go b.pollLoop()
+
+ return nil
+}
+
+// StopEventMonitoring stops event monitoring
+func (b *Backend) StopEventMonitoring() {
+ b.mu.Lock()
+ if !b.running {
+ b.mu.Unlock()
+ return
+ }
+ b.running = false
+ b.mu.Unlock()
+
+ close(b.stopChan)
+}
+
+// SupportsRealTimeEvents returns false (DHCP is polling-based)
+func (b *Backend) SupportsRealTimeEvents() bool {
+ return false
+}
+
+// pollLoop polls DHCP leases and simulates events
+func (b *Backend) pollLoop() {
+ ticker := time.NewTicker(10 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ b.checkForChanges()
+ case <-b.stopChan:
+ return
+ }
+ }
+}
+
+// checkForChanges compares current state with last state and triggers callbacks
+func (b *Backend) checkForChanges() {
+ // Get current stations
+ current, err := b.GetStations()
+ if err != nil {
+ return
+ }
+
+ // Build map of current stations
+ currentMap := make(map[string]backend.Station)
+ for _, st := range current {
+ currentMap[st.MAC] = st
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ // Check for new stations (connected)
+ for mac, st := range currentMap {
+ if _, exists := b.lastStations[mac]; !exists {
+ // New station connected
+ if b.callbacks.OnStationConnected != nil {
+ go b.callbacks.OnStationConnected(st)
+ }
+ } else {
+ // Check for updates (IP change, hostname change, etc.)
+ oldStation := b.lastStations[mac]
+ if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname {
+ if b.callbacks.OnStationUpdated != nil {
+ go b.callbacks.OnStationUpdated(st)
+ }
+ }
+ }
+ }
+
+ // Check for disconnected stations
+ for mac := range b.lastStations {
+ if _, exists := currentMap[mac]; !exists {
+ // Station disconnected
+ if b.callbacks.OnStationDisconnected != nil {
+ go b.callbacks.OnStationDisconnected(mac)
+ }
+ }
+ }
+
+ // Update last state
+ b.lastStations = currentMap
+}
diff --git a/internal/station/dhcp/parser.go b/internal/station/dhcp/parser.go
new file mode 100644
index 0000000..efcd583
--- /dev/null
+++ b/internal/station/dhcp/parser.go
@@ -0,0 +1,72 @@
+package dhcp
+
+import (
+ "bufio"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "github.com/nemunaire/repeater/internal/models"
+)
+
+// parseDHCPLeases reads and parses DHCP lease file
+func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
+ var leases []models.DHCPLease
+
+ file, err := os.Open(path)
+ if err != nil {
+ return leases, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ var currentLease models.DHCPLease
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ if strings.HasPrefix(line, "lease ") {
+ ip := strings.Fields(line)[1]
+ currentLease = models.DHCPLease{IP: ip}
+ } else if strings.Contains(line, "hardware ethernet") {
+ mac := strings.Fields(line)[2]
+ mac = strings.TrimSuffix(mac, ";")
+ currentLease.MAC = mac
+ } else if strings.Contains(line, "client-hostname") {
+ hostname := strings.Fields(line)[1]
+ hostname = strings.Trim(hostname, `";`)
+ currentLease.Hostname = hostname
+ } else if line == "}" {
+ if currentLease.IP != "" && currentLease.MAC != "" {
+ leases = append(leases, currentLease)
+ }
+ currentLease = models.DHCPLease{}
+ }
+ }
+
+ return leases, nil
+}
+
+// getARPInfo retrieves ARP table information using arp command
+// Returns a map of IP -> MAC address
+func getARPInfo() (map[string]string, error) {
+ arpInfo := make(map[string]string)
+
+ cmd := exec.Command("arp", "-a")
+ output, err := cmd.Output()
+ if err != nil {
+ return arpInfo, err
+ }
+
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
+ ip := matches[1]
+ mac := matches[2]
+ arpInfo[ip] = mac
+ }
+ }
+
+ return arpInfo, nil
+}
diff --git a/internal/station/factory.go b/internal/station/factory.go
new file mode 100644
index 0000000..94bfc2b
--- /dev/null
+++ b/internal/station/factory.go
@@ -0,0 +1,24 @@
+package station
+
+import (
+ "fmt"
+
+ "github.com/nemunaire/repeater/internal/station/arp"
+ "github.com/nemunaire/repeater/internal/station/backend"
+ "github.com/nemunaire/repeater/internal/station/dhcp"
+ "github.com/nemunaire/repeater/internal/station/hostapd"
+)
+
+// createBackend creates a station backend based on the backend name
+func createBackend(backendName string) (backend.StationBackend, error) {
+ switch backendName {
+ case "arp":
+ return arp.NewBackend(), nil
+ case "dhcp":
+ return dhcp.NewBackend(), nil
+ case "hostapd":
+ return hostapd.NewBackend(), nil
+ default:
+ return nil, fmt.Errorf("invalid station backend: %s (must be 'arp', 'dhcp', or 'hostapd')", backendName)
+ }
+}
diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go
new file mode 100644
index 0000000..ce5708a
--- /dev/null
+++ b/internal/station/hostapd/backend.go
@@ -0,0 +1,345 @@
+package hostapd
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "log"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/nemunaire/repeater/internal/station/backend"
+)
+
+// Backend implements StationBackend using hostapd_cli
+type Backend struct {
+ interfaceName string
+ hostapdCLI string // Path to hostapd_cli executable
+
+ stations map[string]*HostapdStation // Key: MAC address
+ callbacks backend.EventCallbacks
+
+ mu sync.RWMutex
+ running bool
+ stopCh chan struct{}
+
+ // IP correlation - will be populated by periodic DHCP lease correlation
+ ipByMAC map[string]string // MAC -> IP mapping
+}
+
+// NewBackend creates a new hostapd backend
+func NewBackend() *Backend {
+ return &Backend{
+ stations: make(map[string]*HostapdStation),
+ ipByMAC: make(map[string]string),
+ hostapdCLI: "hostapd_cli",
+ stopCh: make(chan struct{}),
+ }
+}
+
+// Initialize initializes the hostapd backend
+func (b *Backend) Initialize(config backend.BackendConfig) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ b.interfaceName = config.InterfaceName
+ if b.interfaceName == "" {
+ b.interfaceName = "wlan1" // Default AP interface
+ }
+
+ // Check if hostapd_cli is available
+ if _, err := exec.LookPath(b.hostapdCLI); err != nil {
+ return fmt.Errorf("hostapd_cli not found in PATH: %w", err)
+ }
+
+ // Verify we can communicate with hostapd
+ if err := b.runCommand("ping"); err != nil {
+ return fmt.Errorf("failed to communicate with hostapd on interface %s: %w", b.interfaceName, err)
+ }
+
+ log.Printf("Hostapd backend initialized for interface %s", b.interfaceName)
+
+ // Load initial station list
+ if err := b.loadStations(); err != nil {
+ log.Printf("Warning: Failed to load initial stations: %v", err)
+ }
+
+ return nil
+}
+
+// Close cleans up backend resources
+func (b *Backend) Close() error {
+ b.StopEventMonitoring()
+ return nil
+}
+
+// runCommand executes a hostapd_cli command and returns the output
+func (b *Backend) runCommand(args ...string) error {
+ cmdArgs := []string{"-i", b.interfaceName}
+ cmdArgs = append(cmdArgs, args...)
+ cmd := exec.Command(b.hostapdCLI, cmdArgs...)
+ return cmd.Run()
+}
+
+// runCommandOutput executes a hostapd_cli command and returns the output
+func (b *Backend) runCommandOutput(args ...string) (string, error) {
+ cmdArgs := []string{"-i", b.interfaceName}
+ cmdArgs = append(cmdArgs, args...)
+ cmd := exec.Command(b.hostapdCLI, cmdArgs...)
+ out, err := cmd.Output()
+ if err != nil {
+ return "", err
+ }
+ return string(out), nil
+}
+
+// GetStations returns all connected stations
+func (b *Backend) GetStations() ([]backend.Station, error) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+
+ stations := make([]backend.Station, 0, len(b.stations))
+ for mac, hs := range b.stations {
+ station := b.convertStation(mac, hs)
+ stations = append(stations, station)
+ }
+
+ return stations, nil
+}
+
+// StartEventMonitoring starts monitoring for station events via polling
+func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ if b.running {
+ return nil
+ }
+
+ b.callbacks = callbacks
+ b.running = true
+
+ // Start polling goroutine
+ go b.pollStations()
+
+ log.Printf("Hostapd event monitoring started (polling mode)")
+ return nil
+}
+
+// StopEventMonitoring stops event monitoring
+func (b *Backend) StopEventMonitoring() {
+ b.mu.Lock()
+ if !b.running {
+ b.mu.Unlock()
+ return
+ }
+ b.running = false
+ b.mu.Unlock()
+
+ close(b.stopCh)
+}
+
+// SupportsRealTimeEvents returns false (hostapd_cli uses polling)
+func (b *Backend) SupportsRealTimeEvents() bool {
+ return false
+}
+
+// pollStations periodically polls for station changes
+func (b *Backend) pollStations() {
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-b.stopCh:
+ return
+ case <-ticker.C:
+ if err := b.checkStationChanges(); err != nil {
+ log.Printf("Error polling stations: %v", err)
+ }
+ }
+ }
+}
+
+// checkStationChanges checks for station connect/disconnect events
+func (b *Backend) checkStationChanges() error {
+ // Get current stations from hostapd
+ currentStations, err := b.fetchStations()
+ if err != nil {
+ return err
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ // Build a map of current MACs
+ currentMACs := make(map[string]bool)
+ for mac := range currentStations {
+ currentMACs[mac] = true
+ }
+
+ // Check for new stations
+ for mac, station := range currentStations {
+ if _, exists := b.stations[mac]; !exists {
+ // New station connected
+ b.stations[mac] = station
+ if b.callbacks.OnStationConnected != nil {
+ st := b.convertStation(mac, station)
+ go b.callbacks.OnStationConnected(st)
+ }
+ log.Printf("Station connected: %s", mac)
+ }
+ }
+
+ // Check for removed stations
+ for mac := range b.stations {
+ if !currentMACs[mac] {
+ // Station disconnected
+ delete(b.stations, mac)
+ delete(b.ipByMAC, mac)
+ if b.callbacks.OnStationDisconnected != nil {
+ go b.callbacks.OnStationDisconnected(mac)
+ }
+ log.Printf("Station disconnected: %s", mac)
+ }
+ }
+
+ return nil
+}
+
+// loadStations loads the initial list of stations from hostapd
+func (b *Backend) loadStations() error {
+ stations, err := b.fetchStations()
+ if err != nil {
+ return err
+ }
+
+ b.stations = stations
+ log.Printf("Loaded %d initial stations from hostapd", len(b.stations))
+ return nil
+}
+
+// fetchStations fetches all stations using hostapd_cli all_sta command
+func (b *Backend) fetchStations() (map[string]*HostapdStation, error) {
+ output, err := b.runCommandOutput("all_sta")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get stations: %w", err)
+ }
+
+ return b.parseAllStaOutput(output), nil
+}
+
+// parseAllStaOutput parses the output of "hostapd_cli all_sta"
+func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
+ stations := make(map[string]*HostapdStation)
+ scanner := bufio.NewScanner(bytes.NewBufferString(output))
+
+ var currentMAC string
+ var currentStation *HostapdStation
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+
+ // Check if this is a MAC address line (starts the station block)
+ if !strings.Contains(line, "=") && len(line) == 17 && strings.Count(line, ":") == 5 {
+ // Save previous station if exists
+ if currentMAC != "" && currentStation != nil {
+ stations[currentMAC] = currentStation
+ }
+ // Start new station
+ currentMAC = strings.ToLower(line)
+ currentStation = &HostapdStation{}
+ continue
+ }
+
+ // Parse key=value pairs
+ if currentStation != nil && strings.Contains(line, "=") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ switch key {
+ case "signal":
+ if v, err := strconv.Atoi(value); err == nil {
+ currentStation.Signal = int32(v)
+ }
+ case "rx_bytes":
+ if v, err := strconv.ParseUint(value, 10, 64); err == nil {
+ currentStation.RxBytes = v
+ }
+ case "tx_bytes":
+ if v, err := strconv.ParseUint(value, 10, 64); err == nil {
+ currentStation.TxBytes = v
+ }
+ }
+ }
+ }
+
+ // Save last station
+ if currentMAC != "" && currentStation != nil {
+ stations[currentMAC] = currentStation
+ }
+
+ return stations
+}
+
+// convertStation converts HostapdStation to backend.Station
+func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station {
+ // Get IP address if available from correlation
+ ip := b.ipByMAC[mac]
+
+ // Attempt hostname resolution if we have an IP
+ hostname := ""
+ // TODO: Could do reverse DNS lookup here if needed
+
+ return backend.Station{
+ MAC: mac,
+ IP: ip,
+ Hostname: hostname,
+ Type: backend.GuessDeviceType(hostname, mac),
+ Signal: hs.Signal,
+ RxBytes: hs.RxBytes,
+ TxBytes: hs.TxBytes,
+ ConnectedAt: time.Now(), // We don't have exact connection time
+ }
+}
+
+// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP)
+// This should be called periodically to correlate hostapd stations with IP addresses
+func (b *Backend) UpdateIPMapping(macToIP map[string]string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ // Track which stations got IP updates
+ updated := make(map[string]bool)
+
+ for mac, ip := range macToIP {
+ if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip {
+ // IP changed
+ updated[mac] = true
+ } else if !exists {
+ // New IP mapping
+ updated[mac] = true
+ }
+ b.ipByMAC[mac] = ip
+ }
+
+ // Trigger update callbacks for stations that got new/changed IPs
+ for mac := range updated {
+ if station, exists := b.stations[mac]; exists {
+ if b.callbacks.OnStationUpdated != nil {
+ st := b.convertStation(mac, station)
+ go b.callbacks.OnStationUpdated(st)
+ }
+ }
+ }
+}
diff --git a/internal/station/hostapd/correlation.go b/internal/station/hostapd/correlation.go
new file mode 100644
index 0000000..fe4361a
--- /dev/null
+++ b/internal/station/hostapd/correlation.go
@@ -0,0 +1,130 @@
+package hostapd
+
+import (
+ "bufio"
+ "log"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/nemunaire/repeater/internal/models"
+)
+
+// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses
+type DHCPCorrelator struct {
+ backend *Backend
+ dhcpLeasesPath string
+ stopChan chan struct{}
+ running bool
+}
+
+// NewDHCPCorrelator creates a new DHCP correlator
+func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator {
+ if dhcpLeasesPath == "" {
+ dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases"
+ }
+
+ return &DHCPCorrelator{
+ backend: backend,
+ dhcpLeasesPath: dhcpLeasesPath,
+ stopChan: make(chan struct{}),
+ }
+}
+
+// Start begins periodic correlation of DHCP leases with hostapd stations
+func (dc *DHCPCorrelator) Start() {
+ if dc.running {
+ return
+ }
+
+ dc.running = true
+ go dc.correlationLoop()
+ log.Printf("DHCP correlation started for hostapd backend")
+}
+
+// Stop stops the correlation loop
+func (dc *DHCPCorrelator) Stop() {
+ if !dc.running {
+ return
+ }
+
+ dc.running = false
+ close(dc.stopChan)
+ log.Printf("DHCP correlation stopped")
+}
+
+// correlationLoop periodically correlates DHCP leases with stations
+func (dc *DHCPCorrelator) correlationLoop() {
+ // Do an initial correlation immediately
+ dc.correlate()
+
+ // Then correlate every 10 seconds
+ ticker := time.NewTicker(10 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ dc.correlate()
+ case <-dc.stopChan:
+ return
+ }
+ }
+}
+
+// correlate performs one correlation cycle
+func (dc *DHCPCorrelator) correlate() {
+ // Parse DHCP leases
+ leases, err := parseDHCPLeases(dc.dhcpLeasesPath)
+ if err != nil {
+ log.Printf("Warning: Failed to parse DHCP leases: %v", err)
+ return
+ }
+
+ // Build MAC -> IP mapping
+ macToIP := make(map[string]string)
+ for _, lease := range leases {
+ macToIP[lease.MAC] = lease.IP
+ }
+
+ // Update backend with IP mappings
+ dc.backend.UpdateIPMapping(macToIP)
+}
+
+// parseDHCPLeases reads and parses DHCP lease file
+func parseDHCPLeases(path string) ([]models.DHCPLease, error) {
+ var leases []models.DHCPLease
+
+ file, err := os.Open(path)
+ if err != nil {
+ return leases, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ var currentLease models.DHCPLease
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ if strings.HasPrefix(line, "lease ") {
+ ip := strings.Fields(line)[1]
+ currentLease = models.DHCPLease{IP: ip}
+ } else if strings.Contains(line, "hardware ethernet") {
+ mac := strings.Fields(line)[2]
+ mac = strings.TrimSuffix(mac, ";")
+ currentLease.MAC = mac
+ } else if strings.Contains(line, "client-hostname") {
+ hostname := strings.Fields(line)[1]
+ hostname = strings.Trim(hostname, `";`)
+ currentLease.Hostname = hostname
+ } else if line == "}" {
+ if currentLease.IP != "" && currentLease.MAC != "" {
+ leases = append(leases, currentLease)
+ }
+ currentLease = models.DHCPLease{}
+ }
+ }
+
+ return leases, scanner.Err()
+}
diff --git a/internal/station/hostapd/types.go b/internal/station/hostapd/types.go
new file mode 100644
index 0000000..2521c1f
--- /dev/null
+++ b/internal/station/hostapd/types.go
@@ -0,0 +1,10 @@
+package hostapd
+
+// HostapdStation represents station properties from hostapd_cli
+type HostapdStation struct {
+ RxPackets uint64
+ TxPackets uint64
+ RxBytes uint64
+ TxBytes uint64
+ Signal int32 // Signal in dBm
+}
diff --git a/internal/station/station.go b/internal/station/station.go
new file mode 100644
index 0000000..e6acaf1
--- /dev/null
+++ b/internal/station/station.go
@@ -0,0 +1,111 @@
+package station
+
+import (
+ "sync"
+
+ "github.com/nemunaire/repeater/internal/models"
+ "github.com/nemunaire/repeater/internal/station/backend"
+)
+
+var (
+ currentBackend backend.StationBackend
+ mu sync.RWMutex
+)
+
+// Initialize initializes the station discovery backend
+func Initialize(backendName string, config backend.BackendConfig) error {
+ mu.Lock()
+ defer mu.Unlock()
+
+ // Close existing backend if any
+ if currentBackend != nil {
+ currentBackend.Close()
+ }
+
+ // Create new backend
+ b, err := createBackend(backendName)
+ if err != nil {
+ return err
+ }
+
+ // Initialize the backend
+ if err := b.Initialize(config); err != nil {
+ return err
+ }
+
+ currentBackend = b
+ return nil
+}
+
+// GetStations returns all connected stations as ConnectedDevice models
+func GetStations() ([]models.ConnectedDevice, error) {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ if currentBackend == nil {
+ return nil, nil
+ }
+
+ stations, err := currentBackend.GetStations()
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert backend.Station to models.ConnectedDevice
+ devices := make([]models.ConnectedDevice, len(stations))
+ for i, s := range stations {
+ devices[i] = models.ConnectedDevice{
+ Name: s.Hostname,
+ Type: s.Type,
+ MAC: s.MAC,
+ IP: s.IP,
+ }
+ }
+
+ return devices, nil
+}
+
+// StartEventMonitoring starts monitoring for station events
+func StartEventMonitoring(callbacks backend.EventCallbacks) error {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ if currentBackend == nil {
+ return nil
+ }
+
+ return currentBackend.StartEventMonitoring(callbacks)
+}
+
+// StopEventMonitoring stops monitoring for station events
+func StopEventMonitoring() {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ if currentBackend != nil {
+ currentBackend.StopEventMonitoring()
+ }
+}
+
+// Close closes the current backend
+func Close() {
+ mu.Lock()
+ defer mu.Unlock()
+
+ if currentBackend != nil {
+ currentBackend.Close()
+ currentBackend = nil
+ }
+}
+
+// SupportsRealTimeEvents returns true if the current backend supports real-time events
+func SupportsRealTimeEvents() bool {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ if currentBackend == nil {
+ return false
+ }
+
+ return currentBackend.SupportsRealTimeEvents()
+}
diff --git a/internal/syslog/parser.go b/internal/syslog/parser.go
new file mode 100644
index 0000000..10c07b6
--- /dev/null
+++ b/internal/syslog/parser.go
@@ -0,0 +1,32 @@
+package syslog
+
+import (
+ "strings"
+)
+
+// ParseSyslogLine extracts the message content from a syslog line.
+// It looks for the daemon prefix in the line and returns the message after it.
+//
+// Example input: "Dec 2 02:01:33 tyet daemon.info iwd: Error loading /var/lib/iwd//nemuphone.psk"
+// Example output: "Error loading /var/lib/iwd//nemuphone.psk", true
+//
+// Returns the message and a boolean indicating if the line was successfully parsed.
+func ParseSyslogLine(line, daemonPrefix string) (string, bool) {
+ // Find the daemon prefix in the line (e.g., "iwd:")
+ idx := strings.Index(line, daemonPrefix)
+ if idx == -1 {
+ return "", false
+ }
+
+ // Extract everything after the daemon prefix
+ message := line[idx+len(daemonPrefix):]
+
+ // Trim leading/trailing whitespace
+ message = strings.TrimSpace(message)
+
+ if message == "" {
+ return "", false
+ }
+
+ return message, true
+}
diff --git a/internal/syslog/syslog.go b/internal/syslog/syslog.go
new file mode 100644
index 0000000..605a1fe
--- /dev/null
+++ b/internal/syslog/syslog.go
@@ -0,0 +1,225 @@
+package syslog
+
+import (
+ "bufio"
+ "io"
+ "log"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/nemunaire/repeater/internal/logging"
+)
+
+// SyslogTailer tails a syslog file and filters messages to the logging system.
+type SyslogTailer struct {
+ path string
+ filters []string
+ source string
+
+ file *os.File
+ done chan struct{}
+ wg sync.WaitGroup
+ mu sync.Mutex
+ running bool
+}
+
+// NewSyslogTailer creates a new syslog tailer.
+// path: Path to the syslog file (e.g., "/var/log/messages")
+// filter: Filter string to match in lines (e.g., "daemon.info iwd:")
+// source: Source name for logging (e.g., "iwd")
+func NewSyslogTailer(path string, filters []string, source string) *SyslogTailer {
+ return &SyslogTailer{
+ path: path,
+ filters: filters,
+ source: source,
+ done: make(chan struct{}),
+ }
+}
+
+// Start opens the syslog file and begins tailing it.
+func (t *SyslogTailer) Start() error {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ if t.running {
+ return nil
+ }
+
+ // Try to open the file
+ file, err := os.Open(t.path)
+ if err != nil {
+ // File might not exist yet, we'll retry in the goroutine
+ log.Printf("Warning: Cannot open syslog file %s: %v (will retry)", t.path, err)
+ } else {
+ // Seek to the end to only read new entries
+ _, err = file.Seek(0, io.SeekEnd)
+ if err != nil {
+ file.Close()
+ return err
+ }
+ t.file = file
+ }
+
+ t.running = true
+ t.wg.Add(1)
+ go t.tail()
+
+ return nil
+}
+
+// Stop signals the tailer to stop and waits for it to finish.
+func (t *SyslogTailer) Stop() {
+ t.mu.Lock()
+ if !t.running {
+ t.mu.Unlock()
+ return
+ }
+ t.mu.Unlock()
+
+ close(t.done)
+ t.wg.Wait()
+
+ t.mu.Lock()
+ if t.file != nil {
+ t.file.Close()
+ t.file = nil
+ }
+ t.running = false
+ t.mu.Unlock()
+}
+
+// tail is the main loop that reads from the syslog file.
+func (t *SyslogTailer) tail() {
+ defer t.wg.Done()
+
+ retryDelay := 1 * time.Second
+ maxRetryDelay := 30 * time.Second
+
+ for {
+ select {
+ case <-t.done:
+ return
+ default:
+ }
+
+ // Check if we have a file open
+ t.mu.Lock()
+ file := t.file
+ t.mu.Unlock()
+
+ if file == nil {
+ // Try to open the file
+ newFile, err := os.Open(t.path)
+ if err != nil {
+ // File doesn't exist or can't be opened, wait and retry
+ select {
+ case <-t.done:
+ return
+ case <-time.After(retryDelay):
+ // Exponential backoff
+ retryDelay *= 2
+ if retryDelay > maxRetryDelay {
+ retryDelay = maxRetryDelay
+ }
+ continue
+ }
+ }
+
+ // Seek to the end
+ _, err = newFile.Seek(0, io.SeekEnd)
+ if err != nil {
+ log.Printf("Error seeking syslog file: %v", err)
+ newFile.Close()
+ time.Sleep(retryDelay)
+ continue
+ }
+
+ t.mu.Lock()
+ t.file = newFile
+ file = newFile
+ t.mu.Unlock()
+
+ retryDelay = 1 * time.Second
+ log.Printf("Syslog tailer: opened %s", t.path)
+ }
+
+ // Read lines from the file
+ if err := t.readLines(file); err != nil {
+ if err == io.EOF {
+ // End of file, wait a bit and try again
+ time.Sleep(100 * time.Millisecond)
+ continue
+ }
+
+ // Other error, close the file and retry
+ log.Printf("Error reading syslog file: %v", err)
+ t.mu.Lock()
+ if t.file != nil {
+ t.file.Close()
+ t.file = nil
+ }
+ t.mu.Unlock()
+
+ time.Sleep(retryDelay)
+ }
+ }
+}
+
+// readLines reads and processes lines from the file.
+func (t *SyslogTailer) readLines(file *os.File) error {
+ scanner := bufio.NewScanner(file)
+
+ // Increase buffer size to handle long log lines
+ const maxCapacity = 512 * 1024
+ buf := make([]byte, maxCapacity)
+ scanner.Buffer(buf, maxCapacity)
+
+ for scanner.Scan() {
+ select {
+ case <-t.done:
+ return nil
+ default:
+ }
+
+ line := scanner.Text()
+
+ // Check if the line contains any of the filter strings
+ var matchedFilter string
+ for _, filter := range t.filters {
+ if strings.Contains(line, filter) {
+ matchedFilter = filter
+ break
+ }
+ }
+ if matchedFilter == "" {
+ continue
+ }
+
+ // Parse the syslog line to extract the message
+ // We look for "iwd:" (or whatever comes after the filter)
+ // The filter is "daemon.info iwd:" so we want to extract text after "iwd:"
+ daemonPrefix := extractDaemonPrefix(matchedFilter)
+ message, ok := ParseSyslogLine(line, daemonPrefix)
+ if !ok {
+ // Couldn't parse the line, skip it
+ continue
+ }
+
+ // Add to logging system
+ logging.AddLog(t.source, message)
+ }
+
+ return scanner.Err()
+}
+
+// extractDaemonPrefix extracts the daemon prefix from the filter string.
+// For example, "daemon.info iwd:" returns "iwd:"
+func extractDaemonPrefix(filter string) string {
+ parts := strings.Fields(filter)
+ if len(parts) > 0 {
+ return parts[len(parts)-1]
+ }
+ return filter
+}
diff --git a/internal/wifi/backend/types.go b/internal/wifi/backend/types.go
new file mode 100644
index 0000000..1346e9b
--- /dev/null
+++ b/internal/wifi/backend/types.go
@@ -0,0 +1,56 @@
+package backend
+
+// WiFiBackend is the interface that must be implemented by all WiFi backends (iwd, wpa_supplicant, etc.)
+type WiFiBackend interface {
+ // Lifecycle Management
+ Initialize(interfaceName string) error
+ Close() error
+
+ // Network Discovery
+ ScanNetworks() error
+ GetOrderedNetworks() ([]BackendNetwork, error)
+ IsScanning() (bool, error)
+
+ // Connection Management
+ Connect(ssid, password string) error
+ Disconnect() error
+ GetConnectionState() (ConnectionState, error)
+ GetConnectedSSID() string
+
+ // Event Monitoring
+ StartEventMonitoring(callbacks EventCallbacks) error
+ StopEventMonitoring()
+}
+
+// BackendNetwork represents a WiFi network in a backend-agnostic format.
+// Both iwd and wpa_supplicant backends convert their native representations to this type.
+type BackendNetwork struct {
+ SSID string
+ SignalDBm int16 // Signal strength in dBm (-100 to 0)
+ SecurityType string // "open", "wep", "psk", "8021x"
+ BSSID string // MAC address of the access point
+ Frequency uint32 // Frequency in MHz (e.g., 2412 for channel 1, 5180 for channel 36)
+}
+
+// ConnectionState represents the WiFi connection state in a backend-agnostic way.
+type ConnectionState string
+
+const (
+ StateConnected ConnectionState = "connected"
+ StateDisconnected ConnectionState = "disconnected"
+ StateConnecting ConnectionState = "connecting"
+ StateDisconnecting ConnectionState = "disconnecting"
+)
+
+// EventCallbacks defines callback functions that backends use to notify the wifi package of events.
+// This allows the wifi package to remain backend-agnostic while still receiving real-time updates.
+type EventCallbacks struct {
+ // OnStateChange is called when the connection state changes
+ OnStateChange func(state ConnectionState, ssid string)
+
+ // OnScanComplete is called when a network scan completes
+ OnScanComplete func()
+
+ // OnSignalUpdate is called when signal strength changes for the connected network
+ OnSignalUpdate func(ssid string, signalDBm int16)
+}
diff --git a/internal/wifi/broadcaster.go b/internal/wifi/broadcaster.go
new file mode 100644
index 0000000..931ad3e
--- /dev/null
+++ b/internal/wifi/broadcaster.go
@@ -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
+}
diff --git a/internal/wifi/events.go b/internal/wifi/events.go
new file mode 100644
index 0000000..c8f3672
--- /dev/null
+++ b/internal/wifi/events.go
@@ -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,
+ },
+ }
+}
diff --git a/internal/wifi/factory.go b/internal/wifi/factory.go
new file mode 100644
index 0000000..fdb63cc
--- /dev/null
+++ b/internal/wifi/factory.go
@@ -0,0 +1,21 @@
+package wifi
+
+import (
+ "fmt"
+
+ "github.com/nemunaire/repeater/internal/wifi/backend"
+ "github.com/nemunaire/repeater/internal/wifi/iwd"
+ "github.com/nemunaire/repeater/internal/wifi/wpasupplicant"
+)
+
+// createBackend creates the appropriate WiFi backend based on the backend name
+func createBackend(backendName string) (backend.WiFiBackend, error) {
+ switch backendName {
+ case "iwd":
+ return iwd.NewIWDBackend(), nil
+ case "wpasupplicant":
+ return wpasupplicant.NewWPABackend(), nil
+ default:
+ return nil, fmt.Errorf("invalid wifi backend: %s (must be 'iwd' or 'wpasupplicant')", backendName)
+ }
+}
diff --git a/internal/wifi/iwd/agent.go b/internal/wifi/iwd/agent.go
new file mode 100644
index 0000000..3a53ba6
--- /dev/null
+++ b/internal/wifi/iwd/agent.go
@@ -0,0 +1,170 @@
+package iwd
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/godbus/dbus/v5/introspect"
+)
+
+const agentIntrospectXML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+// Agent implements the net.connman.iwd.Agent interface for credential callbacks
+type Agent struct {
+ conn *dbus.Conn
+ path dbus.ObjectPath
+ passphraseStore map[string]string
+ mu sync.RWMutex
+}
+
+// NewAgent creates a new Agent instance
+func NewAgent(conn *dbus.Conn, path dbus.ObjectPath) *Agent {
+ return &Agent{
+ conn: conn,
+ path: path,
+ passphraseStore: make(map[string]string),
+ }
+}
+
+// SetPassphrase stores a passphrase for a given network SSID
+func (a *Agent) SetPassphrase(ssid, passphrase string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.passphraseStore[ssid] = passphrase
+}
+
+// ClearPassphrase removes the stored passphrase for a network
+func (a *Agent) ClearPassphrase(ssid string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ delete(a.passphraseStore, ssid)
+}
+
+// Export registers the agent object on D-Bus
+func (a *Agent) Export() error {
+ err := a.conn.Export(a, a.path, AgentInterface)
+ if err != nil {
+ return fmt.Errorf("failed to export agent: %v", err)
+ }
+
+ err = a.conn.Export(introspect.Introspectable(agentIntrospectXML), a.path, "org.freedesktop.DBus.Introspectable")
+ if err != nil {
+ a.conn.Export(nil, a.path, AgentInterface)
+ return fmt.Errorf("failed to export introspection: %v", err)
+ }
+
+ return nil
+}
+
+// Unexport unregisters the agent from D-Bus
+func (a *Agent) Unexport() {
+ a.conn.Export(nil, a.path, AgentInterface)
+ a.conn.Export(nil, a.path, "org.freedesktop.DBus.Introspectable")
+}
+
+// getNetworkSSID queries the network object to get its SSID (Name property)
+func (a *Agent) getNetworkSSID(networkPath dbus.ObjectPath) (string, error) {
+ obj := a.conn.Object(Service, networkPath)
+ variant, err := obj.GetProperty(NetworkInterface + ".Name")
+ if err != nil {
+ return "", fmt.Errorf("failed to get network name: %v", err)
+ }
+
+ name, ok := variant.Value().(string)
+ if !ok {
+ return "", fmt.Errorf("network name is not a string")
+ }
+
+ return name, nil
+}
+
+// RequestPassphrase is called by iwd when connecting to PSK networks
+func (a *Agent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
+ fmt.Printf("[Agent] RequestPassphrase called for network: %s\n", network)
+
+ ssid, err := a.getNetworkSSID(network)
+ if err != nil {
+ fmt.Printf("[Agent] Failed to get SSID: %v\n", err)
+ return "", dbus.MakeFailedError(fmt.Errorf("failed to get network SSID: %v", err))
+ }
+
+ fmt.Printf("[Agent] Network SSID: %s\n", ssid)
+
+ a.mu.RLock()
+ passphrase, ok := a.passphraseStore[ssid]
+ a.mu.RUnlock()
+
+ if !ok {
+ fmt.Printf("[Agent] No passphrase stored for SSID: %s\n", ssid)
+ return "", dbus.MakeFailedError(fmt.Errorf("no passphrase stored for network '%s'", ssid))
+ }
+
+ fmt.Printf("[Agent] Returning passphrase for SSID: %s\n", ssid)
+ return passphrase, nil
+}
+
+// RequestPrivateKeyPassphrase is called for encrypted private keys
+func (a *Agent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) {
+ // Not implemented for now
+ return "", dbus.MakeFailedError(fmt.Errorf("RequestPrivateKeyPassphrase not implemented"))
+}
+
+// RequestUserNameAndPassword is called for enterprise networks
+func (a *Agent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) {
+ // Not implemented for now
+ return "", "", dbus.MakeFailedError(fmt.Errorf("RequestUserNameAndPassword not implemented"))
+}
+
+// RequestUserPassword is called for enterprise networks with known username
+func (a *Agent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) {
+ // Not implemented for now
+ return "", dbus.MakeFailedError(fmt.Errorf("RequestUserPassword not implemented"))
+}
+
+// Cancel is called when a request is canceled
+func (a *Agent) Cancel(reason string) *dbus.Error {
+ // Nothing to do, just acknowledge
+ return nil
+}
+
+// Release is called when the agent is unregistered
+func (a *Agent) Release() *dbus.Error {
+ // Cleanup if needed
+ a.mu.Lock()
+ a.passphraseStore = make(map[string]string)
+ a.mu.Unlock()
+ return nil
+}
diff --git a/internal/wifi/iwd/agentmanager.go b/internal/wifi/iwd/agentmanager.go
new file mode 100644
index 0000000..7ea6c77
--- /dev/null
+++ b/internal/wifi/iwd/agentmanager.go
@@ -0,0 +1,39 @@
+package iwd
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// AgentManager handles agent registration with iwd
+type AgentManager struct {
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// NewAgentManager creates a new AgentManager instance
+func NewAgentManager(conn *dbus.Conn) *AgentManager {
+ return &AgentManager{
+ conn: conn,
+ obj: conn.Object(Service, "/net/connman/iwd"),
+ }
+}
+
+// RegisterAgent registers an agent with iwd
+func (am *AgentManager) RegisterAgent(agentPath dbus.ObjectPath) error {
+ err := am.obj.Call(AgentManagerInterface+".RegisterAgent", 0, agentPath).Err
+ if err != nil {
+ return fmt.Errorf("failed to register agent: %v", err)
+ }
+ return nil
+}
+
+// UnregisterAgent unregisters an agent from iwd
+func (am *AgentManager) UnregisterAgent(agentPath dbus.ObjectPath) error {
+ err := am.obj.Call(AgentManagerInterface+".UnregisterAgent", 0, agentPath).Err
+ if err != nil {
+ return fmt.Errorf("failed to unregister agent: %v", err)
+ }
+ return nil
+}
diff --git a/internal/wifi/iwd/backend.go b/internal/wifi/iwd/backend.go
new file mode 100644
index 0000000..615a280
--- /dev/null
+++ b/internal/wifi/iwd/backend.go
@@ -0,0 +1,255 @@
+package iwd
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/nemunaire/repeater/internal/wifi/backend"
+)
+
+const (
+ AgentPath = "/com/github/nemunaire/repeater/agent"
+)
+
+// IWDBackend implements the WiFiBackend interface for iwd (Intel Wireless Daemon)
+type IWDBackend struct {
+ conn *dbus.Conn
+ manager *Manager
+ station *Station
+ agent *Agent
+ agentManager *AgentManager
+ signalMonitor *SignalMonitor
+ interfaceName string
+ callbacks backend.EventCallbacks
+}
+
+// NewIWDBackend creates a new IWD backend instance
+func NewIWDBackend() *IWDBackend {
+ return &IWDBackend{}
+}
+
+// Initialize initializes the iwd backend with the given interface name
+func (b *IWDBackend) Initialize(interfaceName string) error {
+ b.interfaceName = interfaceName
+ var err error
+
+ // Connect to D-Bus
+ b.conn, err = dbus.SystemBus()
+ if err != nil {
+ return fmt.Errorf("échec de connexion à D-Bus: %v", err)
+ }
+
+ // Find station for interface
+ b.manager = NewManager(b.conn)
+ b.station, err = b.manager.FindStation(interfaceName)
+ if err != nil {
+ return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err)
+ }
+
+ // Create and register agent for credential callbacks
+ b.agent = NewAgent(b.conn, dbus.ObjectPath(AgentPath))
+ if err := b.agent.Export(); err != nil {
+ return fmt.Errorf("échec de l'export de l'agent: %v", err)
+ }
+
+ b.agentManager = NewAgentManager(b.conn)
+ if err := b.agentManager.RegisterAgent(dbus.ObjectPath(AgentPath)); err != nil {
+ b.agent.Unexport()
+ return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err)
+ }
+
+ return nil
+}
+
+// Close closes the D-Bus connection and unregisters the agent
+func (b *IWDBackend) Close() error {
+ if b.agentManager != nil && b.agent != nil {
+ b.agentManager.UnregisterAgent(dbus.ObjectPath(AgentPath))
+ b.agent.Unexport()
+ }
+ if b.conn != nil {
+ b.conn.Close()
+ }
+ return nil
+}
+
+// ScanNetworks triggers a network scan
+func (b *IWDBackend) ScanNetworks() error {
+ err := b.station.Scan()
+ if err != nil {
+ return fmt.Errorf("erreur lors du scan: %v", err)
+ }
+ return nil
+}
+
+// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
+func (b *IWDBackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
+ networkInfos, err := b.station.GetOrderedNetworks()
+ if err != nil {
+ return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
+ }
+
+ var networks []backend.BackendNetwork
+ seenSSIDs := make(map[string]bool)
+
+ for _, netInfo := range networkInfos {
+ network := NewNetwork(b.conn, netInfo.Path)
+ props, err := network.GetProperties()
+ if err != nil {
+ continue
+ }
+
+ if props.Name == "" || seenSSIDs[props.Name] {
+ continue
+ }
+ seenSSIDs[props.Name] = true
+
+ // Convert iwd network to backend-agnostic format
+ backendNet := backend.BackendNetwork{
+ SSID: props.Name,
+ SignalDBm: netInfo.Signal / 100, // iwd provides 100*dBm, convert to dBm
+ SecurityType: props.Type,
+ BSSID: generateSyntheticBSSID(props.Name), // iwd doesn't expose BSSID
+ Frequency: 0, // iwd doesn't expose frequency in GetOrderedNetworks
+ }
+
+ networks = append(networks, backendNet)
+ }
+
+ return networks, nil
+}
+
+// IsScanning checks if a scan is currently in progress
+func (b *IWDBackend) IsScanning() (bool, error) {
+ return b.station.IsScanning()
+}
+
+// Connect connects to a WiFi network
+func (b *IWDBackend) Connect(ssid, password string) error {
+ // Store passphrase in agent for callback
+ if password != "" {
+ b.agent.SetPassphrase(ssid, password)
+ }
+
+ // Ensure passphrase is cleared after connection attempt
+ defer func() {
+ if password != "" {
+ b.agent.ClearPassphrase(ssid)
+ }
+ }()
+
+ // Get network object
+ network, err := b.station.GetNetwork(ssid)
+ if err != nil {
+ return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err)
+ }
+
+ // Connect - iwd will call agent.RequestPassphrase() if needed
+ if err := network.Connect(); err != nil {
+ return fmt.Errorf("erreur lors de la connexion: %v", err)
+ }
+
+ return nil
+}
+
+// Disconnect disconnects from the current WiFi network
+func (b *IWDBackend) Disconnect() error {
+ if err := b.station.Disconnect(); err != nil {
+ return fmt.Errorf("erreur lors de la déconnexion: %v", err)
+ }
+ return nil
+}
+
+// GetConnectionState returns the current WiFi connection state
+func (b *IWDBackend) GetConnectionState() (backend.ConnectionState, error) {
+ state, err := b.station.GetState()
+ if err != nil {
+ return backend.StateDisconnected, err
+ }
+ return mapIWDState(state), nil
+}
+
+// GetConnectedSSID returns the SSID of the currently connected network
+func (b *IWDBackend) GetConnectedSSID() string {
+ network, err := b.station.GetConnectedNetwork()
+ if err != nil {
+ return ""
+ }
+
+ props, err := network.GetProperties()
+ if err != nil {
+ return ""
+ }
+
+ return props.Name
+}
+
+// StartEventMonitoring starts monitoring WiFi events
+func (b *IWDBackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
+ b.callbacks = callbacks
+
+ // Create signal monitor
+ b.signalMonitor = NewSignalMonitor(b.conn, b.station)
+
+ // Register callbacks - wrap to convert iwd types to backend types
+ b.signalMonitor.OnStateChange(func(state StationState, ssid string) {
+ if b.callbacks.OnStateChange != nil {
+ b.callbacks.OnStateChange(mapIWDState(state), ssid)
+ }
+ })
+
+ b.signalMonitor.OnScanComplete(func() {
+ if b.callbacks.OnScanComplete != nil {
+ b.callbacks.OnScanComplete()
+ }
+ })
+
+ // Start monitoring
+ return b.signalMonitor.Start()
+}
+
+// StopEventMonitoring stops monitoring WiFi events
+func (b *IWDBackend) StopEventMonitoring() {
+ if b.signalMonitor != nil {
+ b.signalMonitor.Stop()
+ }
+}
+
+// mapIWDState maps iwd-specific states to backend-agnostic states
+func mapIWDState(state StationState) backend.ConnectionState {
+ switch state {
+ case StateConnected:
+ return backend.StateConnected
+ case StateConnecting:
+ return backend.StateConnecting
+ case StateDisconnecting:
+ return backend.StateDisconnecting
+ case StateDisconnected:
+ return backend.StateDisconnected
+ case StateRoaming:
+ // Map roaming to connected since we're still connected during roaming
+ return backend.StateConnected
+ default:
+ return backend.StateDisconnected
+ }
+}
+
+// generateSyntheticBSSID generates a consistent fake BSSID from SSID
+// (iwd doesn't expose real BSSID)
+func generateSyntheticBSSID(ssid string) string {
+ // Use a simple hash approach - consistent per SSID
+ hash := 0
+ for _, c := range ssid {
+ hash = ((hash << 5) - hash) + int(c)
+ }
+
+ // Generate 6 bytes for MAC address
+ b1 := byte((hash >> 0) & 0xff)
+ b2 := byte((hash >> 8) & 0xff)
+ b3 := byte((hash >> 16) & 0xff)
+ b4 := byte((hash >> 24) & 0xff)
+ b5 := byte(len(ssid) & 0xff)
+ b6 := byte((len(ssid) >> 8) & 0xff)
+
+ return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6)
+}
diff --git a/internal/wifi/iwd/manager.go b/internal/wifi/iwd/manager.go
new file mode 100644
index 0000000..f636664
--- /dev/null
+++ b/internal/wifi/iwd/manager.go
@@ -0,0 +1,71 @@
+package iwd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// Manager handles iwd object discovery via ObjectManager
+type Manager struct {
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// NewManager creates a new Manager instance
+func NewManager(conn *dbus.Conn) *Manager {
+ return &Manager{
+ conn: conn,
+ obj: conn.Object(Service, dbus.ObjectPath(ManagerPath)),
+ }
+}
+
+// GetManagedObjects returns all iwd managed objects
+func (m *Manager) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, error) {
+ var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+ err := m.obj.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get managed objects: %v", err)
+ }
+ return objects, nil
+}
+
+// FindStation finds the Station object for the given interface name
+func (m *Manager) FindStation(interfaceName string) (*Station, error) {
+ objects, err := m.GetManagedObjects()
+ if err != nil {
+ return nil, err
+ }
+
+ // First, find the device with matching interface name
+ var devicePath dbus.ObjectPath
+ for path, interfaces := range objects {
+ if deviceProps, ok := interfaces[DeviceInterface]; ok {
+ if nameVariant, ok := deviceProps["Name"]; ok {
+ if name, ok := nameVariant.Value().(string); ok && name == interfaceName {
+ devicePath = path
+ break
+ }
+ }
+ }
+ }
+
+ if devicePath == "" {
+ return nil, fmt.Errorf("device with interface '%s' not found", interfaceName)
+ }
+
+ // Now find the station object under this device
+ // Station path is typically the same as device path or a child of it
+ for path, interfaces := range objects {
+ if _, ok := interfaces[StationInterface]; ok {
+ // Check if this station belongs to our device
+ // Station path should be the device path or start with it
+ if path == devicePath || strings.HasPrefix(string(path), string(devicePath)+"/") {
+ return NewStation(m.conn, path), nil
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("station for device '%s' not found", interfaceName)
+}
diff --git a/internal/wifi/iwd/network.go b/internal/wifi/iwd/network.go
new file mode 100644
index 0000000..1563a95
--- /dev/null
+++ b/internal/wifi/iwd/network.go
@@ -0,0 +1,64 @@
+package iwd
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// Network represents an iwd Network interface
+type Network struct {
+ path dbus.ObjectPath
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// NewNetwork creates a new Network instance
+func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network {
+ return &Network{
+ path: path,
+ conn: conn,
+ obj: conn.Object(Service, path),
+ }
+}
+
+// GetProperties retrieves all network properties
+func (n *Network) GetProperties() (*NetworkProperties, error) {
+ var props map[string]dbus.Variant
+ err := n.obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, NetworkInterface).Store(&props)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get network properties: %v", err)
+ }
+
+ netProps := &NetworkProperties{}
+
+ if nameVariant, ok := props["Name"]; ok {
+ if name, ok := nameVariant.Value().(string); ok {
+ netProps.Name = name
+ }
+ }
+
+ if typeVariant, ok := props["Type"]; ok {
+ if netType, ok := typeVariant.Value().(string); ok {
+ netProps.Type = netType
+ }
+ }
+
+ if connectedVariant, ok := props["Connected"]; ok {
+ if connected, ok := connectedVariant.Value().(bool); ok {
+ netProps.Connected = connected
+ }
+ }
+
+ return netProps, nil
+}
+
+// Connect initiates a connection to this network
+// Credentials are provided via the registered agent's RequestPassphrase callback
+func (n *Network) Connect() error {
+ err := n.obj.Call(NetworkInterface+".Connect", 0).Err
+ if err != nil {
+ return fmt.Errorf("connect failed: %v", err)
+ }
+ return nil
+}
diff --git a/internal/wifi/iwd/signals.go b/internal/wifi/iwd/signals.go
new file mode 100644
index 0000000..90ed32e
--- /dev/null
+++ b/internal/wifi/iwd/signals.go
@@ -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)
+}
diff --git a/internal/wifi/iwd/station.go b/internal/wifi/iwd/station.go
new file mode 100644
index 0000000..54014fd
--- /dev/null
+++ b/internal/wifi/iwd/station.go
@@ -0,0 +1,142 @@
+package iwd
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// Station represents an iwd Station interface
+type Station struct {
+ path dbus.ObjectPath
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// NewStation creates a new Station instance
+func NewStation(conn *dbus.Conn, path dbus.ObjectPath) *Station {
+ return &Station{
+ path: path,
+ conn: conn,
+ obj: conn.Object(Service, path),
+ }
+}
+
+// Scan triggers a network scan
+func (s *Station) Scan() error {
+ err := s.obj.Call(StationInterface+".Scan", 0).Err
+ if err != nil {
+ return fmt.Errorf("scan failed: %v", err)
+ }
+ return nil
+}
+
+// IsScanning checks if a scan is currently in progress
+func (s *Station) IsScanning() (bool, error) {
+ prop, err := s.obj.GetProperty(StationInterface + ".Scanning")
+ if err != nil {
+ return false, fmt.Errorf("failed to get Scanning property: %v", err)
+ }
+
+ scanning, ok := prop.Value().(bool)
+ if !ok {
+ return false, fmt.Errorf("Scanning property is not a boolean")
+ }
+
+ return scanning, nil
+}
+
+// GetOrderedNetworks returns networks sorted by signal strength
+func (s *Station) GetOrderedNetworks() ([]NetworkInfo, error) {
+ var result []struct {
+ Path dbus.ObjectPath
+ Signal int16
+ }
+
+ err := s.obj.Call(StationInterface+".GetOrderedNetworks", 0).Store(&result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get ordered networks: %v", err)
+ }
+
+ networks := make([]NetworkInfo, len(result))
+ for i, r := range result {
+ networks[i] = NetworkInfo{
+ Path: r.Path,
+ Signal: r.Signal,
+ }
+ }
+
+ return networks, nil
+}
+
+// GetState returns the current connection state
+func (s *Station) GetState() (StationState, error) {
+ prop, err := s.obj.GetProperty(StationInterface + ".State")
+ if err != nil {
+ return "", fmt.Errorf("failed to get State property: %v", err)
+ }
+
+ state, ok := prop.Value().(string)
+ if !ok {
+ return "", fmt.Errorf("State property is not a string")
+ }
+
+ return StationState(state), nil
+}
+
+// Disconnect disconnects from the current network
+func (s *Station) Disconnect() error {
+ err := s.obj.Call(StationInterface+".Disconnect", 0).Err
+ if err != nil {
+ return fmt.Errorf("disconnect failed: %v", err)
+ }
+ return nil
+}
+
+// GetNetwork finds and returns a Network object by SSID
+func (s *Station) GetNetwork(ssid string) (*Network, error) {
+ networks, err := s.GetOrderedNetworks()
+ if err != nil {
+ return nil, err
+ }
+
+ // Find the network with matching SSID
+ for _, netInfo := range networks {
+ network := NewNetwork(s.conn, netInfo.Path)
+ props, err := network.GetProperties()
+ if err != nil {
+ continue
+ }
+
+ if props.Name == ssid {
+ return network, nil
+ }
+ }
+
+ return nil, fmt.Errorf("network '%s' not found", ssid)
+}
+
+// GetConnectedNetwork returns the currently connected network
+func (s *Station) GetConnectedNetwork() (*Network, error) {
+ prop, err := s.obj.GetProperty(StationInterface + ".ConnectedNetwork")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get ConnectedNetwork property: %v", err)
+ }
+
+ path, ok := prop.Value().(dbus.ObjectPath)
+ if !ok {
+ return nil, fmt.Errorf("ConnectedNetwork property is not an ObjectPath")
+ }
+
+ // Check if path is empty (not connected)
+ if path == "/" || path == "" {
+ return nil, fmt.Errorf("not connected to any network")
+ }
+
+ 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
+}
diff --git a/internal/wifi/iwd/types.go b/internal/wifi/iwd/types.go
new file mode 100644
index 0000000..76624c4
--- /dev/null
+++ b/internal/wifi/iwd/types.go
@@ -0,0 +1,38 @@
+package iwd
+
+import "github.com/godbus/dbus/v5"
+
+const (
+ // D-Bus service and interfaces
+ Service = "net.connman.iwd"
+ ManagerPath = "/"
+ DeviceInterface = "net.connman.iwd.Device"
+ StationInterface = "net.connman.iwd.Station"
+ NetworkInterface = "net.connman.iwd.Network"
+ AgentInterface = "net.connman.iwd.Agent"
+ AgentManagerInterface = "net.connman.iwd.AgentManager"
+)
+
+// NetworkInfo represents a network with its signal strength
+type NetworkInfo struct {
+ Path dbus.ObjectPath
+ Signal int16 // 100 * dBm (0 to -10000)
+}
+
+// NetworkProperties holds network properties
+type NetworkProperties struct {
+ Name string // SSID
+ Type string // "open", "wep", "psk", "8021x"
+ Connected bool
+}
+
+// StationState represents the connection state
+type StationState string
+
+const (
+ StateConnected StationState = "connected"
+ StateDisconnected StationState = "disconnected"
+ StateConnecting StationState = "connecting"
+ StateDisconnecting StationState = "disconnecting"
+ StateRoaming StationState = "roaming"
+)
diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go
new file mode 100644
index 0000000..dac04c3
--- /dev/null
+++ b/internal/wifi/wifi.go
@@ -0,0 +1,242 @@
+package wifi
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/nemunaire/repeater/internal/models"
+ "github.com/nemunaire/repeater/internal/wifi/backend"
+)
+
+var (
+ wifiBackend backend.WiFiBackend
+ wifiBroadcaster *WifiBroadcaster
+)
+
+// Initialize initializes the WiFi service with the specified backend
+func Initialize(interfaceName string, backendName string) error {
+ // Create the appropriate backend using the factory
+ var err error
+ wifiBackend, err = createBackend(backendName)
+ if err != nil {
+ return err
+ }
+
+ // Initialize the backend
+ return wifiBackend.Initialize(interfaceName)
+}
+
+// Close closes the backend connection
+func Close() {
+ if wifiBackend != nil {
+ wifiBackend.Close()
+ }
+}
+
+// GetCachedNetworks returns previously discovered networks without triggering a scan
+func GetCachedNetworks() ([]models.WiFiNetwork, error) {
+ // Get ordered networks from backend
+ backendNetworks, err := wifiBackend.GetOrderedNetworks()
+ if err != nil {
+ return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
+ }
+
+ // Convert backend networks to models
+ networks := make([]models.WiFiNetwork, 0, len(backendNetworks))
+ for _, backendNet := range backendNetworks {
+ wifiNet := models.WiFiNetwork{
+ SSID: backendNet.SSID,
+ Signal: signalToStrength(int(backendNet.SignalDBm)),
+ Security: mapSecurityType(backendNet.SecurityType),
+ BSSID: backendNet.BSSID,
+ Channel: 0, // Not yet exposed by backends
+ }
+ 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
+ scanning, err := wifiBackend.IsScanning()
+ if err == nil && scanning {
+ time.Sleep(3 * time.Second)
+ } else {
+ // Trigger scan
+ err := wifiBackend.ScanNetworks()
+ if err != nil && !strings.Contains(err.Error(), "rejected") {
+ return nil, fmt.Errorf("erreur lors du scan: %v", err)
+ }
+ time.Sleep(2 * time.Second)
+ }
+
+ // Get ordered networks from backend
+ backendNetworks, err := wifiBackend.GetOrderedNetworks()
+ if err != nil {
+ return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err)
+ }
+
+ // Convert backend networks to models
+ networks := make([]models.WiFiNetwork, 0, len(backendNetworks))
+ for _, backendNet := range backendNetworks {
+ wifiNet := models.WiFiNetwork{
+ SSID: backendNet.SSID,
+ Signal: signalToStrength(int(backendNet.SignalDBm)),
+ Security: mapSecurityType(backendNet.SecurityType),
+ BSSID: backendNet.BSSID,
+ Channel: 0, // Not yet exposed by backends
+ }
+ networks = append(networks, wifiNet)
+ }
+
+ // Sort by signal strength (descending)
+ sort.Slice(networks, func(i, j int) bool {
+ return networks[i].Signal > networks[j].Signal
+ })
+
+ // Broadcast to WebSocket clients if available
+ if wifiBroadcaster != nil {
+ wifiBroadcaster.BroadcastScanUpdate(networks)
+ }
+
+ return networks, nil
+}
+
+// Connect connects to a WiFi network
+func Connect(ssid, password string) error {
+ // Use backend to connect
+ if err := wifiBackend.Connect(ssid, password); err != nil {
+ return err
+ }
+
+ // Poll for connection
+ for i := 0; i < 20; i++ {
+ time.Sleep(500 * time.Millisecond)
+ if IsConnected() {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("timeout lors de la connexion")
+}
+
+// Disconnect disconnects from the current WiFi network
+func Disconnect() error {
+ return wifiBackend.Disconnect()
+}
+
+// IsConnected checks if WiFi is connected
+func IsConnected() bool {
+ state, err := wifiBackend.GetConnectionState()
+ if err != nil {
+ return false
+ }
+ return state == backend.StateConnected
+}
+
+// GetConnectedSSID returns the SSID of the currently connected network
+func GetConnectedSSID() string {
+ return wifiBackend.GetConnectedSSID()
+}
+
+// GetConnectionState returns the current WiFi connection state
+func GetConnectionState() string {
+ state, err := wifiBackend.GetConnectionState()
+ if err != nil {
+ return string(backend.StateDisconnected)
+ }
+ return string(state)
+}
+
+// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting
+func StartEventMonitoring() error {
+ // Initialize broadcaster
+ wifiBroadcaster = NewWifiBroadcaster()
+
+ // Set up callbacks
+ callbacks := backend.EventCallbacks{
+ OnStateChange: handleStateChange,
+ OnScanComplete: handleScanComplete,
+ }
+
+ // Start backend monitoring
+ return wifiBackend.StartEventMonitoring(callbacks)
+}
+
+// StopEventMonitoring stops signal monitoring
+func StopEventMonitoring() {
+ if wifiBackend != nil {
+ wifiBackend.StopEventMonitoring()
+ }
+}
+
+// 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 backend.ConnectionState, 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 backend security types to display format
+func mapSecurityType(securityType string) string {
+ switch securityType {
+ case "open":
+ return "Open"
+ case "wep":
+ return "WEP"
+ case "psk":
+ return "WPA2"
+ case "8021x":
+ return "WPA2"
+ default:
+ return "WPA2"
+ }
+}
+
+// signalToStrength converts signal level (dBm) to strength (1-5)
+func signalToStrength(level int) int {
+ if level >= -30 {
+ return 5
+ } else if level >= -50 {
+ return 4
+ } else if level >= -60 {
+ return 3
+ } else if level >= -70 {
+ return 2
+ } else {
+ return 1
+ }
+}
diff --git a/internal/wifi/wpasupplicant/backend.go b/internal/wifi/wpasupplicant/backend.go
new file mode 100644
index 0000000..406b8f5
--- /dev/null
+++ b/internal/wifi/wpasupplicant/backend.go
@@ -0,0 +1,265 @@
+package wpasupplicant
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/nemunaire/repeater/internal/wifi/backend"
+)
+
+// WPABackend implements the WiFiBackend interface for wpa_supplicant
+type WPABackend struct {
+ conn *dbus.Conn
+ wpasupplicant dbus.BusObject
+ iface *WPAInterface
+ signalMonitor *SignalMonitor
+ interfaceName string
+ currentNetwork dbus.ObjectPath
+}
+
+// NewWPABackend creates a new wpa_supplicant backend instance
+func NewWPABackend() *WPABackend {
+ return &WPABackend{}
+}
+
+// Initialize initializes the wpa_supplicant backend with the given interface name
+func (b *WPABackend) Initialize(interfaceName string) error {
+ b.interfaceName = interfaceName
+ var err error
+
+ // Connect to D-Bus
+ b.conn, err = dbus.SystemBus()
+ if err != nil {
+ return fmt.Errorf("failed to connect to D-Bus: %v", err)
+ }
+
+ // Get wpa_supplicant root object
+ b.wpasupplicant = b.conn.Object(Service, dbus.ObjectPath(RootPath))
+
+ // Get interface path for the given interface name
+ interfacePath, err := b.getInterfacePath(interfaceName)
+ if err != nil {
+ return fmt.Errorf("failed to get interface for %s: %v", interfaceName, err)
+ }
+
+ b.iface = NewWPAInterface(b.conn, interfacePath)
+
+ return nil
+}
+
+// getInterfacePath gets or creates the wpa_supplicant Interface object path
+func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, error) {
+ var interfacePath dbus.ObjectPath
+
+ // Try to get existing interface
+ err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath)
+ if err == nil {
+ return interfacePath, nil
+ }
+
+ // Interface doesn't exist, create it
+ args := map[string]dbus.Variant{
+ "Ifname": dbus.MakeVariant(interfaceName),
+ }
+
+ err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to create interface: %v", err)
+ }
+
+ return interfacePath, nil
+}
+
+// Close closes the D-Bus connection
+func (b *WPABackend) Close() error {
+ if b.conn != nil {
+ b.conn.Close()
+ }
+ return nil
+}
+
+// ScanNetworks triggers a network scan
+func (b *WPABackend) ScanNetworks() error {
+ err := b.iface.Scan("active")
+ if err != nil {
+ return fmt.Errorf("failed to trigger scan: %v", err)
+ }
+ return nil
+}
+
+// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format
+func (b *WPABackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) {
+ // Get BSS list
+ bssPaths, err := b.iface.GetBSSs()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get BSSs: %v", err)
+ }
+
+ var networks []backend.BackendNetwork
+ seenSSIDs := make(map[string]bool)
+
+ // Iterate through BSSs and collect network info
+ for _, bssPath := range bssPaths {
+ bss := NewBSS(b.conn, bssPath)
+ props, err := bss.GetProperties()
+ if err != nil {
+ continue
+ }
+
+ ssid := string(props.SSID)
+ if ssid == "" || seenSSIDs[ssid] {
+ continue
+ }
+ seenSSIDs[ssid] = true
+
+ // Get BSSID string
+ bssidStr, err := bss.GetBSSIDString()
+ if err != nil {
+ bssidStr = ""
+ }
+
+ // Convert to backend-agnostic format
+ backendNet := backend.BackendNetwork{
+ SSID: ssid,
+ SignalDBm: props.Signal,
+ SecurityType: props.DetermineSecurityType(),
+ BSSID: bssidStr,
+ Frequency: props.Frequency,
+ }
+
+ networks = append(networks, backendNet)
+ }
+
+ // Sort by signal strength (descending)
+ // Note: This is a simple bubble sort for demonstration
+ // In production, use sort.Slice
+ for i := 0; i < len(networks); i++ {
+ for j := i + 1; j < len(networks); j++ {
+ if networks[j].SignalDBm > networks[i].SignalDBm {
+ networks[i], networks[j] = networks[j], networks[i]
+ }
+ }
+ }
+
+ return networks, nil
+}
+
+// IsScanning checks if a scan is currently in progress
+func (b *WPABackend) IsScanning() (bool, error) {
+ return b.iface.GetScanning()
+}
+
+// Connect connects to a WiFi network
+func (b *WPABackend) Connect(ssid, password string) error {
+ // Create network configuration
+ config := make(map[string]interface{})
+ config["ssid"] = fmt.Sprintf("\"%s\"", ssid) // wpa_supplicant expects quoted SSID
+
+ if password != "" {
+ // For WPA/WPA2-PSK networks
+ config["psk"] = fmt.Sprintf("\"%s\"", password)
+ } else {
+ // For open networks
+ config["key_mgmt"] = "NONE"
+ }
+
+ // Add network
+ networkPath, err := b.iface.AddNetwork(config)
+ if err != nil {
+ return fmt.Errorf("failed to add network: %v", err)
+ }
+
+ // Store current network path for cleanup
+ b.currentNetwork = networkPath
+
+ // Select (connect to) the network
+ err = b.iface.SelectNetwork(networkPath)
+ if err != nil {
+ // Clean up network on failure
+ b.iface.RemoveNetwork(networkPath)
+ return fmt.Errorf("failed to select network: %v", err)
+ }
+
+ return nil
+}
+
+// Disconnect disconnects from the current WiFi network
+func (b *WPABackend) Disconnect() error {
+ // Disconnect from current network
+ if err := b.iface.Disconnect(); err != nil {
+ return fmt.Errorf("failed to disconnect: %v", err)
+ }
+
+ // Remove the network configuration if we have one
+ if b.currentNetwork != "" && b.currentNetwork != "/" {
+ b.iface.RemoveNetwork(b.currentNetwork)
+ b.currentNetwork = ""
+ }
+
+ return nil
+}
+
+// GetConnectionState returns the current WiFi connection state
+func (b *WPABackend) GetConnectionState() (backend.ConnectionState, error) {
+ state, err := b.iface.GetState()
+ if err != nil {
+ return backend.StateDisconnected, err
+ }
+ return mapWPAState(state), nil
+}
+
+// GetConnectedSSID returns the SSID of the currently connected network
+func (b *WPABackend) GetConnectedSSID() string {
+ // Get current BSS
+ bssPath, err := b.iface.GetCurrentBSS()
+ if err != nil || bssPath == "/" {
+ return ""
+ }
+
+ // Get BSS object
+ bss := NewBSS(b.conn, bssPath)
+ ssid, err := bss.GetSSIDString()
+ if err != nil {
+ return ""
+ }
+
+ return ssid
+}
+
+// StartEventMonitoring starts monitoring WiFi events
+func (b *WPABackend) StartEventMonitoring(callbacks backend.EventCallbacks) error {
+ // Create signal monitor
+ b.signalMonitor = NewSignalMonitor(b.conn, b.iface)
+
+ // Start monitoring
+ return b.signalMonitor.Start(callbacks)
+}
+
+// StopEventMonitoring stops monitoring WiFi events
+func (b *WPABackend) StopEventMonitoring() {
+ if b.signalMonitor != nil {
+ b.signalMonitor.Stop()
+ }
+}
+
+// Wait for scan to complete (helper method)
+func (b *WPABackend) waitForScanComplete(timeout time.Duration) error {
+ start := time.Now()
+ for {
+ if time.Since(start) > timeout {
+ return fmt.Errorf("scan timeout")
+ }
+
+ scanning, err := b.iface.GetScanning()
+ if err != nil {
+ return err
+ }
+
+ if !scanning {
+ return nil
+ }
+
+ time.Sleep(100 * time.Millisecond)
+ }
+}
diff --git a/internal/wifi/wpasupplicant/bss.go b/internal/wifi/wpasupplicant/bss.go
new file mode 100644
index 0000000..799e9f9
--- /dev/null
+++ b/internal/wifi/wpasupplicant/bss.go
@@ -0,0 +1,157 @@
+package wpasupplicant
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// BSS represents a wpa_supplicant BSS (Basic Service Set) object
+type BSS struct {
+ path dbus.ObjectPath
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// BSSProperties holds the properties of a BSS
+type BSSProperties struct {
+ SSID []byte
+ BSSID []byte
+ Signal int16 // Signal strength in dBm
+ Frequency uint32 // Frequency in MHz
+ Privacy bool // Whether encryption is enabled
+ RSN map[string]dbus.Variant
+ WPA map[string]dbus.Variant
+}
+
+// NewBSS creates a new BSS instance
+func NewBSS(conn *dbus.Conn, path dbus.ObjectPath) *BSS {
+ return &BSS{
+ path: path,
+ conn: conn,
+ obj: conn.Object(Service, path),
+ }
+}
+
+// GetProperties returns all properties of the BSS
+func (b *BSS) GetProperties() (*BSSProperties, error) {
+ props := &BSSProperties{}
+
+ // Get SSID
+ if ssidProp, err := b.obj.GetProperty(BSSInterface + ".SSID"); err == nil {
+ if ssid, ok := ssidProp.Value().([]byte); ok {
+ props.SSID = ssid
+ }
+ }
+
+ // Get BSSID
+ if bssidProp, err := b.obj.GetProperty(BSSInterface + ".BSSID"); err == nil {
+ if bssid, ok := bssidProp.Value().([]byte); ok {
+ props.BSSID = bssid
+ }
+ }
+
+ // Get Signal
+ if signalProp, err := b.obj.GetProperty(BSSInterface + ".Signal"); err == nil {
+ if signal, ok := signalProp.Value().(int16); ok {
+ props.Signal = signal
+ }
+ }
+
+ // Get Frequency
+ if freqProp, err := b.obj.GetProperty(BSSInterface + ".Frequency"); err == nil {
+ if freq, ok := freqProp.Value().(uint16); ok {
+ props.Frequency = uint32(freq)
+ }
+ }
+
+ // Get Privacy
+ if privacyProp, err := b.obj.GetProperty(BSSInterface + ".Privacy"); err == nil {
+ if privacy, ok := privacyProp.Value().(bool); ok {
+ props.Privacy = privacy
+ }
+ }
+
+ // Get RSN (WPA2) information
+ if rsnProp, err := b.obj.GetProperty(BSSInterface + ".RSN"); err == nil {
+ if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok {
+ props.RSN = rsn
+ }
+ }
+
+ // Get WPA information
+ if wpaProp, err := b.obj.GetProperty(BSSInterface + ".WPA"); err == nil {
+ if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok {
+ props.WPA = wpa
+ }
+ }
+
+ return props, nil
+}
+
+// GetSSIDString returns the SSID as a string
+func (b *BSS) GetSSIDString() (string, error) {
+ prop, err := b.obj.GetProperty(BSSInterface + ".SSID")
+ if err != nil {
+ return "", fmt.Errorf("failed to get SSID property: %v", err)
+ }
+
+ ssid, ok := prop.Value().([]byte)
+ if !ok {
+ return "", fmt.Errorf("SSID property is not a byte array")
+ }
+
+ return string(ssid), nil
+}
+
+// GetBSSIDString returns the BSSID as a formatted MAC address string
+func (b *BSS) GetBSSIDString() (string, error) {
+ prop, err := b.obj.GetProperty(BSSInterface + ".BSSID")
+ if err != nil {
+ return "", fmt.Errorf("failed to get BSSID property: %v", err)
+ }
+
+ bssid, ok := prop.Value().([]byte)
+ if !ok || len(bssid) != 6 {
+ return "", fmt.Errorf("BSSID property is not a valid MAC address")
+ }
+
+ return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
+ bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]), nil
+}
+
+// GetSignal returns the signal strength in dBm
+func (b *BSS) GetSignal() (int16, error) {
+ prop, err := b.obj.GetProperty(BSSInterface + ".Signal")
+ if err != nil {
+ return 0, fmt.Errorf("failed to get Signal property: %v", err)
+ }
+
+ signal, ok := prop.Value().(int16)
+ if !ok {
+ return 0, fmt.Errorf("Signal property is not an int16")
+ }
+
+ return signal, nil
+}
+
+// DetermineSecurityType determines the security type based on BSS properties
+func (p *BSSProperties) DetermineSecurityType() string {
+ // Check for WPA2 (RSN)
+ if len(p.RSN) > 0 {
+ return "psk"
+ }
+
+ // Check for WPA
+ if len(p.WPA) > 0 {
+ return "psk"
+ }
+
+ // Check for WEP (privacy but no WPA/RSN)
+ if p.Privacy {
+ return "wep"
+ }
+
+ // Open network
+ return "open"
+}
diff --git a/internal/wifi/wpasupplicant/interface.go b/internal/wifi/wpasupplicant/interface.go
new file mode 100644
index 0000000..ecc9fbb
--- /dev/null
+++ b/internal/wifi/wpasupplicant/interface.go
@@ -0,0 +1,146 @@
+package wpasupplicant
+
+import (
+ "fmt"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// WPAInterface represents a wpa_supplicant Interface object
+type WPAInterface struct {
+ path dbus.ObjectPath
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// NewWPAInterface creates a new WPAInterface instance
+func NewWPAInterface(conn *dbus.Conn, path dbus.ObjectPath) *WPAInterface {
+ return &WPAInterface{
+ path: path,
+ conn: conn,
+ obj: conn.Object(Service, path),
+ }
+}
+
+// Scan triggers a network scan
+func (i *WPAInterface) Scan(scanType string) error {
+ args := map[string]interface{}{
+ "Type": scanType, // "active" or "passive"
+ }
+
+ err := i.obj.Call(InterfaceInterface+".Scan", 0, args).Err
+ if err != nil {
+ return fmt.Errorf("scan failed: %v", err)
+ }
+ return nil
+}
+
+// GetBSSs returns a list of BSS (Basic Service Set) object paths
+func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) {
+ prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get BSSs property: %v", err)
+ }
+
+ bsss, ok := prop.Value().([]dbus.ObjectPath)
+ if !ok {
+ return nil, fmt.Errorf("BSSs property is not an array of ObjectPath")
+ }
+
+ return bsss, nil
+}
+
+// GetState returns the current connection state
+func (i *WPAInterface) GetState() (WPAState, error) {
+ prop, err := i.obj.GetProperty(InterfaceInterface + ".State")
+ if err != nil {
+ return "", fmt.Errorf("failed to get State property: %v", err)
+ }
+
+ state, ok := prop.Value().(string)
+ if !ok {
+ return "", fmt.Errorf("State property is not a string")
+ }
+
+ return WPAState(state), nil
+}
+
+// GetCurrentBSS returns the currently connected BSS object path
+func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) {
+ prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS")
+ if err != nil {
+ return "", fmt.Errorf("failed to get CurrentBSS property: %v", err)
+ }
+
+ bss, ok := prop.Value().(dbus.ObjectPath)
+ if !ok {
+ return "", fmt.Errorf("CurrentBSS property is not an ObjectPath")
+ }
+
+ return bss, nil
+}
+
+// AddNetwork creates a new network configuration
+func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPath, error) {
+ var networkPath dbus.ObjectPath
+
+ // Convert config to proper DBus variant format
+ dbusConfig := make(map[string]dbus.Variant)
+ for key, value := range config {
+ dbusConfig[key] = dbus.MakeVariant(value)
+ }
+
+ err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to add network: %v", err)
+ }
+
+ return networkPath, nil
+}
+
+// SelectNetwork connects to a network
+func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error {
+ err := i.obj.Call(InterfaceInterface+".SelectNetwork", 0, networkPath).Err
+ if err != nil {
+ return fmt.Errorf("failed to select network: %v", err)
+ }
+ return nil
+}
+
+// RemoveNetwork removes a network configuration
+func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error {
+ err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err
+ if err != nil {
+ return fmt.Errorf("failed to remove network: %v", err)
+ }
+ return nil
+}
+
+// Disconnect disconnects from the current network
+func (i *WPAInterface) Disconnect() error {
+ err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err
+ if err != nil {
+ return fmt.Errorf("disconnect failed: %v", err)
+ }
+ return nil
+}
+
+// GetPath returns the D-Bus object path for this interface
+func (i *WPAInterface) GetPath() dbus.ObjectPath {
+ return i.path
+}
+
+// GetScanning returns whether a scan is currently in progress
+func (i *WPAInterface) GetScanning() (bool, error) {
+ prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning")
+ if err != nil {
+ return false, fmt.Errorf("failed to get Scanning property: %v", err)
+ }
+
+ scanning, ok := prop.Value().(bool)
+ if !ok {
+ return false, fmt.Errorf("Scanning property is not a boolean")
+ }
+
+ return scanning, nil
+}
diff --git a/internal/wifi/wpasupplicant/network.go b/internal/wifi/wpasupplicant/network.go
new file mode 100644
index 0000000..3152a6b
--- /dev/null
+++ b/internal/wifi/wpasupplicant/network.go
@@ -0,0 +1,41 @@
+package wpasupplicant
+
+import (
+ "github.com/godbus/dbus/v5"
+)
+
+// Network represents a wpa_supplicant Network configuration object
+type Network struct {
+ path dbus.ObjectPath
+ conn *dbus.Conn
+ obj dbus.BusObject
+}
+
+// NewNetwork creates a new Network instance
+func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network {
+ return &Network{
+ path: path,
+ conn: conn,
+ obj: conn.Object(Service, path),
+ }
+}
+
+// GetPath returns the D-Bus object path for this network
+func (n *Network) GetPath() dbus.ObjectPath {
+ return n.path
+}
+
+// GetProperties returns properties of the network configuration
+func (n *Network) GetProperties() (map[string]dbus.Variant, error) {
+ prop, err := n.obj.GetProperty(NetworkInterface + ".Properties")
+ if err != nil {
+ return nil, err
+ }
+
+ props, ok := prop.Value().(map[string]dbus.Variant)
+ if !ok {
+ return nil, nil
+ }
+
+ return props, nil
+}
diff --git a/internal/wifi/wpasupplicant/signals.go b/internal/wifi/wpasupplicant/signals.go
new file mode 100644
index 0000000..a17a292
--- /dev/null
+++ b/internal/wifi/wpasupplicant/signals.go
@@ -0,0 +1,236 @@
+package wpasupplicant
+
+import (
+ "log"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/nemunaire/repeater/internal/wifi/backend"
+)
+
+// SignalMonitor monitors D-Bus signals from wpa_supplicant
+type SignalMonitor struct {
+ conn *dbus.Conn
+ iface *WPAInterface
+ callbacks backend.EventCallbacks
+
+ // Signal channel
+ signalChan chan *dbus.Signal
+
+ // Control
+ stopChan chan struct{}
+ mu sync.RWMutex
+ running bool
+
+ // State tracking
+ lastState WPAState
+}
+
+// NewSignalMonitor creates a new signal monitor
+func NewSignalMonitor(conn *dbus.Conn, iface *WPAInterface) *SignalMonitor {
+ return &SignalMonitor{
+ conn: conn,
+ iface: iface,
+ signalChan: make(chan *dbus.Signal, 100),
+ stopChan: make(chan struct{}),
+ }
+}
+
+// Start begins monitoring D-Bus signals
+func (sm *SignalMonitor) Start(callbacks backend.EventCallbacks) error {
+ sm.mu.Lock()
+ if sm.running {
+ sm.mu.Unlock()
+ return nil
+ }
+ sm.running = true
+ sm.callbacks = callbacks
+ sm.mu.Unlock()
+
+ interfacePath := sm.iface.GetPath()
+
+ // Add signal match for PropertiesChanged on Interface
+ matchOptions := []dbus.MatchOption{
+ dbus.WithMatchObjectPath(interfacePath),
+ 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
+ }
+
+ // Add signal match for ScanDone
+ scanDoneOptions := []dbus.MatchOption{
+ dbus.WithMatchObjectPath(interfacePath),
+ dbus.WithMatchInterface(InterfaceInterface),
+ dbus.WithMatchMember("ScanDone"),
+ }
+
+ if err := sm.conn.AddMatchSignal(scanDoneOptions...); err != nil {
+ sm.mu.Lock()
+ sm.running = false
+ sm.mu.Unlock()
+ return err
+ }
+
+ // Register signal channel
+ sm.conn.Signal(sm.signalChan)
+
+ // Get initial state
+ state, err := sm.iface.GetState()
+ if err == nil {
+ sm.lastState = state
+ }
+
+ // Start monitoring goroutine
+ go sm.monitor()
+
+ log.Printf("D-Bus signal monitoring started for wpa_supplicant interface %s", interfacePath)
+ 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 for wpa_supplicant")
+}
+
+// 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) {
+ // Handle ScanDone signal
+ if sig.Name == InterfaceInterface+".ScanDone" {
+ sm.handleScanDone(sig)
+ return
+ }
+
+ // Handle PropertiesChanged signals
+ if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" {
+ return
+ }
+
+ // Verify signal is from Interface
+ if len(sig.Body) < 2 {
+ return
+ }
+
+ interfaceName, ok := sig.Body[0].(string)
+ if !ok || interfaceName != InterfaceInterface {
+ 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(WPAState(state))
+ }
+ }
+
+ // Check for CurrentBSS property change (connection status)
+ if _, ok := changedProps["CurrentBSS"]; ok {
+ // BSS changed, trigger state update
+ sm.handleConnectionChange()
+ }
+}
+
+// handleStateChange processes a state change
+func (sm *SignalMonitor) handleStateChange(state WPAState) {
+ sm.lastState = state
+
+ sm.mu.RLock()
+ callback := sm.callbacks.OnStateChange
+ sm.mu.RUnlock()
+
+ if callback == nil {
+ return
+ }
+
+ // Map wpa_supplicant state to backend state
+ backendState := mapWPAState(state)
+
+ // Get connected SSID if connected
+ ssid := ""
+ if backendState == backend.StateConnected {
+ if bssPath, err := sm.iface.GetCurrentBSS(); err == nil && bssPath != "/" {
+ bss := NewBSS(sm.conn, bssPath)
+ if ssidStr, err := bss.GetSSIDString(); err == nil {
+ ssid = ssidStr
+ }
+ }
+ }
+
+ callback(backendState, ssid)
+}
+
+// handleConnectionChange processes connection changes
+func (sm *SignalMonitor) handleConnectionChange() {
+ // Get current state and trigger state change callback
+ state, err := sm.iface.GetState()
+ if err != nil {
+ return
+ }
+
+ sm.handleStateChange(state)
+}
+
+// handleScanDone processes scan completion
+func (sm *SignalMonitor) handleScanDone(sig *dbus.Signal) {
+ sm.mu.RLock()
+ callback := sm.callbacks.OnScanComplete
+ sm.mu.RUnlock()
+
+ if callback != nil {
+ callback()
+ }
+}
+
+// mapWPAState maps wpa_supplicant states to backend-agnostic states
+func mapWPAState(state WPAState) backend.ConnectionState {
+ switch state {
+ case StateCompleted:
+ return backend.StateConnected
+ case StateAuthenticating, StateAssociating, StateAssociated, State4WayHandshake, StateGroupHandshake:
+ return backend.StateConnecting
+ case StateDisconnected, StateInactive, StateInterfaceDisabled:
+ return backend.StateDisconnected
+ case StateScanning:
+ // Keep as disconnected if just scanning
+ return backend.StateDisconnected
+ default:
+ return backend.StateDisconnected
+ }
+}
diff --git a/internal/wifi/wpasupplicant/types.go b/internal/wifi/wpasupplicant/types.go
new file mode 100644
index 0000000..85fec0f
--- /dev/null
+++ b/internal/wifi/wpasupplicant/types.go
@@ -0,0 +1,27 @@
+package wpasupplicant
+
+const (
+ // D-Bus service and interfaces
+ Service = "fi.w1.wpa_supplicant1"
+ RootPath = "/fi/w1/wpa_supplicant1"
+ InterfaceInterface = "fi.w1.wpa_supplicant1.Interface"
+ BSSInterface = "fi.w1.wpa_supplicant1.BSS"
+ NetworkInterface = "fi.w1.wpa_supplicant1.Network"
+)
+
+// WPAState represents the wpa_supplicant connection state
+type WPAState string
+
+const (
+ // wpa_supplicant state strings
+ StateDisconnected WPAState = "disconnected"
+ StateInactive WPAState = "inactive"
+ StateScanning WPAState = "scanning"
+ StateAuthenticating WPAState = "authenticating"
+ StateAssociating WPAState = "associating"
+ StateAssociated WPAState = "associated"
+ State4WayHandshake WPAState = "4way_handshake"
+ StateGroupHandshake WPAState = "group_handshake"
+ StateCompleted WPAState = "completed"
+ StateInterfaceDisabled WPAState = "interface_disabled"
+)
diff --git a/main.go b/main.go
deleted file mode 100644
index c8b9fe7..0000000
--- a/main.go
+++ /dev/null
@@ -1,826 +0,0 @@
-package main
-
-import (
- "bufio"
- "embed"
- "encoding/json"
- "fmt"
- "io/fs"
- "log"
- "net/http"
- "os"
- "os/exec"
- "regexp"
- "sort"
- "strings"
- "sync"
- "time"
-
- "github.com/godbus/dbus/v5"
- "github.com/gorilla/mux"
- "github.com/gorilla/websocket"
-)
-
-//go:embed all:static
-var _assets embed.FS
-
-// Structures de données
-type WiFiNetwork struct {
- SSID string `json:"ssid"`
- Signal int `json:"signal"`
- Security string `json:"security"`
- Channel int `json:"channel"`
- BSSID string `json:"bssid"`
-}
-
-type ConnectedDevice struct {
- Name string `json:"name"`
- Type string `json:"type"`
- MAC string `json:"mac"`
- IP string `json:"ip"`
-}
-
-type HotspotConfig struct {
- SSID string `json:"ssid"`
- Password string `json:"password"`
- Channel int `json:"channel"`
-}
-
-type SystemStatus struct {
- Connected bool `json:"connected"`
- ConnectedSSID string `json:"connectedSSID"`
- HotspotEnabled bool `json:"hotspotEnabled"`
- ConnectedCount int `json:"connectedCount"`
- DataUsage float64 `json:"dataUsage"`
- Uptime int64 `json:"uptime"`
- ConnectedDevices []ConnectedDevice `json:"connectedDevices"`
-}
-
-type WiFiConnectRequest struct {
- SSID string `json:"ssid"`
- Password string `json:"password"`
-}
-
-type LogEntry struct {
- Timestamp time.Time `json:"timestamp"`
- Source string `json:"source"`
- Message string `json:"message"`
-}
-
-// Variables globales
-var (
- currentStatus SystemStatus
- statusMutex sync.RWMutex
- logEntries []LogEntry
- logMutex sync.RWMutex
- websocketClients = make(map[*websocket.Conn]bool)
- clientsMutex sync.RWMutex
- upgrader = websocket.Upgrader{
- CheckOrigin: func(r *http.Request) bool {
- return true
- },
- }
- startTime = time.Now()
- dbusConn *dbus.Conn
- wpaSupplicant dbus.BusObject
-)
-
-const (
- WLAN_INTERFACE = "wlan0"
- AP_INTERFACE = "wlan1"
- HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
- WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf"
-
- // D-Bus constantes pour wpa_supplicant
- WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1"
- WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1"
- WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1"
- WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface"
- WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS"
- WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network"
-)
-
-func main() {
- // Initialiser D-Bus
- var err error
- dbusConn, err = dbus.SystemBus()
- if err != nil {
- log.Fatalf("Erreur de connexion D-Bus: %v", err)
- }
- defer dbusConn.Close()
-
- wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH))
-
- // Initialiser le statut système
- initializeStatus()
-
- // Démarrer les tâches périodiques
- go periodicStatusUpdate()
- go periodicDeviceUpdate()
-
- // Configuration du routeur
- r := mux.NewRouter()
-
- // Routes API
- api := r.PathPrefix("/api").Subrouter()
- api.HandleFunc("/wifi/scan", scanWiFiHandler).Methods("GET")
- api.HandleFunc("/wifi/connect", connectWiFiHandler).Methods("POST")
- api.HandleFunc("/wifi/disconnect", disconnectWiFiHandler).Methods("POST")
- api.HandleFunc("/hotspot/config", configureHotspotHandler).Methods("POST")
- api.HandleFunc("/hotspot/toggle", toggleHotspotHandler).Methods("POST")
- api.HandleFunc("/devices", getDevicesHandler).Methods("GET")
- api.HandleFunc("/status", getStatusHandler).Methods("GET")
- api.HandleFunc("/logs", getLogsHandler).Methods("GET")
- api.HandleFunc("/logs", clearLogsHandler).Methods("DELETE")
-
- // WebSocket pour les logs en temps réel
- r.HandleFunc("/ws/logs", websocketHandler)
-
- // Servir les fichiers statiques
- sub, err := fs.Sub(_assets, "static")
- if err != nil {
- log.Fatal("Unable to cd to static/ directory:", err)
- }
- Assets := http.FS(sub)
- r.PathPrefix("/").Handler(http.FileServer(Assets))
-
- addLog("Système", "Serveur API démarré sur le port 8080")
- log.Fatal(http.ListenAndServe(":8080", r))
-}
-
-func initializeStatus() {
- statusMutex.Lock()
- defer statusMutex.Unlock()
-
- currentStatus = SystemStatus{
- Connected: false,
- ConnectedSSID: "",
- HotspotEnabled: true,
- ConnectedCount: 0,
- DataUsage: 0.0,
- Uptime: 0,
- }
-}
-
-// Handlers API
-
-func scanWiFiHandler(w http.ResponseWriter, r *http.Request) {
- networks, err := scanWiFiNetworks()
- if err != nil {
- addLog("WiFi", fmt.Sprintf("Erreur lors du scan: %v", err))
- http.Error(w, "Erreur lors du scan WiFi", http.StatusInternalServerError)
- return
- }
-
- addLog("WiFi", fmt.Sprintf("Scan terminé - %d réseaux trouvés", len(networks)))
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(networks)
-}
-
-func connectWiFiHandler(w http.ResponseWriter, r *http.Request) {
- var req WiFiConnectRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "Données invalides", http.StatusBadRequest)
- return
- }
-
- addLog("WiFi", fmt.Sprintf("Tentative de connexion à %s", req.SSID))
-
- err := connectToWiFiDBus(req.SSID, req.Password)
- if err != nil {
- addLog("WiFi", fmt.Sprintf("Échec de connexion: %v", err))
- http.Error(w, fmt.Sprintf("Échec de connexion: %v", err), http.StatusInternalServerError)
- return
- }
-
- statusMutex.Lock()
- currentStatus.Connected = true
- currentStatus.ConnectedSSID = req.SSID
- statusMutex.Unlock()
-
- addLog("WiFi", fmt.Sprintf("Connexion réussie à %s", req.SSID))
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{"status": "success"})
-}
-
-func disconnectWiFiHandler(w http.ResponseWriter, r *http.Request) {
- addLog("WiFi", "Tentative de déconnexion")
-
- err := disconnectWiFiDBus()
- if err != nil {
- addLog("WiFi", fmt.Sprintf("Erreur de déconnexion: %v", err))
- http.Error(w, fmt.Sprintf("Erreur de déconnexion: %v", err), http.StatusInternalServerError)
- return
- }
-
- statusMutex.Lock()
- currentStatus.Connected = false
- currentStatus.ConnectedSSID = ""
- statusMutex.Unlock()
-
- addLog("WiFi", "Déconnexion réussie")
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{"status": "success"})
-}
-
-func configureHotspotHandler(w http.ResponseWriter, r *http.Request) {
- var config HotspotConfig
- if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
- http.Error(w, "Données invalides", http.StatusBadRequest)
- return
- }
-
- err := configureHotspot(config)
- if err != nil {
- addLog("Hotspot", fmt.Sprintf("Erreur de configuration: %v", err))
- http.Error(w, fmt.Sprintf("Erreur de configuration: %v", err), http.StatusInternalServerError)
- return
- }
-
- addLog("Hotspot", fmt.Sprintf("Configuration mise à jour: %s (Canal %d)", config.SSID, config.Channel))
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{"status": "success"})
-}
-
-func toggleHotspotHandler(w http.ResponseWriter, r *http.Request) {
- statusMutex.Lock()
- currentStatus.HotspotEnabled = !currentStatus.HotspotEnabled
- enabled := currentStatus.HotspotEnabled
- statusMutex.Unlock()
-
- var err error
- if enabled {
- err = startHotspot()
- addLog("Hotspot", "Hotspot activé")
- } else {
- err = stopHotspot()
- addLog("Hotspot", "Hotspot désactivé")
- }
-
- if err != nil {
- addLog("Hotspot", fmt.Sprintf("Erreur: %v", err))
- http.Error(w, fmt.Sprintf("Erreur: %v", err), http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled})
-}
-
-func getDevicesHandler(w http.ResponseWriter, r *http.Request) {
- devices, err := getConnectedDevices()
- if err != nil {
- addLog("Système", fmt.Sprintf("Erreur lors de la récupération des appareils: %v", err))
- http.Error(w, "Erreur lors de la récupération des appareils", http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(devices)
-}
-
-func getStatusHandler(w http.ResponseWriter, r *http.Request) {
- statusMutex.RLock()
- status := currentStatus
- status.Uptime = int64(time.Since(startTime).Seconds())
- statusMutex.RUnlock()
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(status)
-}
-
-func getLogsHandler(w http.ResponseWriter, r *http.Request) {
- logMutex.RLock()
- logs := make([]LogEntry, len(logEntries))
- copy(logs, logEntries)
- logMutex.RUnlock()
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(logs)
-}
-
-func clearLogsHandler(w http.ResponseWriter, r *http.Request) {
- logMutex.Lock()
- logEntries = []LogEntry{}
- logMutex.Unlock()
-
- addLog("Système", "Logs effacés")
-
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]string{"status": "success"})
-}
-
-// Fonctions WiFi avec D-Bus
-
-func scanWiFiNetworks() ([]WiFiNetwork, error) {
- interfacePath, err := getWiFiInterfacePath()
- if err != nil {
- return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
- }
-
- // Déclencher un scan
- wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
- call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")})
- if call.Err != nil {
- return nil, fmt.Errorf("erreur lors du scan: %v", call.Err)
- }
-
- // Attendre un peu pour que le scan se termine
- time.Sleep(2 * time.Second)
-
- // Récupérer la liste des BSS
- bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs")
- if err != nil {
- return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err)
- }
-
- var networks []WiFiNetwork
- seenSSIDs := make(map[string]bool)
-
- for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) {
- bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath)
-
- // Récupérer les propriétés du BSS
- var props map[string]dbus.Variant
- err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props)
- if err != nil {
- continue
- }
-
- network := WiFiNetwork{}
-
- // Extraire SSID
- if ssidBytes, ok := props["SSID"].Value().([]byte); ok {
- network.SSID = string(ssidBytes)
- }
-
- // Éviter les doublons
- if network.SSID == "" || seenSSIDs[network.SSID] {
- continue
- }
- seenSSIDs[network.SSID] = true
-
- // Extraire BSSID
- if bssidBytes, ok := props["BSSID"].Value().([]byte); ok {
- network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
- bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5])
- }
-
- // Extraire la force du signal
- if signal, ok := props["Signal"].Value().(int16); ok {
- network.Signal = signalToStrength(int(signal))
- }
-
- // Extraire la fréquence et calculer le canal
- if frequency, ok := props["Frequency"].Value().(uint16); ok {
- network.Channel = frequencyToChannel(int(frequency))
- }
-
- // Déterminer la sécurité
- if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal {
- if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 {
- network.Security = "WPA"
- } else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 {
- network.Security = "WPA2"
- } else {
- network.Security = "WEP"
- }
- } else {
- network.Security = "Open"
- }
-
- networks = append(networks, network)
- }
-
- // Trier par force du signal
- sort.Slice(networks, func(i, j int) bool {
- return networks[i].Signal > networks[j].Signal
- })
-
- return networks, nil
-}
-
-func connectToWiFiDBus(ssid, password string) error {
- interfacePath, err := getWiFiInterfacePath()
- if err != nil {
- return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
- }
-
- wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
-
- // Créer un nouveau réseau
- networkConfig := map[string]dbus.Variant{
- "ssid": dbus.MakeVariant(ssid),
- }
-
- if password != "" {
- networkConfig["psk"] = dbus.MakeVariant(password)
- }
-
- var networkPath dbus.ObjectPath
- err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath)
- if err != nil {
- return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err)
- }
-
- // Sélectionner le réseau
- err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err
- if err != nil {
- return fmt.Errorf("erreur lors de la sélection du réseau: %v", err)
- }
-
- // Attendre la connexion
- for i := 0; i < 20; i++ {
- time.Sleep(500 * time.Millisecond)
- if isConnectedDBus() {
- return nil
- }
- }
-
- return fmt.Errorf("timeout lors de la connexion")
-}
-
-func disconnectWiFiDBus() error {
- interfacePath, err := getWiFiInterfacePath()
- if err != nil {
- return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err)
- }
-
- wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
-
- // Déconnecter
- err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err
- if err != nil {
- return fmt.Errorf("erreur lors de la déconnexion: %v", err)
- }
-
- // Supprimer tous les réseaux
- var networks []dbus.ObjectPath
- err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks)
- if err == nil {
- for _, networkPath := range networks {
- wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath)
- }
- }
-
- return nil
-}
-
-func getWiFiInterfacePath() (dbus.ObjectPath, error) {
- var interfacePath dbus.ObjectPath
- err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath)
- if err != nil {
- return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err)
- }
-
- return interfacePath, nil
-}
-
-func isConnectedDBus() bool {
- interfacePath, err := getWiFiInterfacePath()
- if err != nil {
- return false
- }
-
- wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath)
- var state string
- err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state)
- if err != nil {
- return false
- }
-
- return state == "completed"
-}
-
-func frequencyToChannel(frequency int) int {
- if frequency >= 2412 && frequency <= 2484 {
- if frequency == 2484 {
- return 14
- }
- return (frequency-2412)/5 + 1
- } else if frequency >= 5170 && frequency <= 5825 {
- return (frequency - 5000) / 5
- }
- return 0
-}
-
-func signalToStrength(level int) int {
- if level >= -30 {
- return 5
- } else if level >= -50 {
- return 4
- } else if level >= -60 {
- return 3
- } else if level >= -70 {
- return 2
- } else {
- return 1
- }
-}
-
-func connectToWiFi(ssid, password string) error {
- // Créer la configuration wpa_supplicant
- config := fmt.Sprintf(`ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
-update_config=1
-country=FR
-
-network={
- ssid="%s"
- psk="%s"
-}
-`, ssid, password)
-
- err := os.WriteFile(WPA_CONF, []byte(config), 0600)
- if err != nil {
- return err
- }
-
- // Redémarrer wpa_supplicant
- cmd := exec.Command("systemctl", "restart", "wpa_supplicant")
- if err := cmd.Run(); err != nil {
- return err
- }
-
- // Attendre la connexion
- for i := 0; i < 10; i++ {
- time.Sleep(1 * time.Second)
- if isConnected() {
- return nil
- }
- }
-
- return fmt.Errorf("timeout lors de la connexion")
-}
-
-func isConnected() bool {
- cmd := exec.Command("iwconfig", WLAN_INTERFACE)
- output, err := cmd.Output()
- if err != nil {
- return false
- }
-
- return strings.Contains(string(output), "Access Point:")
-}
-
-// Fonctions Hotspot
-
-func configureHotspot(config HotspotConfig) error {
- hostapdConfig := fmt.Sprintf(`interface=%s
-driver=nl80211
-ssid=%s
-hw_mode=g
-channel=%d
-wmm_enabled=0
-macaddr_acl=0
-auth_algs=1
-ignore_broadcast_ssid=0
-wpa=2
-wpa_passphrase=%s
-wpa_key_mgmt=WPA-PSK
-wpa_pairwise=TKIP
-rsn_pairwise=CCMP
-`, AP_INTERFACE, config.SSID, config.Channel, config.Password)
-
- return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644)
-}
-
-func startHotspot() error {
- cmd := exec.Command("systemctl", "start", "hostapd")
- return cmd.Run()
-}
-
-func stopHotspot() error {
- cmd := exec.Command("systemctl", "stop", "hostapd")
- return cmd.Run()
-}
-
-// Fonctions pour les appareils connectés
-
-func getConnectedDevices() ([]ConnectedDevice, error) {
- var devices []ConnectedDevice
-
- // Lire les baux DHCP
- leases, err := parseDHCPLeases()
- if err != nil {
- return devices, err
- }
-
- // Obtenir les informations ARP
- arpInfo, err := getARPInfo()
- if err != nil {
- return devices, err
- }
-
- for _, lease := range leases {
- device := ConnectedDevice{
- Name: lease.Hostname,
- MAC: lease.MAC,
- IP: lease.IP,
- Type: guessDeviceType(lease.Hostname, lease.MAC),
- }
-
- // Vérifier si l'appareil est toujours connecté via ARP
- if _, exists := arpInfo[lease.IP]; exists {
- devices = append(devices, device)
- }
- }
-
- return devices, nil
-}
-
-type DHCPLease struct {
- IP string
- MAC string
- Hostname string
-}
-
-func parseDHCPLeases() ([]DHCPLease, error) {
- var leases []DHCPLease
-
- file, err := os.Open("/var/lib/dhcp/dhcpd.leases")
- if err != nil {
- return leases, err
- }
- defer file.Close()
-
- scanner := bufio.NewScanner(file)
- var currentLease DHCPLease
-
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
-
- if strings.HasPrefix(line, "lease ") {
- ip := strings.Fields(line)[1]
- currentLease = DHCPLease{IP: ip}
- } else if strings.Contains(line, "hardware ethernet") {
- mac := strings.Fields(line)[2]
- mac = strings.TrimSuffix(mac, ";")
- currentLease.MAC = mac
- } else if strings.Contains(line, "client-hostname") {
- hostname := strings.Fields(line)[1]
- hostname = strings.Trim(hostname, `";`)
- currentLease.Hostname = hostname
- } else if line == "}" {
- if currentLease.IP != "" && currentLease.MAC != "" {
- leases = append(leases, currentLease)
- }
- currentLease = DHCPLease{}
- }
- }
-
- return leases, nil
-}
-
-func getARPInfo() (map[string]string, error) {
- arpInfo := make(map[string]string)
-
- cmd := exec.Command("arp", "-a")
- output, err := cmd.Output()
- if err != nil {
- return arpInfo, err
- }
-
- lines := strings.Split(string(output), "\n")
- for _, line := range lines {
- if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 {
- ip := matches[1]
- mac := matches[2]
- arpInfo[ip] = mac
- }
- }
-
- return arpInfo, nil
-}
-
-func guessDeviceType(hostname, mac string) string {
- hostname = strings.ToLower(hostname)
-
- if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
- return "mobile"
- } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
- return "tablet"
- } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
- return "laptop"
- }
-
- // Deviner par préfixe MAC (OUI)
- macPrefix := strings.ToUpper(mac[:8])
- switch macPrefix {
- case "00:50:56", "00:0C:29", "00:05:69": // VMware
- return "laptop"
- case "08:00:27": // VirtualBox
- return "laptop"
- default:
- return "mobile"
- }
-}
-
-// Fonctions de logging
-
-func addLog(source, message string) {
- logMutex.Lock()
- entry := LogEntry{
- Timestamp: time.Now(),
- Source: source,
- Message: message,
- }
- logEntries = append(logEntries, entry)
-
- // Garder seulement les 100 derniers logs
- if len(logEntries) > 100 {
- logEntries = logEntries[len(logEntries)-100:]
- }
- logMutex.Unlock()
-
- // Envoyer aux clients WebSocket
- broadcastToWebSockets(entry)
-
- // Log vers la console
- log.Printf("[%s] %s", source, message)
-}
-
-// WebSocket pour les logs en temps réel
-
-func websocketHandler(w http.ResponseWriter, r *http.Request) {
- conn, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- log.Printf("Erreur WebSocket: %v", err)
- return
- }
- defer conn.Close()
-
- clientsMutex.Lock()
- websocketClients[conn] = true
- clientsMutex.Unlock()
-
- defer func() {
- clientsMutex.Lock()
- delete(websocketClients, conn)
- clientsMutex.Unlock()
- }()
-
- // Envoyer les logs existants
- logMutex.RLock()
- for _, entry := range logEntries {
- conn.WriteJSON(entry)
- }
- logMutex.RUnlock()
-
- // Maintenir la connexion
- for {
- _, _, err := conn.ReadMessage()
- if err != nil {
- break
- }
- }
-}
-
-func broadcastToWebSockets(entry LogEntry) {
- clientsMutex.RLock()
- defer clientsMutex.RUnlock()
-
- for client := range websocketClients {
- err := client.WriteJSON(entry)
- if err != nil {
- client.Close()
- delete(websocketClients, client)
- }
- }
-}
-
-// Tâches périodiques
-
-func periodicStatusUpdate() {
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
-
- for range ticker.C {
- statusMutex.Lock()
- currentStatus.Connected = isConnected()
- if !currentStatus.Connected {
- currentStatus.ConnectedSSID = ""
- }
- statusMutex.Unlock()
- }
-}
-
-func periodicDeviceUpdate() {
- ticker := time.NewTicker(10 * time.Second)
- defer ticker.Stop()
-
- for range ticker.C {
- devices, err := getConnectedDevices()
- if err != nil {
- continue
- }
-
- statusMutex.Lock()
- currentStatus.ConnectedDevices = devices
- currentStatus.ConnectedCount = len(devices)
- statusMutex.Unlock()
- }
-}
diff --git a/oapi-gin.cfg.yaml b/oapi-gin.cfg.yaml
new file mode 100644
index 0000000..b30fef2
--- /dev/null
+++ b/oapi-gin.cfg.yaml
@@ -0,0 +1,5 @@
+package: api
+generate:
+ - gin
+ - embedded-spec
+output: internal/api/routes.gen.go
diff --git a/oapi-types.cfg.yaml b/oapi-types.cfg.yaml
new file mode 100644
index 0000000..57b44d2
--- /dev/null
+++ b/oapi-types.cfg.yaml
@@ -0,0 +1,4 @@
+package: api
+generate:
+ - types
+output: internal/api/types.gen.go
diff --git a/openapi.yaml b/openapi.yaml
new file mode 100644
index 0000000..4357d09
--- /dev/null
+++ b/openapi.yaml
@@ -0,0 +1,552 @@
+openapi: 3.0.3
+info:
+ title: Travel Router Control API
+ description: |
+ API for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity.
+ The router can operate as a WiFi repeater, connecting to upstream networks while providing
+ a hotspot for client devices.
+ version: 1.0.0
+ contact:
+ name: API Support
+ license:
+ name: MIT
+
+servers:
+ - url: http://localhost:8080
+ description: Local router API
+
+tags:
+ - name: WiFi
+ description: WiFi client operations (upstream network connection)
+ - name: Hotspot
+ description: Access point operations (client-facing hotspot)
+ - name: Devices
+ description: Connected devices management
+ - name: System
+ description: System status and monitoring
+ - name: Logs
+ 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:
+ - WiFi
+ summary: Scan for available WiFi networks
+ description: |
+ Triggers a WiFi scan using wpa_supplicant via D-Bus and returns all discovered networks
+ sorted by signal strength. The scan takes approximately 2 seconds to complete.
+ operationId: scanWiFi
+ responses:
+ '200':
+ description: Successfully scanned 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: Scan error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /api/wifi/connect:
+ post:
+ tags:
+ - WiFi
+ summary: Connect to a WiFi network
+ description: |
+ Connects the router to an upstream WiFi network using wpa_supplicant.
+ Supports both open and password-protected networks (WPA/WPA2).
+ operationId: connectWiFi
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WiFiConnectRequest'
+ examples:
+ protected:
+ summary: WPA2 protected network
+ value:
+ ssid: "Hotel-Guest"
+ password: "guest1234"
+ open:
+ summary: Open network
+ value:
+ ssid: "Public-WiFi"
+ password: ""
+ responses:
+ '200':
+ description: Successfully connected
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SuccessResponse'
+ '400':
+ description: Invalid request data
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Connection failed
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /api/wifi/disconnect:
+ post:
+ tags:
+ - WiFi
+ summary: Disconnect from WiFi network
+ description: |
+ Disconnects from the currently connected upstream WiFi network
+ and removes all saved network configurations.
+ operationId: disconnectWiFi
+ responses:
+ '200':
+ description: Successfully disconnected
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SuccessResponse'
+ '500':
+ description: Disconnection failed
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /api/hotspot/toggle:
+ post:
+ tags:
+ - Hotspot
+ summary: Toggle hotspot on/off
+ description: |
+ Enables or disables the hotspot (access point) by starting/stopping
+ the hostapd service. Returns the new enabled state and updates
+ the system status with current hostapd_cli information.
+ operationId: toggleHotspot
+ responses:
+ '200':
+ description: Hotspot state changed successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ enabled:
+ type: boolean
+ description: Current hotspot state after toggle
+ required:
+ - enabled
+ example:
+ enabled: true
+ '500':
+ description: Failed to change hotspot state
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /api/devices:
+ get:
+ tags:
+ - Devices
+ summary: Get connected devices
+ description: |
+ Returns a list of all devices currently connected to the hotspot.
+ Device information is gathered from DHCP leases and ARP tables.
+ Only devices with active ARP entries are considered connected.
+ operationId: getDevices
+ responses:
+ '200':
+ description: List of connected devices
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/ConnectedDevice'
+ example:
+ - name: "iPhone-12"
+ type: "mobile"
+ mac: "aa:bb:cc:11:22:33"
+ ip: "192.168.1.100"
+ - name: "MacBook-Pro"
+ type: "laptop"
+ mac: "dd:ee:ff:44:55:66"
+ ip: "192.168.1.101"
+ '500':
+ description: Failed to retrieve device list
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /api/status:
+ get:
+ tags:
+ - System
+ summary: Get system status
+ description: |
+ Returns comprehensive system status including WiFi connection state,
+ detailed hotspot status from hostapd_cli, connected device count,
+ data usage, and uptime.
+ operationId: getStatus
+ responses:
+ '200':
+ description: Current system status
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SystemStatus'
+
+ /api/logs:
+ get:
+ tags:
+ - Logs
+ summary: Get system logs
+ description: |
+ Returns the last 100 log entries from the system.
+ For real-time log streaming, use the WebSocket endpoint.
+ operationId: getLogs
+ responses:
+ '200':
+ description: List of log entries
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogEntry'
+ example:
+ - timestamp: "2025-10-28T14:32:10Z"
+ source: "WiFi"
+ message: "Scan terminé - 5 réseaux trouvés"
+ - timestamp: "2025-10-28T14:32:15Z"
+ source: "WiFi"
+ message: "Tentative de connexion à Hotel-Guest"
+ delete:
+ tags:
+ - Logs
+ summary: Clear system logs
+ description: Clears all stored log entries (keeps only the "logs cleared" entry)
+ operationId: clearLogs
+ responses:
+ '200':
+ description: Logs cleared successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SuccessResponse'
+
+ /ws/logs:
+ get:
+ tags:
+ - Logs
+ summary: WebSocket for real-time logs
+ description: |
+ WebSocket endpoint for receiving real-time log updates.
+ Upon connection, all existing logs are sent, followed by new logs as they occur.
+
+ This is a WebSocket endpoint - upgrade the HTTP connection to WebSocket protocol.
+ operationId: logsWebSocket
+ responses:
+ '101':
+ description: WebSocket connection established
+ '400':
+ description: WebSocket upgrade failed
+
+components:
+ schemas:
+ WiFiNetwork:
+ type: object
+ description: Discovered WiFi network information
+ properties:
+ ssid:
+ type: string
+ description: Network SSID (name)
+ example: "Hotel-Guest"
+ signal:
+ type: integer
+ description: Signal strength (1-5 scale)
+ minimum: 1
+ maximum: 5
+ example: 4
+ security:
+ type: string
+ description: Security type
+ enum:
+ - Open
+ - WEP
+ - WPA
+ - WPA2
+ example: "WPA2"
+ channel:
+ type: integer
+ description: WiFi channel number
+ minimum: 1
+ maximum: 165
+ example: 6
+ bssid:
+ type: string
+ description: Access point MAC address
+ pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
+ example: "aa:bb:cc:dd:ee:ff"
+ required:
+ - ssid
+ - signal
+ - security
+ - channel
+ - bssid
+
+ WiFiConnectRequest:
+ type: object
+ description: Request to connect to a WiFi network
+ properties:
+ ssid:
+ type: string
+ description: Network SSID to connect to
+ example: "Hotel-Guest"
+ password:
+ type: string
+ description: Network password (empty string for open networks)
+ example: "guest1234"
+ required:
+ - ssid
+ - password
+
+ HotspotStatus:
+ type: object
+ description: Detailed hotspot status from hostapd_cli
+ properties:
+ state:
+ type: string
+ description: Hotspot state (ENABLED, DISABLED, etc.)
+ example: "ENABLED"
+ ssid:
+ type: string
+ description: Current SSID being broadcast
+ example: "TravelRouter"
+ bssid:
+ type: string
+ description: MAC address of the access point
+ pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
+ example: "4a:e3:4e:09:57:f8"
+ channel:
+ type: integer
+ description: Current WiFi channel
+ minimum: 1
+ maximum: 14
+ example: 11
+ frequency:
+ type: integer
+ description: Frequency in MHz
+ example: 2462
+ numStations:
+ type: integer
+ description: Number of connected stations
+ minimum: 0
+ example: 2
+ hwMode:
+ type: string
+ description: Hardware mode (g, a, n, ac, etc.)
+ example: "g"
+ countryCode:
+ type: string
+ description: Country code
+ example: "VN"
+ required:
+ - state
+
+ ConnectedDevice:
+ type: object
+ description: Device connected to the hotspot
+ properties:
+ name:
+ type: string
+ description: Device hostname
+ example: "iPhone-12"
+ type:
+ type: string
+ description: Detected device type
+ enum:
+ - mobile
+ - tablet
+ - laptop
+ - desktop
+ - unknown
+ example: "mobile"
+ mac:
+ type: string
+ description: Device MAC address
+ pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
+ example: "aa:bb:cc:11:22:33"
+ ip:
+ type: string
+ description: Assigned IP address
+ pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$'
+ example: "192.168.1.100"
+ required:
+ - name
+ - type
+ - mac
+ - ip
+
+ SystemStatus:
+ type: object
+ description: Overall system status
+ properties:
+ connected:
+ type: boolean
+ description: Whether router is connected to upstream WiFi
+ example: true
+ connectionState:
+ type: string
+ description: Current WiFi connection state
+ enum:
+ - connected
+ - disconnected
+ - connecting
+ - disconnecting
+ - roaming
+ example: "connected"
+ connectedSSID:
+ type: string
+ description: SSID of connected upstream network (empty if not connected)
+ example: "Hotel-Guest"
+ hotspotStatus:
+ allOf:
+ - $ref: '#/components/schemas/HotspotStatus'
+ nullable: true
+ description: Detailed hotspot status (null if hotspot is not running)
+ connectedCount:
+ type: integer
+ description: Number of devices connected to hotspot
+ minimum: 0
+ example: 3
+ dataUsage:
+ type: number
+ format: double
+ description: Total data usage in MB
+ example: 145.7
+ uptime:
+ type: integer
+ format: int64
+ description: System uptime in seconds
+ example: 3600
+ connectedDevices:
+ type: array
+ description: List of devices connected to hotspot
+ items:
+ $ref: '#/components/schemas/ConnectedDevice'
+ required:
+ - connected
+ - connectionState
+ - connectedSSID
+ - connectedCount
+ - dataUsage
+ - uptime
+ - connectedDevices
+
+ LogEntry:
+ type: object
+ description: System log entry
+ properties:
+ timestamp:
+ type: string
+ format: date-time
+ description: When the log entry was created
+ example: "2025-10-28T14:32:10Z"
+ source:
+ type: string
+ description: Log source component
+ enum:
+ - Système
+ - WiFi
+ - Hotspot
+ example: "WiFi"
+ message:
+ type: string
+ description: Log message
+ example: "Scan terminé - 5 réseaux trouvés"
+ required:
+ - timestamp
+ - source
+ - message
+
+ SuccessResponse:
+ type: object
+ description: Generic success response
+ properties:
+ status:
+ type: string
+ enum:
+ - success
+ example: "success"
+ required:
+ - status
+
+ Error:
+ type: object
+ description: Error response
+ properties:
+ error:
+ type: string
+ description: Error message
+ example: "Erreur lors du scan WiFi"
+ required:
+ - error
diff --git a/static/app.js b/static/app.js
deleted file mode 100644
index 6fce809..0000000
--- a/static/app.js
+++ /dev/null
@@ -1,239 +0,0 @@
-// État global de l'application
-let appState = {
- selectedWifi: null,
- hotspotEnabled: true,
- connectedDevices: [],
- wifiNetworks: [],
- uptime: 0,
- dataUsage: 0
-};
-
-// Simulation de données
-const mockDevices = [
- { name: "iPhone 13", type: "mobile", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.101" },
- { name: "MacBook Pro", type: "laptop", mac: "11:22:33:44:55:66", ip: "192.168.1.102" },
- { name: "iPad", type: "tablet", mac: "77:88:99:AA:BB:CC", ip: "192.168.1.103" }
-];
-
-// Initialisation
-document.addEventListener('DOMContentLoaded', function() {
- initializeApp();
- startPeriodicUpdates();
-});
-
-function initializeApp() {
- updateWifiList();
- updateDevicesList();
- updateStats();
- addLog("Système", "Interface web initialisée");
-}
-
-async function updateWifiList() {
- const wifiList = document.getElementById('wifiList');
- wifiList.innerHTML = '';
-
- (await (await fetch('/api/wifi/scan')).json()).forEach((network, index) => {
- const wifiItem = document.createElement('div');
- wifiItem.className = 'wifi-item';
- wifiItem.onclick = () => selectWifi(network, wifiItem);
-
- wifiItem.innerHTML = `
-
-
${network.ssid}
-
${network.security} • Canal ${network.channel}
-
-
- ${generateSignalBars(network.signal)}
-
- `;
-
- wifiList.appendChild(wifiItem);
- });
-}
-
-function generateSignalBars(strength) {
- const bars = [];
- for (let i = 1; i <= 5; i++) {
- const height = i * 3;
- const active = i <= strength ? 'active' : '';
- bars.push(``);
- }
- return `${bars.join('')}
`;
-}
-
-function selectWifi(network, element) {
- // Retirer la sélection précédente
- document.querySelectorAll('.wifi-item').forEach(item => {
- item.classList.remove('selected');
- });
-
- // Ajouter la sélection
- element.classList.add('selected');
- appState.selectedWifi = network;
-
- addLog("WiFi", `Réseau sélectionné: ${network.ssid}`);
-}
-
-function updateDevicesList() {
- const devicesList = document.getElementById('devicesList');
- devicesList.innerHTML = '';
-
- mockDevices.forEach(device => {
- const deviceCard = document.createElement('div');
- deviceCard.className = 'device-card';
-
- const deviceIcon = getDeviceIcon(device.type);
-
- deviceCard.innerHTML = `
- ${deviceIcon}
- ${device.name}
- ${device.ip}
- `;
-
- devicesList.appendChild(deviceCard);
- });
-
- document.getElementById('connectedDevices').textContent = mockDevices.length;
-}
-
-function getDeviceIcon(type) {
- const icons = {
- mobile: '',
- laptop: '',
- tablet: ''
- };
- return icons[type] || icons.mobile;
-}
-
-function updateStats() {
- appState.uptime += 1;
- appState.dataUsage += Math.random() * 0.5;
-
- const hours = Math.floor(appState.uptime / 3600);
- const minutes = Math.floor((appState.uptime % 3600) / 60);
- const seconds = appState.uptime % 60;
-
- document.getElementById('uptime').textContent =
- `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
-
- document.getElementById('dataUsage').textContent = `${appState.dataUsage.toFixed(1)} MB`;
-}
-
-function addLog(source, message) {
- const logContainer = document.getElementById('logContainer');
- const timestamp = new Date().toLocaleTimeString();
- const logEntry = document.createElement('div');
- logEntry.className = 'log-entry';
- logEntry.innerHTML = `[${timestamp}] ${source}: ${message}`;
-
- logContainer.appendChild(logEntry);
- logContainer.scrollTop = logContainer.scrollHeight;
-}
-
-function showNotification(message, type = 'success') {
- const notification = document.getElementById('notification');
- notification.textContent = message;
- notification.className = `notification ${type}`;
- notification.classList.add('show');
-
- setTimeout(() => {
- notification.classList.remove('show');
- }, 3000);
-}
-
-// Fonctions d'action
-function scanWifi() {
- const scanBtn = document.getElementById('scanBtn');
- const originalText = scanBtn.textContent;
-
- scanBtn.innerHTML = ' Scan en cours...';
-
- setTimeout(() => {
- updateWifiList();
- scanBtn.textContent = originalText;
- showNotification('Scan terminé - Réseaux mis à jour');
- addLog("WiFi", "Scan des réseaux terminé");
- }, 2000);
-}
-
-function connectToWifi() {
- if (!appState.selectedWifi) {
- showNotification('Veuillez sélectionner un réseau WiFi', 'error');
- return;
- }
-
- const password = document.getElementById('wifiPassword').value;
- if (!password && appState.selectedWifi.security !== 'Open') {
- showNotification('Mot de passe requis', 'error');
- return;
- }
-
- const connectBtn = document.getElementById('connectBtn');
- const originalText = connectBtn.textContent;
-
- connectBtn.innerHTML = ' Connexion...';
-
- setTimeout(() => {
- connectBtn.textContent = originalText;
- showNotification(`Connecté à ${appState.selectedWifi.ssid}`);
- addLog("WiFi", `Connexion établie avec ${appState.selectedWifi.ssid}`);
-
- // Mettre à jour le statut
- document.getElementById('connectionStatus').innerHTML = `
-
- Connecté à ${appState.selectedWifi.ssid}
- `;
- }, 3000);
-}
-
-function updateHotspot() {
- const name = document.getElementById('hotspotName').value;
- const password = document.getElementById('hotspotPassword').value;
- const channel = document.getElementById('hotspotChannel').value;
-
- if (!name || !password) {
- showNotification('Nom et mot de passe requis', 'error');
- return;
- }
-
- showNotification('Configuration du hotspot mise à jour');
- addLog("Hotspot", `Configuration mise à jour: ${name} (Canal ${channel})`);
-}
-
-function toggleHotspot() {
- appState.hotspotEnabled = !appState.hotspotEnabled;
- const btn = document.getElementById('hotspotBtn');
-
- if (appState.hotspotEnabled) {
- btn.textContent = 'Arrêter le hotspot';
- showNotification('Hotspot activé');
- addLog("Hotspot", "Hotspot activé");
- } else {
- btn.textContent = 'Démarrer le hotspot';
- showNotification('Hotspot désactivé');
- addLog("Hotspot", "Hotspot désactivé");
- }
-}
-
-function clearLogs() {
- document.getElementById('logContainer').innerHTML = '';
- addLog("Système", "Logs effacés");
-}
-
-// Mises à jour périodiques
-function startPeriodicUpdates() {
- setInterval(updateStats, 1000);
- setInterval(() => {
- // Simulation de nouveaux logs
- if (Math.random() > 0.95) {
- const events = [
- "Nouveau client connecté",
- "Paquet routé vers l'extérieur",
- "Vérification de la connexion",
- "Mise à jour des tables de routage"
- ];
- const randomEvent = events[Math.floor(Math.random() * events.length)];
- addLog("Système", randomEvent);
- }
- }, 5000);
-}
diff --git a/static/index.html b/static/index.html
deleted file mode 100644
index dbb7131..0000000
--- a/static/index.html
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
- WiFi Repeater Control
-
-
-
-
-
-
-
-
-
0
-
Appareils connectés
-
-
-
0 MB
-
Données utilisées
-
-
-
00:00:00
-
Temps de fonctionnement
-
-
-
-
-
-
-
- Connexion WiFi Externe
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Configuration Hotspot
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Appareils connectés
-
-
-
-
-
-
-
-
-
-
- Logs système
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/static/style.css b/static/style.css
deleted file mode 100644
index 3ea544a..0000000
--- a/static/style.css
+++ /dev/null
@@ -1,338 +0,0 @@
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- min-height: 100vh;
- padding: 20px;
-}
-
-.container {
- max-width: 1200px;
- margin: 0 auto;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(10px);
- border-radius: 20px;
- padding: 30px;
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
-}
-
-.header {
- text-align: center;
- margin-bottom: 40px;
- padding-bottom: 20px;
- border-bottom: 2px solid #e0e0e0;
-}
-
-.header h1 {
- color: #333;
- font-size: 2.5em;
- margin-bottom: 10px;
-}
-
-.status-indicator {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- padding: 10px 20px;
- border-radius: 25px;
- font-weight: 500;
- margin-top: 10px;
-}
-
-.status-online {
- background: #d4edda;
- color: #155724;
-}
-
-.status-offline {
- background: #f8d7da;
- color: #721c24;
-}
-
-.status-dot {
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: currentColor;
- animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.5; }
-}
-
-.grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
- gap: 30px;
- margin-bottom: 30px;
-}
-
-.card {
- background: white;
- border-radius: 15px;
- padding: 25px;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
- transition: transform 0.3s ease, box-shadow 0.3s ease;
-}
-
-.card:hover {
- transform: translateY(-5px);
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
-}
-
-.card h2 {
- color: #333;
- margin-bottom: 20px;
- font-size: 1.4em;
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.icon {
- width: 24px;
- height: 24px;
- fill: currentColor;
-}
-
-.form-group {
- margin-bottom: 20px;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 8px;
- color: #555;
- font-weight: 500;
-}
-
-.form-group input, .form-group select {
- width: 100%;
- padding: 12px 16px;
- border: 2px solid #e0e0e0;
- border-radius: 8px;
- font-size: 16px;
- transition: border-color 0.3s ease;
-}
-
-.form-group input:focus, .form-group select:focus {
- outline: none;
- border-color: #667eea;
-}
-
-.btn {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- border: none;
- padding: 12px 24px;
- border-radius: 8px;
- font-size: 16px;
- font-weight: 500;
- cursor: pointer;
- transition: transform 0.2s ease, box-shadow 0.2s ease;
- width: 100%;
-}
-
-.btn:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
-}
-
-.btn:active {
- transform: translateY(0);
-}
-
-.btn-secondary {
- background: #6c757d;
- margin-top: 10px;
-}
-
-.btn-danger {
- background: #dc3545;
-}
-
-.wifi-list {
- max-height: 300px;
- overflow-y: auto;
- border: 1px solid #e0e0e0;
- border-radius: 8px;
- margin-bottom: 20px;
-}
-
-.wifi-item {
- padding: 12px 16px;
- border-bottom: 1px solid #f0f0f0;
- cursor: pointer;
- transition: background-color 0.2s ease;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.wifi-item:hover {
- background-color: #f8f9fa;
-}
-
-.wifi-item:last-child {
- border-bottom: none;
-}
-
-.wifi-item.selected {
- background-color: #e7f3ff;
- border-left: 4px solid #667eea;
-}
-
-.wifi-signal {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.signal-strength {
- width: 20px;
- height: 20px;
- position: relative;
-}
-
-.signal-bars {
- display: flex;
- gap: 2px;
- align-items: flex-end;
-}
-
-.signal-bar {
- width: 3px;
- background: #ccc;
- border-radius: 1px;
-}
-
-.signal-bar.active {
- background: #28a745;
-}
-
-.devices-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 15px;
-}
-
-.device-card {
- background: #f8f9fa;
- border-radius: 10px;
- padding: 15px;
- text-align: center;
- transition: transform 0.2s ease;
-}
-
-.device-card:hover {
- transform: scale(1.05);
-}
-
-.device-icon {
- width: 40px;
- height: 40px;
- margin: 0 auto 10px;
- fill: #667eea;
-}
-
-.log-container {
- background: #1a1a1a;
- color: #00ff00;
- padding: 20px;
- border-radius: 10px;
- font-family: 'Courier New', monospace;
- font-size: 14px;
- max-height: 300px;
- overflow-y: auto;
- line-height: 1.4;
-}
-
-.log-entry {
- margin-bottom: 5px;
-}
-
-.log-timestamp {
- color: #888;
-}
-
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 20px;
-}
-
-.stat-card {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 20px;
- border-radius: 10px;
- text-align: center;
-}
-
-.stat-value {
- font-size: 2em;
- font-weight: bold;
- margin-bottom: 5px;
-}
-
-.stat-label {
- font-size: 0.9em;
- opacity: 0.9;
-}
-
-.loading {
- display: inline-block;
- width: 20px;
- height: 20px;
- border: 3px solid #f3f3f3;
- border-top: 3px solid #667eea;
- border-radius: 50%;
- animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-
-.notification {
- position: fixed;
- top: 20px;
- right: 20px;
- padding: 15px 20px;
- border-radius: 10px;
- color: white;
- font-weight: 500;
- z-index: 1000;
- transform: translateX(100%);
- transition: transform 0.3s ease;
-}
-
-.notification.show {
- transform: translateX(0);
-}
-
-.notification.success {
- background: #28a745;
-}
-
-.notification.error {
- background: #dc3545;
-}
-
-@media (max-width: 768px) {
- .grid {
- grid-template-columns: 1fr;
- }
-
- .container {
- padding: 20px;
- }
-}