Browse Source

Initial commit: MyBeacon daemon with Network Manager refactoring

Components:
- beacon-daemon: Main daemon with ZMQ subscribers, spooler, HTTP API
- ble-scanner: BlueZ D-Bus BLE scanner
- wifi-scanner: nl80211/pcap WiFi scanner
- network_manager: NEW - Full network management with priority logic
  * Priority 1: eth0 (carrier detect, DHCP/Static)
  * Priority 2: wlan0 client (if configured)
  * Fallback: wlan0 AP (120s timeout, SSID=Luckfox_Setup)
- dashboard: Vue.js web UI (WIP)

Current status:
- Network Manager fully rewritten with AP fallback
- WiFi client management moved from main.go to network_manager.go
- Scanner coordination integrated (stops WiFi scanner when using wlan0)
- NOT YET TESTED - need to build and flash to device

Next: Test eth0 hotplug, wlan0 client, AP fallback on hardware
root 1 month ago
commit
186ee32fd6

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# Binaries
+/bin/
+/beacon-daemon
+/beacon-daemon-arm
+/ble-scanner
+/wifi-scanner
+*.exe
+
+# Build artifacts
+update.img
+*.tar.gz
+/release/
+
+# Go
+*.out
+*.test
+*.prof
+vendor/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# Node.js (dashboard)
+dashboard/node_modules/
+dashboard/dist/
+dashboard/.DS_Store

+ 92 - 0
Makefile

@@ -0,0 +1,92 @@
+# MyBeacon Native - Build Makefile
+# Cross-compilation for ARM (Luckfox Pico Ultra W)
+
+BINDIR := bin
+ARMDIR := $(BINDIR)/arm
+CMDS := ble-scanner wifi-scanner beacon-daemon
+
+# Build flags
+LDFLAGS := -s -w
+BUILD_FLAGS := -trimpath
+
+# Device settings
+DEVICE_IP ?= 192.168.1.100
+DEVICE_USER ?= root
+
+.PHONY: all clean native arm deps deploy
+
+# Default: native build
+all: native
+
+# Native build (for testing on x86)
+native:
+	@mkdir -p $(BINDIR)
+	@for cmd in $(CMDS); do \
+		echo "Building $$cmd (native)..."; \
+		go build $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" -o $(BINDIR)/$$cmd ./cmd/$$cmd; \
+	done
+	@echo "Build complete: $(BINDIR)/"
+
+# ARM cross-compile (pure Go, no CGO needed)
+arm:
+	@mkdir -p $(ARMDIR)
+	@for cmd in $(CMDS); do \
+		echo "Building $$cmd (arm)..."; \
+		CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 \
+		go build $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" -o $(ARMDIR)/$$cmd ./cmd/$$cmd; \
+	done
+	@ls -lh $(ARMDIR)/
+	@echo "ARM build complete: $(ARMDIR)/"
+
+# Install dependencies
+deps:
+	go mod download
+	go mod tidy
+
+# Clean
+clean:
+	rm -rf $(BINDIR)
+	go clean
+
+# Run tests
+test:
+	go test -v ./...
+
+# Format code
+fmt:
+	go fmt ./...
+
+# Lint
+lint:
+	go vet ./...
+
+# Deploy to device via SCP
+deploy: arm
+	@echo "Deploying to $(DEVICE_USER)@$(DEVICE_IP)..."
+	scp $(ARMDIR)/ble-scanner $(ARMDIR)/wifi-scanner $(ARMDIR)/beacon-daemon \
+		$(DEVICE_USER)@$(DEVICE_IP):/opt/mybeacon/bin/
+	@echo "Deploy complete!"
+
+# Create release tarball
+release: arm
+	@mkdir -p release
+	tar czvf release/mybeacon-arm-$$(date +%Y%m%d).tar.gz \
+		-C $(ARMDIR) ble-scanner wifi-scanner beacon-daemon
+	@echo "Release tarball created: release/mybeacon-arm-$$(date +%Y%m%d).tar.gz"
+
+# Show help
+help:
+	@echo "MyBeacon Build System"
+	@echo ""
+	@echo "Targets:"
+	@echo "  make native    - Build for current platform (default)"
+	@echo "  make arm       - Cross-compile for ARM (Luckfox)"
+	@echo "  make deps      - Download dependencies"
+	@echo "  make clean     - Clean build artifacts"
+	@echo "  make test      - Run tests"
+	@echo "  make deploy    - Deploy to device (set DEVICE_IP)"
+	@echo "  make release   - Create release tarball"
+	@echo ""
+	@echo "Variables:"
+	@echo "  DEVICE_IP=x.x.x.x  - Device IP for deploy"
+	@echo "  DEVICE_USER=root   - Device user for deploy"

+ 86 - 0
README.md

@@ -0,0 +1,86 @@
+# MyBeacon Native
+
+Нативная реализация BLE/WiFi сканера для Luckfox Pico Ultra W.
+
+## Компоненты
+
+- **ble-scanner** — BLE сканирование через HCI raw socket
+- **wifi-scanner** — WiFi probe request capture через AF_PACKET
+- **beacon-daemon** — агрегация событий, upload на сервер, SSH туннель
+
+## Сборка
+
+```bash
+# Установить Go 1.21+
+# Установить ZMQ dev libraries: apt install libzmq3-dev
+
+# Скачать зависимости
+make deps
+
+# Нативная сборка (для тестирования)
+make native
+
+# ARM cross-compile (для Luckfox)
+# Нужен arm-linux-gnueabihf-gcc
+make arm CC_ARM=arm-linux-gnueabihf-gcc
+```
+
+## Запуск
+
+```bash
+# 1. Запустить BLE сканер
+./bin/ble-scanner --zmq tcp://127.0.0.1:5555 --hci 0
+
+# 2. Запустить WiFi сканер (требует root)
+sudo ./bin/wifi-scanner --zmq tcp://127.0.0.1:5556 --iface wlan0
+
+# 3. Запустить демон
+./bin/beacon-daemon --config /opt/mybeacon/etc/config.json
+```
+
+## Конфигурация
+
+```json
+{
+  "api_base": "http://server:5000/api/v1",
+  "zmq_addr_ble": "tcp://127.0.0.1:5555",
+  "zmq_addr_wifi": "tcp://127.0.0.1:5556",
+  "spool_dir": "/var/spool/mybeacon",
+  "ble": {
+    "enabled": true,
+    "batch_interval_ms": 2500
+  },
+  "wifi": {
+    "monitor_enabled": false,
+    "batch_interval_ms": 10000
+  },
+  "ssh_tunnel": {
+    "enabled": false,
+    "server": "tunnel.example.com",
+    "port": 22,
+    "user": "tunnel",
+    "key_path": "/opt/mybeacon/etc/tunnel_key",
+    "remote_port": 12345
+  }
+}
+```
+
+## ZMQ Протокол
+
+Сканеры публикуют события в формате: `topic JSON`
+
+Топики:
+- `ble.ibeacon` — iBeacon
+- `ble.acc` — my-beacon accelerometer
+- `ble.relay` — relay beacon
+- `wifi.probe` — WiFi probe request
+
+## Деплой на устройство
+
+```bash
+# Скопировать бинарники
+scp bin/*-arm root@device:/opt/mybeacon/bin/
+
+# Создать init script
+# См. /home/user/work/luckfox/alpine/MYBEACON.md
+```

+ 763 - 0
cmd/beacon-daemon/api.go

@@ -0,0 +1,763 @@
+package main
+
+import (
+	"bufio"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+	"runtime"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+// APIServer handles HTTP API requests
+type APIServer struct {
+	daemon   *Daemon
+	upgrader websocket.Upgrader
+
+	// Recent events (ring buffer)
+	recentBLE  []interface{}
+	recentWiFi []interface{}
+	recentMu   sync.RWMutex
+
+	// WebSocket clients
+	wsClients   map[*websocket.Conn]bool
+	wsClientsMu sync.Mutex
+
+	// Session management
+	sessions   map[string]time.Time
+	sessionsMu sync.RWMutex
+}
+
+// StatusResponse is the response for /api/status
+type StatusResponse struct {
+	DeviceID   string         `json:"device_id"`
+	Registered bool           `json:"registered"`
+	Mode       string         `json:"mode"`
+	Uptime     int64          `json:"uptime_sec"`
+	Network    NetworkStatus  `json:"network"`
+	Scanners   ScannerStatus  `json:"scanners"`
+	Counters   CounterStatus  `json:"counters"`
+	ServerOK   bool           `json:"server_ok"`
+}
+
+type NetworkStatus struct {
+	Eth0IP       string `json:"eth0_ip,omitempty"`
+	Eth0RX       int64  `json:"eth0_rx,omitempty"`
+	Eth0TX       int64  `json:"eth0_tx,omitempty"`
+	Wlan0IP      string `json:"wlan0_ip,omitempty"`
+	Wlan0SSID    string `json:"wlan0_ssid,omitempty"`
+	Wlan0Signal  int    `json:"wlan0_signal,omitempty"`
+	Wlan0Channel int    `json:"wlan0_channel,omitempty"`
+	Wlan0Gateway string `json:"wlan0_gateway,omitempty"`
+	Wlan0DNS     string `json:"wlan0_dns,omitempty"`
+	Wlan0RX      int64  `json:"wlan0_rx,omitempty"`
+	Wlan0TX      int64  `json:"wlan0_tx,omitempty"`
+	Gateway      string `json:"gateway,omitempty"`
+	DNS          string `json:"dns,omitempty"`
+	NTP          string `json:"ntp,omitempty"`
+	APActive     bool   `json:"ap_active"`
+}
+
+type ScannerStatus struct {
+	BLERunning  bool `json:"ble_running"`
+	WiFiRunning bool `json:"wifi_running"`
+}
+
+type CounterStatus struct {
+	BLEEvents  uint64 `json:"ble_events"`
+	WiFiEvents uint64 `json:"wifi_events"`
+	Uploads    uint64 `json:"uploads"`
+	Errors     uint64 `json:"errors"`
+}
+
+// MetricsResponse is the response for /api/metrics
+type MetricsResponse struct {
+	CPUPercent  float64 `json:"cpu_percent"`
+	MemUsedMB   float64 `json:"mem_used_mb"`
+	MemTotalMB  float64 `json:"mem_total_mb"`
+	Temperature float64 `json:"temperature"`
+	LoadAvg     float64 `json:"load_avg"`
+}
+
+// SettingsRequest is the request for /api/settings
+type SettingsRequest struct {
+	Password string          `json:"password"`
+	Settings SettingsPayload `json:"settings"`
+}
+
+// SettingsPayload contains the actual settings
+type SettingsPayload struct {
+	Mode        string `json:"mode"`
+	WifiSSID    string `json:"wifi_ssid"`
+	WifiPSK     string `json:"wifi_psk"`
+	Eth0Mode    string `json:"eth0_mode"`
+	Eth0IP      string `json:"eth0_ip"`
+	Eth0Gateway string `json:"eth0_gateway"`
+	Eth0DNS     string `json:"eth0_dns"`
+	NTPServers  string `json:"ntp_servers"`
+}
+
+// NewAPIServer creates a new API server
+func NewAPIServer(daemon *Daemon) *APIServer {
+	return &APIServer{
+		daemon: daemon,
+		upgrader: websocket.Upgrader{
+			CheckOrigin: func(r *http.Request) bool {
+				return true // Allow all origins for local access
+			},
+		},
+		recentBLE:  make([]interface{}, 0, 100),
+		recentWiFi: make([]interface{}, 0, 100),
+		wsClients:  make(map[*websocket.Conn]bool),
+		sessions:   make(map[string]time.Time),
+	}
+}
+
+// Start starts the HTTP server
+func (s *APIServer) Start(addr string) error {
+	mux := http.NewServeMux()
+
+	// API endpoints
+	mux.HandleFunc("/api/status", s.handleStatus)
+	mux.HandleFunc("/api/metrics", s.handleMetrics)
+	mux.HandleFunc("/api/ble/recent", s.handleBLERecent)
+	mux.HandleFunc("/api/wifi/recent", s.handleWiFiRecent)
+	mux.HandleFunc("/api/config", s.handleConfig)
+	mux.HandleFunc("/api/settings", s.handleSettings)
+	mux.HandleFunc("/api/unlock", s.handleUnlock)
+	mux.HandleFunc("/api/logs", s.handleLogs)
+	mux.HandleFunc("/api/ws", s.handleWebSocket)
+
+	// Serve static files for dashboard
+	mux.Handle("/", http.FileServer(http.Dir("/opt/mybeacon/www")))
+
+	log.Printf("[api] Starting HTTP server on %s", addr)
+	return http.ListenAndServe(addr, s.corsMiddleware(mux))
+}
+
+// corsMiddleware adds CORS headers
+func (s *APIServer) corsMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+
+		if r.Method == "OPTIONS" {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		next.ServeHTTP(w, r)
+	})
+}
+
+// handleStatus returns device status
+func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
+	s.daemon.mu.Lock()
+	defer s.daemon.mu.Unlock()
+
+	eth0rx, eth0tx := getInterfaceStats("eth0")
+	wlan0rx, wlan0tx := getInterfaceStats("wlan0")
+
+	wlanInfo := getWlanInfo()
+
+	// Gateway and DNS are shared, but wlan0 might have its own
+	gateway := getDefaultGateway()
+	dns := getDNS()
+
+	status := StatusResponse{
+		DeviceID:   s.daemon.state.DeviceID,
+		Registered: s.daemon.state.DeviceToken != "",
+		Mode:       "cloud", // TODO: implement mode switching
+		Uptime:     getUptime(),
+		Network: NetworkStatus{
+			Eth0IP:       getInterfaceIP("eth0"),
+			Eth0RX:       eth0rx,
+			Eth0TX:       eth0tx,
+			Wlan0IP:      getInterfaceIP("wlan0"),
+			Wlan0SSID:    wlanInfo.ssid,
+			Wlan0Signal:  wlanInfo.signal,
+			Wlan0Channel: wlanInfo.channel,
+			Wlan0Gateway: gateway, // same gateway for now
+			Wlan0DNS:     dns,     // same DNS for now
+			Wlan0RX:      wlan0rx,
+			Wlan0TX:      wlan0tx,
+			Gateway:      gateway,
+			DNS:          dns,
+			NTP:          "pool.ntp.org",
+		},
+		Scanners: ScannerStatus{
+			BLERunning:  s.daemon.scanners.IsBLERunning(),
+			WiFiRunning: s.daemon.scanners.IsWiFiRunning(),
+		},
+		ServerOK: s.daemon.state.DeviceToken != "",
+	}
+
+	s.jsonResponse(w, status)
+}
+
+// handleMetrics returns system metrics
+func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
+	metrics := MetricsResponse{
+		CPUPercent:  getCPUPercent(),
+		MemUsedMB:   getMemUsedMB(),
+		MemTotalMB:  getMemTotalMB(),
+		Temperature: getTemperature(),
+		LoadAvg:     getLoadAvg(),
+	}
+
+	s.jsonResponse(w, metrics)
+}
+
+// handleBLERecent returns recent BLE events
+func (s *APIServer) handleBLERecent(w http.ResponseWriter, r *http.Request) {
+	s.recentMu.RLock()
+	events := make([]interface{}, len(s.recentBLE))
+	copy(events, s.recentBLE)
+	s.recentMu.RUnlock()
+
+	s.jsonResponse(w, events)
+}
+
+// handleWiFiRecent returns recent WiFi events
+func (s *APIServer) handleWiFiRecent(w http.ResponseWriter, r *http.Request) {
+	s.recentMu.RLock()
+	events := make([]interface{}, len(s.recentWiFi))
+	copy(events, s.recentWiFi)
+	s.recentMu.RUnlock()
+
+	s.jsonResponse(w, events)
+}
+
+// handleLogs returns recent daemon log lines
+func (s *APIServer) handleLogs(w http.ResponseWriter, r *http.Request) {
+	logFile := "/var/log/mybeacon.log"
+	data, err := os.ReadFile(logFile)
+	if err != nil {
+		s.jsonResponse(w, []string{})
+		return
+	}
+
+	lines := strings.Split(string(data), "\n")
+	// Return last 500 lines
+	if len(lines) > 500 {
+		lines = lines[len(lines)-500:]
+	}
+
+	// Filter out empty lines
+	var result []string
+	for _, line := range lines {
+		if strings.TrimSpace(line) != "" {
+			result = append(result, line)
+		}
+	}
+
+	s.jsonResponse(w, result)
+}
+
+// handleConfig returns current config (without secrets)
+func (s *APIServer) handleConfig(w http.ResponseWriter, r *http.Request) {
+	s.daemon.mu.Lock()
+	cfg := *s.daemon.cfg
+	s.daemon.mu.Unlock()
+
+	// Remove secrets
+	cfg.SSHTunnel.KeyPath = ""
+
+	s.jsonResponse(w, cfg)
+}
+
+// handleSettings handles settings updates (requires password)
+func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req SettingsRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	// Verify password
+	if !s.verifyPassword(req.Password) {
+		http.Error(w, "Invalid password", http.StatusUnauthorized)
+		return
+	}
+
+	s.daemon.mu.Lock()
+
+	// Apply mode change
+	if req.Settings.Mode != "" && (req.Settings.Mode == "cloud" || req.Settings.Mode == "lan") {
+		if s.daemon.cfg.Mode != req.Settings.Mode {
+			log.Printf("[api] Mode changed: %s -> %s", s.daemon.cfg.Mode, req.Settings.Mode)
+			s.daemon.cfg.Mode = req.Settings.Mode
+		}
+	}
+
+	// Apply NTP settings
+	if req.Settings.NTPServers != "" {
+		servers := strings.Split(req.Settings.NTPServers, ",")
+		for i := range servers {
+			servers[i] = strings.TrimSpace(servers[i])
+		}
+		s.daemon.cfg.Network.NTPServers = servers
+		log.Printf("[api] NTP servers updated: %v", servers)
+	}
+
+	// Apply eth0 settings (always local, never from server)
+	if req.Settings.Eth0Mode == "static" {
+		s.daemon.cfg.Network.Eth0.Static = true
+		s.daemon.cfg.Network.Eth0.Address = req.Settings.Eth0IP
+		s.daemon.cfg.Network.Eth0.Gateway = req.Settings.Eth0Gateway
+		s.daemon.cfg.Network.Eth0.DNS = req.Settings.Eth0DNS
+		log.Printf("[api] eth0 static IP configured: %s", req.Settings.Eth0IP)
+	} else if req.Settings.Eth0Mode == "dhcp" {
+		s.daemon.cfg.Network.Eth0.Static = false
+		log.Printf("[api] eth0 set to DHCP")
+	}
+
+	// Save local config
+	SaveConfig(s.daemon.configPath, s.daemon.cfg)
+
+	s.daemon.mu.Unlock()
+
+	// Apply WiFi settings if provided (this may take time)
+	if req.Settings.WifiSSID != "" {
+		// Also save to local config
+		s.daemon.mu.Lock()
+		s.daemon.cfg.WiFi.SSID = req.Settings.WifiSSID
+		s.daemon.cfg.WiFi.PSK = req.Settings.WifiPSK
+		s.daemon.cfg.WiFi.ClientEnabled = true
+		SaveConfig(s.daemon.configPath, s.daemon.cfg)
+		s.daemon.mu.Unlock()
+
+		if err := applyWiFiSettings(req.Settings.WifiSSID, req.Settings.WifiPSK); err != nil {
+			log.Printf("[api] Failed to apply WiFi settings: %v", err)
+			http.Error(w, "Failed to apply WiFi settings: "+err.Error(), http.StatusInternalServerError)
+			return
+		}
+		log.Printf("[api] WiFi settings applied: SSID=%s", req.Settings.WifiSSID)
+	}
+
+	s.jsonResponse(w, map[string]bool{"success": true})
+}
+
+// handleUnlock verifies password and creates session
+func (s *APIServer) handleUnlock(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		Password string `json:"password"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	if !s.verifyPassword(req.Password) {
+		http.Error(w, "Invalid password", http.StatusUnauthorized)
+		return
+	}
+
+	// Create session token (simple implementation)
+	token := generateToken(32)
+	s.sessionsMu.Lock()
+	s.sessions[token] = time.Now().Add(30 * time.Minute)
+	s.sessionsMu.Unlock()
+
+	s.jsonResponse(w, map[string]string{"token": token})
+}
+
+// handleWebSocket handles WebSocket connections for live updates
+func (s *APIServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
+	conn, err := s.upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		log.Printf("[api] WebSocket upgrade error: %v", err)
+		return
+	}
+	defer conn.Close()
+
+	s.wsClientsMu.Lock()
+	s.wsClients[conn] = true
+	s.wsClientsMu.Unlock()
+
+	defer func() {
+		s.wsClientsMu.Lock()
+		delete(s.wsClients, conn)
+		s.wsClientsMu.Unlock()
+	}()
+
+	// Keep connection alive and read messages
+	for {
+		_, _, err := conn.ReadMessage()
+		if err != nil {
+			break
+		}
+	}
+}
+
+// AddBLEEvent adds a BLE event to recent list and broadcasts to WebSocket
+func (s *APIServer) AddBLEEvent(event interface{}) {
+	s.recentMu.Lock()
+	s.recentBLE = append(s.recentBLE, event)
+	if len(s.recentBLE) > 100 {
+		s.recentBLE = s.recentBLE[1:]
+	}
+	s.recentMu.Unlock()
+
+	s.broadcast(map[string]interface{}{
+		"type":  "ble",
+		"event": event,
+	})
+}
+
+// AddWiFiEvent adds a WiFi event to recent list and broadcasts to WebSocket
+func (s *APIServer) AddWiFiEvent(event interface{}) {
+	s.recentMu.Lock()
+	s.recentWiFi = append(s.recentWiFi, event)
+	if len(s.recentWiFi) > 100 {
+		s.recentWiFi = s.recentWiFi[1:]
+	}
+	s.recentMu.Unlock()
+
+	s.broadcast(map[string]interface{}{
+		"type":  "wifi",
+		"event": event,
+	})
+}
+
+// broadcast sends a message to all WebSocket clients
+func (s *APIServer) broadcast(msg interface{}) {
+	data, err := json.Marshal(msg)
+	if err != nil {
+		return
+	}
+
+	s.wsClientsMu.Lock()
+	defer s.wsClientsMu.Unlock()
+
+	for conn := range s.wsClients {
+		if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
+			conn.Close()
+			delete(s.wsClients, conn)
+		}
+	}
+}
+
+// verifyPassword checks if password matches device password
+func (s *APIServer) verifyPassword(password string) bool {
+	s.daemon.mu.Lock()
+	devicePassword := s.daemon.state.DevicePassword
+	s.daemon.mu.Unlock()
+
+	// Use default password "admin" if no password set (device not registered)
+	if devicePassword == "" {
+		devicePassword = "admin"
+	}
+
+	return password != "" && password == devicePassword
+}
+
+// jsonResponse sends a JSON response
+func (s *APIServer) jsonResponse(w http.ResponseWriter, data interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(data)
+}
+
+// Helper functions for system metrics
+
+func getUptime() int64 {
+	data, err := os.ReadFile("/proc/uptime")
+	if err != nil {
+		return 0
+	}
+	var uptime float64
+	if err := parseFloat(string(data), &uptime); err != nil {
+		return 0
+	}
+	return int64(uptime)
+}
+
+func parseFloat(s string, f *float64) error {
+	parts := strings.Fields(s)
+	if len(parts) == 0 {
+		return nil
+	}
+	return json.Unmarshal([]byte(parts[0]), f)
+}
+
+func getCPUPercent() float64 {
+	// Simplified - read from /proc/stat
+	return 0 // TODO: implement proper CPU usage
+}
+
+func getMemUsedMB() float64 {
+	var m runtime.MemStats
+	runtime.ReadMemStats(&m)
+	return float64(m.Alloc) / 1024 / 1024
+}
+
+func getMemTotalMB() float64 {
+	data, err := os.ReadFile("/proc/meminfo")
+	if err != nil {
+		return 0
+	}
+	scanner := bufio.NewScanner(strings.NewReader(string(data)))
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "MemTotal:") {
+			var total int64
+			if err := parseMemInfo(line, &total); err == nil {
+				return float64(total) / 1024
+			}
+		}
+	}
+	return 0
+}
+
+func parseMemInfo(line string, value *int64) error {
+	parts := strings.Fields(line)
+	if len(parts) >= 2 {
+		return json.Unmarshal([]byte(parts[1]), value)
+	}
+	return nil
+}
+
+func getTemperature() float64 {
+	data, err := os.ReadFile("/sys/class/thermal/thermal_zone0/temp")
+	if err != nil {
+		return 0
+	}
+	var temp int64
+	if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &temp); err != nil {
+		return 0
+	}
+	return float64(temp) / 1000
+}
+
+func getLoadAvg() float64 {
+	data, err := os.ReadFile("/proc/loadavg")
+	if err != nil {
+		return 0
+	}
+	var load float64
+	if err := parseFloat(string(data), &load); err != nil {
+		return 0
+	}
+	return load
+}
+
+func generateToken(length int) string {
+	const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	b := make([]byte, length)
+	for i := range b {
+		b[i] = chars[time.Now().UnixNano()%int64(len(chars))]
+		time.Sleep(time.Nanosecond)
+	}
+	return string(b)
+}
+
+func getInterfaceStats(name string) (rx, tx int64) {
+	rxPath := "/sys/class/net/" + name + "/statistics/rx_bytes"
+	txPath := "/sys/class/net/" + name + "/statistics/tx_bytes"
+
+	if data, err := os.ReadFile(rxPath); err == nil {
+		json.Unmarshal([]byte(strings.TrimSpace(string(data))), &rx)
+	}
+	if data, err := os.ReadFile(txPath); err == nil {
+		json.Unmarshal([]byte(strings.TrimSpace(string(data))), &tx)
+	}
+	return
+}
+
+func getDefaultGateway() string {
+	data, err := os.ReadFile("/proc/net/route")
+	if err != nil {
+		return ""
+	}
+	scanner := bufio.NewScanner(strings.NewReader(string(data)))
+	for scanner.Scan() {
+		fields := strings.Fields(scanner.Text())
+		if len(fields) >= 3 && fields[1] == "00000000" {
+			gw := fields[2]
+			if len(gw) == 8 {
+				b, _ := hex.DecodeString(gw)
+				if len(b) == 4 {
+					// Little-endian: reverse bytes
+					return fmt.Sprintf("%d.%d.%d.%d", b[3], b[2], b[1], b[0])
+				}
+			}
+		}
+	}
+	return ""
+}
+
+func getDNS() string {
+	data, err := os.ReadFile("/etc/resolv.conf")
+	if err != nil {
+		return ""
+	}
+	var servers []string
+	scanner := bufio.NewScanner(strings.NewReader(string(data)))
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "nameserver ") {
+			servers = append(servers, strings.TrimPrefix(line, "nameserver "))
+		}
+	}
+	return strings.Join(servers, ", ")
+}
+
+type wlanInfoResult struct {
+	ssid    string
+	signal  int
+	channel int
+}
+
+func getWlanInfo() wlanInfoResult {
+	var info wlanInfoResult
+
+	// Get SSID, signal, channel from iw dev wlan0 link
+	out, err := exec.Command("iw", "dev", "wlan0", "link").Output()
+	if err == nil {
+		scanner := bufio.NewScanner(strings.NewReader(string(out)))
+		for scanner.Scan() {
+			line := strings.TrimSpace(scanner.Text())
+			if strings.HasPrefix(line, "SSID:") {
+				info.ssid = strings.TrimSpace(strings.TrimPrefix(line, "SSID:"))
+			} else if strings.HasPrefix(line, "signal:") {
+				// signal: -30 dBm
+				parts := strings.Fields(line)
+				if len(parts) >= 2 {
+					if v, err := strconv.Atoi(parts[1]); err == nil {
+						info.signal = v
+					}
+				}
+			} else if strings.HasPrefix(line, "freq:") {
+				// freq: 2462
+				parts := strings.Fields(line)
+				if len(parts) >= 2 {
+					if f, err := strconv.Atoi(parts[1]); err == nil {
+						info.channel = freqToChannel(f)
+					}
+				}
+			}
+		}
+	}
+
+	return info
+}
+
+// freqToChannel converts WiFi frequency (MHz) to channel number
+func freqToChannel(freq int) int {
+	if freq >= 2412 && freq <= 2484 {
+		if freq == 2484 {
+			return 14
+		}
+		return (freq - 2407) / 5
+	}
+	if freq >= 5170 && freq <= 5825 {
+		return (freq - 5000) / 5
+	}
+	return 0
+}
+
+// applyWiFiSettings configures wpa_supplicant and connects to WiFi
+func applyWiFiSettings(ssid, psk string) error {
+	// Retry the whole connection up to 3 times
+	var lastErr error
+	for attempt := 1; attempt <= 3; attempt++ {
+		log.Printf("[wifi] Connection attempt %d/3 to SSID=%s", attempt, ssid)
+
+		if err := tryWiFiConnect(ssid, psk); err != nil {
+			lastErr = err
+			log.Printf("[wifi] Attempt %d failed: %v", attempt, err)
+			if attempt < 3 {
+				log.Printf("[wifi] Waiting 5s before retry...")
+				time.Sleep(5 * time.Second)
+			}
+			continue
+		}
+
+		log.Println("[wifi] WiFi client connected successfully")
+		return nil
+	}
+
+	return fmt.Errorf("all connection attempts failed: %w", lastErr)
+}
+
+func tryWiFiConnect(ssid, psk string) error {
+	// Use external shell script for WiFi connection (more reliable)
+	scriptPath := "/opt/mybeacon/bin/wifi-connect.sh"
+
+	cmd := exec.Command(scriptPath, ssid, psk)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("wifi-connect.sh failed: %w (output: %s)", err, string(output))
+	}
+
+	log.Printf("[wifi] %s", strings.TrimSpace(string(output)))
+
+	// Ensure wlan0 is configured in /etc/network/interfaces (for manual use)
+	ensureWlan0Interface()
+
+	return nil
+}
+
+func ensureWlan0Interface() error {
+	interfacesPath := "/etc/network/interfaces"
+	data, err := os.ReadFile(interfacesPath)
+	if err != nil {
+		return err
+	}
+
+	content := string(data)
+
+	// Check if wlan0 is already configured (not commented out)
+	lines := strings.Split(content, "\n")
+	for _, line := range lines {
+		trimmed := strings.TrimSpace(line)
+		if !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "iface wlan0 inet") {
+			return nil // Already configured
+		}
+	}
+
+	// Add wlan0 configuration
+	wlan0Config := `
+# WiFi (managed by beacon-daemon)
+auto wlan0
+iface wlan0 inet dhcp
+    pre-up wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant/wpa_supplicant.conf
+    post-down killall wpa_supplicant 2>/dev/null || true
+`
+
+	if err := os.WriteFile(interfacesPath, []byte(content+wlan0Config), 0644); err != nil {
+		return err
+	}
+
+	log.Println("[wifi] Added wlan0 configuration to /etc/network/interfaces")
+	return nil
+}
+
+// disconnectWiFiClient disconnects from WiFi and stops wpa_supplicant
+func disconnectWiFiClient() {
+	log.Println("[wifi] Disconnecting WiFi client...")
+	exec.Command("killall", "wpa_supplicant", "udhcpc", "dhcpcd").Run()
+	exec.Command("ip", "addr", "flush", "dev", "wlan0").Run()
+	os.RemoveAll("/var/run/wpa_supplicant/wlan0")
+	log.Println("[wifi] WiFi client disconnected")
+}

+ 210 - 0
cmd/beacon-daemon/client.go

@@ -0,0 +1,210 @@
+package main
+
+import (
+	"bytes"
+	"compress/gzip"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+// APIClient handles communication with the server
+type APIClient struct {
+	baseURL    string
+	token      string
+	httpClient *http.Client
+}
+
+// NewAPIClient creates a new API client
+func NewAPIClient(baseURL string) *APIClient {
+	return &APIClient{
+		baseURL: baseURL,
+		httpClient: &http.Client{
+			Timeout: 30 * time.Second,
+		},
+	}
+}
+
+// SetToken sets the authentication token
+func (c *APIClient) SetToken(token string) {
+	c.token = token
+}
+
+// RegistrationRequest is sent to register a device
+type RegistrationRequest struct {
+	DeviceID string  `json:"device_id"`
+	EthIP    *string `json:"eth_ip,omitempty"`
+	WlanIP   *string `json:"wlan_ip,omitempty"`
+}
+
+// RegistrationResponse is returned from registration
+type RegistrationResponse struct {
+	DeviceToken    string `json:"device_token"`
+	DevicePassword string `json:"device_password,omitempty"`
+	SSHTunnel      struct {
+		Enabled    bool   `json:"enabled"`
+		RemotePort int    `json:"remote_port"`
+		Server     string `json:"server"`
+	} `json:"ssh_tunnel,omitempty"`
+}
+
+// Register registers a device with the server
+func (c *APIClient) Register(req *RegistrationRequest) (*RegistrationResponse, error) {
+	body, err := json.Marshal(req)
+	if err != nil {
+		return nil, err
+	}
+
+	httpReq, err := http.NewRequest("POST", c.baseURL+"/registration", bytes.NewReader(body))
+	if err != nil {
+		return nil, err
+	}
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.httpClient.Do(httpReq)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 201 && resp.StatusCode != 200 {
+		respBody, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("registration failed: %d %s", resp.StatusCode, string(respBody))
+	}
+
+	var regResp RegistrationResponse
+	if err := json.NewDecoder(resp.Body).Decode(&regResp); err != nil {
+		return nil, err
+	}
+
+	return &regResp, nil
+}
+
+// ServerConfig is the configuration returned by the server
+type ServerConfig struct {
+	// ForceCloud overrides local mode setting - for remote support
+	ForceCloud bool `json:"force_cloud"`
+
+	BLE struct {
+		Enabled         bool   `json:"enabled"`
+		BatchIntervalMs int    `json:"batch_interval_ms"`
+		UUIDFilterHex   string `json:"uuid_filter_hex,omitempty"`
+	} `json:"ble"`
+	WiFi struct {
+		MonitorEnabled  bool   `json:"monitor_enabled"`
+		ClientEnabled   bool   `json:"client_enabled"`
+		SSID            string `json:"ssid"`
+		PSK             string `json:"psk"`
+		BatchIntervalMs int    `json:"batch_interval_ms"`
+	} `json:"wifi"`
+	SSHTunnel struct {
+		Enabled           bool   `json:"enabled"`
+		Server            string `json:"server"`
+		Port              int    `json:"port"`
+		User              string `json:"user"`
+		RemotePort        int    `json:"remote_port"`
+		KeepaliveInterval int    `json:"keepalive_interval"`
+	} `json:"ssh_tunnel"`
+	Net struct {
+		NTP struct {
+			Servers []string `json:"servers"`
+		} `json:"ntp"`
+	} `json:"net"`
+	Debug bool `json:"debug"`
+}
+
+// GetConfig fetches configuration from the server
+func (c *APIClient) GetConfig(deviceID string) (*ServerConfig, error) {
+	httpReq, err := http.NewRequest("GET", c.baseURL+"/config", nil)
+	if err != nil {
+		return nil, err
+	}
+
+	q := httpReq.URL.Query()
+	q.Set("device_id", deviceID)
+	httpReq.URL.RawQuery = q.Encode()
+
+	if c.token != "" {
+		httpReq.Header.Set("Authorization", "Bearer "+c.token)
+	}
+
+	resp, err := c.httpClient.Do(httpReq)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return nil, fmt.Errorf("config fetch failed: %d", resp.StatusCode)
+	}
+
+	var cfg ServerConfig
+	if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
+		return nil, err
+	}
+
+	return &cfg, nil
+}
+
+// EventBatch is a batch of events to upload
+type EventBatch struct {
+	DeviceID string        `json:"device_id"`
+	Events   []interface{} `json:"events"`
+}
+
+// UploadEvents uploads a batch of events (gzipped)
+func (c *APIClient) UploadEvents(endpoint string, batch *EventBatch) error {
+	// Serialize to JSON
+	jsonData, err := json.Marshal(batch)
+	if err != nil {
+		return err
+	}
+
+	// Gzip compress
+	var buf bytes.Buffer
+	gw := gzip.NewWriter(&buf)
+	if _, err := gw.Write(jsonData); err != nil {
+		return err
+	}
+	if err := gw.Close(); err != nil {
+		return err
+	}
+
+	// Store compressed data for retries
+	compressedData := buf.Bytes()
+	url := c.baseURL + endpoint
+
+	// Send with retries
+	var lastErr error
+	for attempt := 1; attempt <= 3; attempt++ {
+		// Create fresh request for each attempt (body can only be read once)
+		httpReq, err := http.NewRequest("POST", url, bytes.NewReader(compressedData))
+		if err != nil {
+			return err
+		}
+
+		httpReq.Header.Set("Content-Type", "application/json")
+		httpReq.Header.Set("Content-Encoding", "gzip")
+		if c.token != "" {
+			httpReq.Header.Set("Authorization", "Bearer "+c.token)
+		}
+
+		resp, err := c.httpClient.Do(httpReq)
+		if err != nil {
+			lastErr = err
+			time.Sleep(time.Duration(attempt) * time.Second)
+			continue
+		}
+		resp.Body.Close()
+
+		if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+			return nil
+		}
+		lastErr = fmt.Errorf("upload failed: %d", resp.StatusCode)
+		time.Sleep(time.Duration(attempt) * time.Second)
+	}
+
+	return lastErr
+}

+ 160 - 0
cmd/beacon-daemon/config.go

@@ -0,0 +1,160 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+)
+
+// Config holds the daemon configuration
+type Config struct {
+	// Mode: "cloud" (server settings priority) or "lan" (local settings priority)
+	Mode string `json:"mode"`
+
+	// Server settings
+	APIBase string `json:"api_base"`
+
+	// Device identity (persisted)
+	DeviceID    string `json:"device_id,omitempty"`
+	DeviceToken string `json:"device_token,omitempty"`
+
+	// BLE settings
+	BLE struct {
+		Enabled         bool `json:"enabled"`
+		BatchIntervalMs int  `json:"batch_interval_ms"`
+	} `json:"ble"`
+
+	// WiFi settings
+	WiFi struct {
+		MonitorEnabled  bool   `json:"monitor_enabled"`
+		ClientEnabled   bool   `json:"client_enabled"`
+		SSID            string `json:"ssid"`
+		PSK             string `json:"psk"`
+		BatchIntervalMs int    `json:"batch_interval_ms"`
+	} `json:"wifi"`
+
+	// SSH Tunnel settings
+	SSHTunnel struct {
+		Enabled           bool   `json:"enabled"`
+		Server            string `json:"server"`
+		Port              int    `json:"port"`
+		User              string `json:"user"`
+		KeyPath           string `json:"key_path"`
+		RemotePort        int    `json:"remote_port"`
+		KeepaliveInterval int    `json:"keepalive_interval"`
+		ReconnectDelay    int    `json:"reconnect_delay"`
+	} `json:"ssh_tunnel"`
+
+	// Network settings (eth0 is ALWAYS local, never from server)
+	Network struct {
+		NTPServers []string `json:"ntp_servers"`
+		Eth0       struct {
+			Static  bool   `json:"static"`
+			Address string `json:"address"`
+			Gateway string `json:"gateway"`
+			DNS     string `json:"dns"`
+		} `json:"eth0"`
+	} `json:"network"`
+
+	// Local-only settings (never from server)
+	ZMQAddrBLE  string `json:"zmq_addr_ble"`
+	ZMQAddrWiFi string `json:"zmq_addr_wifi"`
+	SpoolDir    string `json:"spool_dir"`
+	WiFiIface   string `json:"wifi_iface"`
+	Debug       bool   `json:"debug"`
+}
+
+// DefaultConfig returns a configuration with default values
+func DefaultConfig() *Config {
+	cfg := &Config{
+		Mode:        "cloud",
+		APIBase:     "http://localhost:5000/api/v1",
+		ZMQAddrBLE:  "tcp://127.0.0.1:5555",
+		ZMQAddrWiFi: "tcp://127.0.0.1:5556",
+		SpoolDir:    "/var/spool/mybeacon",
+	}
+	cfg.BLE.Enabled = true
+	cfg.BLE.BatchIntervalMs = 2500
+	cfg.WiFi.MonitorEnabled = false
+	cfg.WiFi.BatchIntervalMs = 10000
+	cfg.SSHTunnel.Port = 22
+	cfg.SSHTunnel.KeepaliveInterval = 30
+	cfg.SSHTunnel.ReconnectDelay = 5
+	cfg.Network.NTPServers = []string{"pool.ntp.org"}
+	return cfg
+}
+
+// LoadConfig loads configuration from a JSON file
+func LoadConfig(path string) (*Config, error) {
+	cfg := DefaultConfig()
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return cfg, nil // Use defaults
+		}
+		return nil, err
+	}
+
+	if err := json.Unmarshal(data, cfg); err != nil {
+		return nil, err
+	}
+
+	return cfg, nil
+}
+
+// SaveConfig saves configuration to a JSON file
+func SaveConfig(path string, cfg *Config) error {
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0600)
+}
+
+// DeviceState holds persistent device state
+type DeviceState struct {
+	DeviceID       string `json:"device_id"`
+	DeviceToken    string `json:"device_token"`
+	DevicePassword string `json:"device_password,omitempty"`
+}
+
+// LoadDeviceState loads device state from file
+func LoadDeviceState(path string) (*DeviceState, error) {
+	state := &DeviceState{}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return state, nil
+		}
+		return nil, err
+	}
+
+	if err := json.Unmarshal(data, state); err != nil {
+		return nil, err
+	}
+
+	return state, nil
+}
+
+// SaveDeviceState saves device state to file
+func SaveDeviceState(path string, state *DeviceState) error {
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(state, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0600)
+}

+ 701 - 0
cmd/beacon-daemon/main.go

@@ -0,0 +1,701 @@
+// Beacon Daemon - collects events from scanners and uploads to server
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"os/signal"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"github.com/go-zeromq/zmq4"
+)
+
+const (
+	defaultConfigPath  = "/opt/mybeacon/etc/config.json"
+	defaultStatePath   = "/opt/mybeacon/etc/device.json"
+	defaultBinDir      = "/opt/mybeacon/bin"
+	defaultWiFiIface   = "wlan0"
+	maxSpoolBytes      = 100 * 1024 * 1024 // 100 MB
+)
+
+type Daemon struct {
+	cfg       *Config
+	state     *DeviceState
+	client    *APIClient
+	spooler   *Spooler
+	tunnel    *SSHTunnel
+	scanners  *ScannerManager
+	api       *APIServer
+	netmgr    *NetworkManager
+
+	bleEvents  []interface{}
+	wifiEvents []interface{}
+	mu         sync.Mutex
+
+	configPath string
+	statePath  string
+
+	stopChan chan struct{}
+}
+
+func main() {
+	var (
+		configPath = flag.String("config", defaultConfigPath, "Config file path")
+		statePath  = flag.String("state", defaultStatePath, "Device state file path")
+		serverAddr = flag.String("server", "", "API server address (e.g., http://192.168.5.2:5000)")
+		binDir     = flag.String("bindir", defaultBinDir, "Directory with scanner binaries")
+		wifiIface  = flag.String("wifi-iface", defaultWiFiIface, "WiFi interface for monitor mode")
+		httpAddr   = flag.String("http", ":8080", "HTTP API listen address")
+		debug      = flag.Bool("debug", false, "Enable debug logging")
+	)
+	flag.Parse()
+
+	log.SetFlags(log.Ltime)
+	log.Println("================================================================================")
+	log.Println("Beacon Daemon starting...")
+
+	// Load configuration
+	cfg, err := LoadConfig(*configPath)
+	if err != nil {
+		log.Printf("Warning: failed to load config: %v (using defaults)", err)
+		cfg = DefaultConfig()
+	}
+	cfg.Debug = *debug || cfg.Debug
+
+	// Override server address if provided
+	if *serverAddr != "" {
+		cfg.APIBase = *serverAddr + "/api/v1"
+		log.Printf("Using server: %s", *serverAddr)
+	}
+
+	// Store WiFi interface
+	cfg.WiFiIface = *wifiIface
+
+	// Load device state
+	state, err := LoadDeviceState(*statePath)
+	if err != nil {
+		log.Printf("Warning: failed to load state: %v", err)
+		state = &DeviceState{}
+	}
+
+	// Get device ID from MAC if not set
+	if state.DeviceID == "" {
+		state.DeviceID = getDeviceID()
+		SaveDeviceState(*statePath, state)
+	}
+	log.Printf("Device ID: %s", state.DeviceID)
+
+	// Create spooler
+	spooler, err := NewSpooler(cfg.SpoolDir, maxSpoolBytes)
+	if err != nil {
+		log.Fatalf("Failed to create spooler: %v", err)
+	}
+
+	// Create SSH tunnel manager
+	tunnel := NewSSHTunnel(cfg)
+
+	// Create scanner manager
+	scanners := NewScannerManager(*binDir, cfg.Debug)
+
+	// Create network manager (manages eth0, wlan0 client, wlan0 AP fallback)
+	netmgr := NewNetworkManager(cfg, scanners)
+
+	// Create daemon
+	daemon := &Daemon{
+		cfg:        cfg,
+		state:      state,
+		client:     NewAPIClient(cfg.APIBase),
+		spooler:    spooler,
+		tunnel:     tunnel,
+		scanners:   scanners,
+		netmgr:     netmgr,
+		configPath: *configPath,
+		statePath:  *statePath,
+		stopChan:   make(chan struct{}),
+	}
+
+	// Create API server
+	daemon.api = NewAPIServer(daemon)
+
+	if state.DeviceToken != "" {
+		daemon.client.SetToken(state.DeviceToken)
+	}
+
+	// Handle signals
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+	go func() {
+		<-sigChan
+		log.Println("Shutting down...")
+		daemon.scanners.StopAll()
+		daemon.tunnel.Stop()
+		daemon.netmgr.Stop()
+		close(daemon.stopChan)
+	}()
+
+	// Start HTTP API server FIRST (dashboard should be available immediately)
+	go func() {
+		log.Printf("Starting HTTP API server on %s", *httpAddr)
+		if err := daemon.api.Start(*httpAddr); err != nil {
+			log.Printf("HTTP server error: %v", err)
+		}
+	}()
+	// Give HTTP server time to bind
+	time.Sleep(100 * time.Millisecond)
+
+	// Start network manager (manages eth0, wlan0 client, wlan0 AP fallback, WiFi scanner)
+	// Network manager will automatically handle all network priorities and scanner coordination
+	daemon.netmgr.Start()
+
+	// Start SSH tunnel if enabled
+	if cfg.SSHTunnel.Enabled {
+		daemon.tunnel.Start()
+	}
+
+	// Start BLE scanner if enabled (not managed by network manager)
+	if cfg.BLE.Enabled {
+		if !daemon.scanners.IsBLERunning() {
+			log.Println("Starting BLE scanner...")
+			if err := daemon.scanners.StartBLE(cfg.ZMQAddrBLE); err != nil {
+				log.Printf("Failed to start BLE scanner: %v", err)
+			}
+		}
+	}
+
+	// Start registration loop (if not registered)
+	go daemon.registrationLoop()
+
+	// Start config polling loop
+	go daemon.configLoop()
+
+	// Start ZMQ subscribers
+	go daemon.subscribeLoop("ble", cfg.ZMQAddrBLE)
+	go daemon.subscribeLoop("wifi", cfg.ZMQAddrWiFi)
+
+	// Start batch upload loops
+	go daemon.uploadLoop("ble", "/ble", cfg.BLE.BatchIntervalMs)
+	go daemon.uploadLoop("wifi", "/wifi", cfg.WiFi.BatchIntervalMs)
+
+	// Start spool flush loop
+	go daemon.spoolFlushLoop()
+
+	// Wait for shutdown
+	<-daemon.stopChan
+	log.Println("Daemon stopped")
+}
+
+// applyScannerConfig starts/stops scanners based on current config
+func (d *Daemon) applyScannerConfig() {
+	d.mu.Lock()
+	bleEnabled := d.cfg.BLE.Enabled
+	wifiMonitorEnabled := d.cfg.WiFi.MonitorEnabled
+	wifiClientEnabled := d.cfg.WiFi.ClientEnabled
+	zmqBLE := d.cfg.ZMQAddrBLE
+	zmqWiFi := d.cfg.ZMQAddrWiFi
+	wifiIface := d.cfg.WiFiIface
+	d.mu.Unlock()
+
+	// BLE scanner
+	if bleEnabled {
+		if !d.scanners.IsBLERunning() {
+			log.Println("Starting BLE scanner...")
+			if err := d.scanners.StartBLE(zmqBLE); err != nil {
+				log.Printf("Failed to start BLE scanner: %v", err)
+			}
+		}
+	} else {
+		if d.scanners.IsBLERunning() {
+			log.Println("Stopping BLE scanner...")
+			d.scanners.StopBLE()
+		}
+	}
+
+	// WiFi scanner - NEVER run if WiFi client is enabled (they share wlan0)
+	// Client mode takes priority over monitor mode
+	if wifiClientEnabled {
+		if d.scanners.IsWiFiRunning() {
+			log.Println("Stopping WiFi scanner (client mode enabled in config)...")
+			d.scanners.StopWiFi()
+		}
+		return
+	}
+
+	// WiFi scanner - only if monitor enabled AND client disabled
+	// Client has HIGHER priority - if both enabled, client works, scanner doesn't
+	if wifiMonitorEnabled {
+		if !d.scanners.IsWiFiRunning() {
+			log.Println("Starting WiFi scanner...")
+			if err := d.scanners.StartWiFi(zmqWiFi, wifiIface); err != nil {
+				log.Printf("Failed to start WiFi scanner: %v", err)
+			}
+		}
+	} else {
+		if d.scanners.IsWiFiRunning() {
+			log.Println("Stopping WiFi scanner (monitor disabled)...")
+			d.scanners.StopWiFi()
+		}
+	}
+}
+
+// applyWiFiClientState checks if WiFi client state matches config and applies changes
+func (d *Daemon) applyWiFiClientState() {
+	d.mu.Lock()
+	clientEnabled := d.cfg.WiFi.ClientEnabled
+	ssid := d.cfg.WiFi.SSID
+	psk := d.cfg.WiFi.PSK
+	d.mu.Unlock()
+
+	// Check if wlan0 has an IP (meaning it's connected)
+	wlan0IP := getInterfaceIP("wlan0")
+	isConnected := wlan0IP != ""
+
+	if clientEnabled && ssid != "" {
+		// Should be connected
+		if !isConnected {
+			// Stop WiFi scanner before connecting client (they can't coexist)
+			if d.scanners.IsWiFiRunning() {
+				log.Println("Stopping WiFi scanner before connecting client...")
+				d.scanners.StopWiFi()
+			}
+
+			log.Printf("WiFi client should be connected (SSID=%s), connecting...", ssid)
+			if err := applyWiFiSettings(ssid, psk); err != nil {
+				log.Printf("Failed to connect WiFi client: %v", err)
+			}
+		}
+	} else {
+		// Should be disconnected
+		if isConnected {
+			log.Println("WiFi client should be disconnected, disconnecting...")
+			disconnectWiFiClient()
+		}
+	}
+}
+
+func (d *Daemon) registrationLoop() {
+	for {
+		select {
+		case <-d.stopChan:
+			return
+		default:
+		}
+
+		if d.state.DeviceToken != "" {
+			time.Sleep(60 * time.Second)
+			continue
+		}
+
+		log.Println("Attempting device registration...")
+		req := &RegistrationRequest{
+			DeviceID: d.state.DeviceID,
+		}
+
+		// Try to get IPs
+		if ip := getInterfaceIP("eth0"); ip != "" {
+			req.EthIP = &ip
+		}
+		if ip := getInterfaceIP("wlan0"); ip != "" {
+			req.WlanIP = &ip
+		}
+
+		resp, err := d.client.Register(req)
+		if err != nil {
+			log.Printf("Registration failed: %v", err)
+			time.Sleep(10 * time.Second)
+			continue
+		}
+
+		d.state.DeviceToken = resp.DeviceToken
+		d.state.DevicePassword = resp.DevicePassword
+		d.client.SetToken(resp.DeviceToken)
+		SaveDeviceState(d.statePath, d.state)
+		log.Printf("Device registered, token received")
+	}
+}
+
+func (d *Daemon) configLoop() {
+	// Initial fetch immediately
+	d.fetchAndApplyConfig()
+
+	ticker := time.NewTicker(30 * time.Second)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-d.stopChan:
+			return
+		case <-ticker.C:
+		}
+
+		d.fetchAndApplyConfig()
+	}
+}
+
+func (d *Daemon) fetchAndApplyConfig() {
+	if d.state.DeviceToken == "" {
+		return
+	}
+
+	serverCfg, err := d.client.GetConfig(d.state.DeviceID)
+	if err != nil {
+		// In LAN mode, suppress errors (server might be unreachable)
+		if d.cfg.Mode == "lan" {
+			// Silent - use local config
+			return
+		}
+		if d.cfg.Debug {
+			log.Printf("Config fetch failed: %v", err)
+		}
+		return
+	}
+
+	// Determine effective mode: force_cloud overrides local mode setting
+	effectiveMode := d.cfg.Mode
+	if effectiveMode == "" {
+		effectiveMode = "cloud"
+	}
+	if serverCfg.ForceCloud {
+		if effectiveMode == "lan" {
+			log.Println("Server force_cloud enabled - switching to cloud mode")
+		}
+		effectiveMode = "cloud"
+	}
+
+	d.mu.Lock()
+
+	// Track changes for scanner restart
+	bleChanged := false
+	wifiMonitorChanged := false
+	wifiClientChanged := false
+	oldClientEnabled := d.cfg.WiFi.ClientEnabled
+
+	if effectiveMode == "cloud" {
+		// Cloud mode: server settings have priority
+		bleChanged = d.cfg.BLE.Enabled != serverCfg.BLE.Enabled
+		wifiMonitorChanged = d.cfg.WiFi.MonitorEnabled != serverCfg.WiFi.MonitorEnabled
+		wifiClientChanged = d.cfg.WiFi.ClientEnabled != serverCfg.WiFi.ClientEnabled ||
+			d.cfg.WiFi.SSID != serverCfg.WiFi.SSID ||
+			d.cfg.WiFi.PSK != serverCfg.WiFi.PSK
+
+		d.cfg.BLE.Enabled = serverCfg.BLE.Enabled
+		d.cfg.BLE.BatchIntervalMs = serverCfg.BLE.BatchIntervalMs
+		d.cfg.WiFi.MonitorEnabled = serverCfg.WiFi.MonitorEnabled
+		d.cfg.WiFi.ClientEnabled = serverCfg.WiFi.ClientEnabled
+		d.cfg.WiFi.SSID = serverCfg.WiFi.SSID
+		d.cfg.WiFi.PSK = serverCfg.WiFi.PSK
+		d.cfg.WiFi.BatchIntervalMs = serverCfg.WiFi.BatchIntervalMs
+		d.cfg.Debug = serverCfg.Debug
+
+		// NTP from server in cloud mode
+		if len(serverCfg.Net.NTP.Servers) > 0 {
+			d.cfg.Network.NTPServers = serverCfg.Net.NTP.Servers
+		}
+	}
+	// LAN mode: local settings have priority, we keep what's in d.cfg
+	// Only SSH tunnel comes from server (for remote support)
+
+	// SSH tunnel ALWAYS from server (for remote support access)
+	sshChanged := d.cfg.SSHTunnel.Enabled != serverCfg.SSHTunnel.Enabled
+	if serverCfg.SSHTunnel.Enabled {
+		d.cfg.SSHTunnel.Enabled = true
+		d.cfg.SSHTunnel.Server = serverCfg.SSHTunnel.Server
+		d.cfg.SSHTunnel.Port = serverCfg.SSHTunnel.Port
+		d.cfg.SSHTunnel.User = serverCfg.SSHTunnel.User
+		d.cfg.SSHTunnel.RemotePort = serverCfg.SSHTunnel.RemotePort
+		d.cfg.SSHTunnel.KeepaliveInterval = serverCfg.SSHTunnel.KeepaliveInterval
+	} else {
+		d.cfg.SSHTunnel.Enabled = false
+	}
+
+	d.mu.Unlock()
+
+	// Apply scanner config changes
+	if bleChanged || wifiMonitorChanged {
+		log.Printf("Config changed (mode=%s): ble=%v wifi_monitor=%v", effectiveMode, d.cfg.BLE.Enabled, d.cfg.WiFi.MonitorEnabled)
+		d.applyScannerConfig()
+	}
+
+	// Apply WiFi client changes
+	if wifiClientChanged {
+		if d.cfg.WiFi.ClientEnabled && d.cfg.WiFi.SSID != "" {
+			// Stop WiFi scanner before connecting client
+			if d.scanners.IsWiFiRunning() {
+				log.Println("Stopping WiFi scanner before connecting client...")
+				d.scanners.StopWiFi()
+			}
+
+			log.Printf("WiFi client enabled by server: SSID=%s", d.cfg.WiFi.SSID)
+			if err := applyWiFiSettings(d.cfg.WiFi.SSID, d.cfg.WiFi.PSK); err != nil {
+				log.Printf("Failed to apply WiFi client settings: %v", err)
+			}
+			// After client connects, update scanner config (will skip WiFi scanner)
+			d.applyScannerConfig()
+		} else if oldClientEnabled && !d.cfg.WiFi.ClientEnabled {
+			log.Println("WiFi client disabled by server")
+			disconnectWiFiClient()
+			// After client disconnects, WiFi scanner may start (if monitor enabled)
+			d.applyScannerConfig()
+		}
+	}
+
+	// Update tunnel config
+	d.tunnel.UpdateConfig(d.cfg)
+	if sshChanged {
+		if d.cfg.SSHTunnel.Enabled {
+			log.Println("SSH tunnel enabled by server")
+			d.tunnel.Start()
+		} else {
+			log.Println("SSH tunnel disabled by server")
+			d.tunnel.Stop()
+		}
+	}
+
+	// Update network manager config (eth0 settings are local-only, never from server)
+	d.netmgr.UpdateConfig(d.cfg)
+
+	// Save updated config
+	SaveConfig(d.configPath, d.cfg)
+}
+
+func (d *Daemon) subscribeLoop(name string, addr string) {
+	for {
+		select {
+		case <-d.stopChan:
+			return
+		default:
+		}
+
+		// Only try to connect if the corresponding scanner is running
+		scannerRunning := false
+		if name == "ble" {
+			scannerRunning = d.scanners.IsBLERunning()
+		} else if name == "wifi" {
+			scannerRunning = d.scanners.IsWiFiRunning()
+		}
+
+		if !scannerRunning {
+			// Wait before checking again
+			time.Sleep(5 * time.Second)
+			continue
+		}
+
+		if err := d.runSubscriber(name, addr); err != nil {
+			log.Printf("[%s] Subscriber error: %v, reconnecting...", name, err)
+			time.Sleep(time.Second)
+		}
+	}
+}
+
+func (d *Daemon) runSubscriber(name string, addr string) error {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	sub := zmq4.NewSub(ctx)
+	defer sub.Close()
+
+	// Subscribe to all topics for this type
+	if err := sub.SetOption(zmq4.OptionSubscribe, name+"."); err != nil {
+		return err
+	}
+
+	if err := sub.Dial(addr); err != nil {
+		return err
+	}
+
+	log.Printf("[%s] Connected to %s", name, addr)
+
+	// Monitor stop channel in goroutine
+	go func() {
+		<-d.stopChan
+		cancel()
+	}()
+
+	for {
+		msg, err := sub.Recv()
+		if err != nil {
+			return err
+		}
+
+		// Message is in first frame
+		data := string(msg.Frames[0])
+
+		// Parse message: "topic JSON"
+		parts := strings.SplitN(data, " ", 2)
+		if len(parts) != 2 {
+			continue
+		}
+
+		var event interface{}
+		if err := json.Unmarshal([]byte(parts[1]), &event); err != nil {
+			continue
+		}
+
+		d.mu.Lock()
+		if name == "ble" {
+			d.bleEvents = append(d.bleEvents, event)
+			if d.cfg.Debug {
+				log.Printf("[%s] Received event, queue size: %d", name, len(d.bleEvents))
+			}
+			// Add to API for WebSocket broadcast
+			if d.api != nil {
+				d.api.AddBLEEvent(event)
+			}
+		} else {
+			d.wifiEvents = append(d.wifiEvents, event)
+			if d.cfg.Debug {
+				log.Printf("[%s] Received event, queue size: %d", name, len(d.wifiEvents))
+			}
+			// Add to API for WebSocket broadcast
+			if d.api != nil {
+				d.api.AddWiFiEvent(event)
+			}
+		}
+		d.mu.Unlock()
+	}
+}
+
+func (d *Daemon) uploadLoop(name string, endpoint string, intervalMs int) {
+	if intervalMs <= 0 {
+		intervalMs = 2500
+	}
+
+	ticker := time.NewTicker(time.Duration(intervalMs) * time.Millisecond)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-d.stopChan:
+			return
+		case <-ticker.C:
+		}
+
+		d.mu.Lock()
+		var events []interface{}
+		if name == "ble" {
+			events = d.bleEvents
+			d.bleEvents = nil
+		} else {
+			events = d.wifiEvents
+			d.wifiEvents = nil
+		}
+		d.mu.Unlock()
+
+		if len(events) == 0 {
+			continue
+		}
+
+		if d.cfg.Debug {
+			log.Printf("[%s] Batch ready: %d events, uploading...", name, len(events))
+		}
+
+		batch := &EventBatch{
+			DeviceID: d.state.DeviceID,
+			Events:   events,
+		}
+
+		if err := d.client.UploadEvents(endpoint, batch); err != nil {
+			log.Printf("[%s] Upload failed: %v, spooling %d events", name, err, len(events))
+			if err := d.spooler.Save(batch, name); err != nil {
+				log.Printf("[%s] Spool save failed: %v", name, err)
+			}
+		} else {
+			log.Printf("[%s] Uploaded %d events to server", name, len(events))
+		}
+	}
+}
+
+func (d *Daemon) spoolFlushLoop() {
+	ticker := time.NewTicker(10 * time.Second)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-d.stopChan:
+			return
+		case <-ticker.C:
+		}
+
+		if d.state.DeviceToken == "" {
+			continue
+		}
+
+		// Try to flush one batch
+		batch, err := d.spooler.PopOldest()
+		if err != nil || batch == nil {
+			continue
+		}
+
+		// Try to upload (guess endpoint from event types)
+		endpoint := "/events"
+		eventType := "unknown"
+		if len(batch.Events) > 0 {
+			if ev, ok := batch.Events[0].(map[string]interface{}); ok {
+				if t, ok := ev["type"].(string); ok {
+					if strings.HasPrefix(t, "wifi") {
+						endpoint = "/wifi"
+						eventType = "wifi"
+					} else {
+						endpoint = "/ble"
+						eventType = "ble"
+					}
+				}
+			}
+		}
+
+		if err := d.client.UploadEvents(endpoint, batch); err != nil {
+			// Re-spool if upload failed
+			d.spooler.Save(batch, "retry")
+		} else {
+			log.Printf("[spool] Flushed %d %s events to server", len(batch.Events), eventType)
+		}
+	}
+}
+
+// getDeviceID returns a device ID based on MAC address
+func getDeviceID() string {
+	// Try wlan0 first, then eth0
+	for _, iface := range []string{"wlan0", "eth0"} {
+		if mac := getInterfaceMAC(iface); mac != "" {
+			return mac
+		}
+	}
+	// Fallback to hostname
+	host, _ := os.Hostname()
+	return host
+}
+
+func getInterfaceMAC(name string) string {
+	iface, err := net.InterfaceByName(name)
+	if err != nil {
+		return ""
+	}
+	return iface.HardwareAddr.String()
+}
+
+func getInterfaceIP(name string) string {
+	iface, err := net.InterfaceByName(name)
+	if err != nil {
+		return ""
+	}
+	addrs, err := iface.Addrs()
+	if err != nil {
+		return ""
+	}
+	for _, addr := range addrs {
+		if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
+			// Return IP with CIDR notation (e.g., 192.168.5.244/24)
+			ones, _ := ipnet.Mask.Size()
+			return fmt.Sprintf("%s/%d", ipnet.IP.String(), ones)
+		}
+	}
+	return ""
+}

+ 394 - 0
cmd/beacon-daemon/network_manager.go

@@ -0,0 +1,394 @@
+// Network Manager - manages all network interfaces with priority-based logic
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"strings"
+	"syscall"
+	"time"
+)
+
+const (
+	eth0Interface   = "eth0"
+	wlan0Interface  = "wlan0"
+	apFallbackDelay = 120 * time.Second // 120 sec without connection
+)
+
+// NetworkState represents current network state
+type NetworkState int
+
+const (
+	StateNoNetwork NetworkState = iota
+	StateEth0Online
+	StateWLAN0ClientOnline
+	StateWLAN0APFallback
+)
+
+// NetworkManager manages all network interfaces with priority-based logic
+// Priority 1: eth0 (carrier detect)
+// Priority 2: wlan0 client (if configured)
+// Fallback: wlan0 AP (120 sec without connection AND without eth0 carrier)
+type NetworkManager struct {
+	cfg      *Config
+	debug    bool
+	stopChan chan struct{}
+
+	// Scanners reference (needed to stop WiFi scanner when using wlan0)
+	scanners *ScannerManager
+
+	// State tracking
+	eth0Carrier    bool
+	eth0HasIP      bool
+	wlan0HasIP     bool
+	currentState   NetworkState
+	lastOnlineTime time.Time
+	apRunning      bool
+	eth0DhcpPid    int
+}
+
+// NewNetworkManager creates a new network manager
+func NewNetworkManager(cfg *Config, scanners *ScannerManager) *NetworkManager {
+	return &NetworkManager{
+		cfg:            cfg,
+		debug:          cfg.Debug,
+		stopChan:       make(chan struct{}),
+		scanners:       scanners,
+		currentState:   StateNoNetwork,
+		lastOnlineTime: time.Now(), // Start timer immediately
+	}
+}
+
+// UpdateConfig updates network manager configuration
+func (nm *NetworkManager) UpdateConfig(cfg *Config) {
+	nm.cfg = cfg
+}
+
+// HasIP returns true if any interface has an IP address
+func (nm *NetworkManager) HasIP() bool {
+	return nm.eth0HasIP || nm.wlan0HasIP
+}
+
+// HasCarrier returns true if eth0 has carrier
+func (nm *NetworkManager) HasCarrier() bool {
+	return nm.eth0Carrier
+}
+
+// IsOnline returns true if device is online (eth0 or wlan0 client)
+func (nm *NetworkManager) IsOnline() bool {
+	return nm.currentState == StateEth0Online || nm.currentState == StateWLAN0ClientOnline
+}
+
+// Start begins network management
+func (nm *NetworkManager) Start() {
+	log.Println("[netmgr] Starting network manager...")
+	log.Println("[netmgr] Priority: eth0 → wlan0 client → wlan0 AP fallback (120s)")
+
+	// Start main monitoring loop
+	go nm.monitorLoop()
+}
+
+// Stop stops the network manager
+func (nm *NetworkManager) Stop() {
+	log.Println("[netmgr] Stopping network manager...")
+	close(nm.stopChan)
+
+	// Clean up all interfaces
+	nm.stopEth0()
+	nm.stopWLAN0Client()
+	nm.stopAP()
+}
+
+// monitorLoop is the main network management loop
+// Polls every second and applies priority-based logic
+func (nm *NetworkManager) monitorLoop() {
+	ticker := time.NewTicker(1 * time.Second)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-nm.stopChan:
+			return
+		case <-ticker.C:
+			nm.tick()
+		}
+	}
+}
+
+// tick performs one iteration of network state check and management
+func (nm *NetworkManager) tick() {
+	// Update state
+	nm.eth0Carrier = nm.checkCarrier(eth0Interface)
+	nm.eth0HasIP = nm.hasIP(eth0Interface)
+	nm.wlan0HasIP = nm.hasIP(wlan0Interface)
+
+	online := nm.eth0HasIP || nm.wlan0HasIP
+	if online {
+		nm.lastOnlineTime = time.Now()
+	}
+
+	// Priority 1: eth0
+	if nm.eth0Carrier {
+		if !nm.eth0HasIP {
+			// Carrier UP but no IP - configure network
+			if nm.cfg.Network.Eth0.Static {
+				log.Println("[netmgr] eth0 carrier UP - configuring static IP")
+				nm.configureEth0Static()
+			} else {
+				log.Println("[netmgr] eth0 carrier UP - starting DHCP")
+				nm.startEth0DHCP()
+			}
+		}
+
+		// eth0 has priority - disable wlan0 client and AP if running
+		if nm.currentState != StateEth0Online {
+			log.Println("[netmgr] eth0 online - stopping wlan0 client/AP")
+			nm.stopWLAN0Client()
+			nm.stopAP()
+			nm.currentState = StateEth0Online
+
+			// Start WiFi scanner if enabled (wlan0 is free)
+			if nm.cfg.WiFi.MonitorEnabled && !nm.scanners.IsWiFiRunning() {
+				log.Println("[netmgr] eth0 online - starting WiFi scanner")
+				nm.scanners.StartWiFi(nm.cfg.ZMQAddrWiFi, nm.cfg.WiFiIface)
+			}
+		}
+		return
+	}
+
+	// No eth0 carrier - stop eth0 DHCP/IP
+	if nm.eth0HasIP {
+		log.Println("[netmgr] eth0 carrier DOWN - stopping network")
+		nm.stopEth0()
+	}
+
+	// Priority 2: wlan0 client (if configured and eth0 is down)
+	if nm.cfg.WiFi.ClientEnabled && nm.cfg.WiFi.SSID != "" {
+		if !nm.wlan0HasIP {
+			// Should connect
+			log.Printf("[netmgr] Connecting wlan0 client to %s...", nm.cfg.WiFi.SSID)
+			nm.stopAP() // Stop AP before connecting client
+			if err := nm.connectWLAN0Client(); err != nil {
+				log.Printf("[netmgr] wlan0 client connection failed: %v", err)
+				// Will fallback to AP after 120s
+			}
+		}
+
+		if nm.wlan0HasIP && nm.currentState != StateWLAN0ClientOnline {
+			log.Println("[netmgr] wlan0 client online")
+			nm.currentState = StateWLAN0ClientOnline
+			nm.stopAP() // Ensure AP is stopped
+		}
+		return
+	}
+
+	// Fallback: wlan0 AP (if offline for 120 seconds and no eth0 carrier)
+	timeSinceOnline := time.Since(nm.lastOnlineTime)
+	if !online && !nm.eth0Carrier && timeSinceOnline >= apFallbackDelay {
+		if !nm.apRunning {
+			log.Printf("[netmgr] No network for %v - starting AP fallback", timeSinceOnline)
+			nm.startAP()
+		}
+		// Keep trying wlan0 client even with AP running
+		if nm.cfg.WiFi.ClientEnabled && nm.cfg.WiFi.SSID != "" {
+			// Try to reconnect every 30 seconds
+			if int(timeSinceOnline.Seconds())%30 == 0 {
+				log.Println("[netmgr] AP running - retrying wlan0 client...")
+				if err := nm.connectWLAN0Client(); err != nil {
+					if nm.debug {
+						log.Printf("[netmgr] wlan0 client retry failed: %v", err)
+					}
+				} else if nm.hasIP(wlan0Interface) {
+					log.Println("[netmgr] wlan0 client connected - stopping AP")
+					nm.stopAP()
+					nm.currentState = StateWLAN0ClientOnline
+				}
+			}
+		}
+	}
+}
+
+// =======================================================================================
+// eth0 management
+// =======================================================================================
+
+func (nm *NetworkManager) startEth0DHCP() {
+	// Stop any existing DHCP first
+	nm.stopEth0DHCP()
+
+	// Flush old IPs
+	exec.Command("ip", "addr", "flush", "dev", eth0Interface).Run()
+
+	// Bring interface up
+	exec.Command("ifconfig", eth0Interface, "up").Run()
+
+	// Start udhcpc
+	pidFile := fmt.Sprintf("/var/run/udhcpc.%s.pid", eth0Interface)
+	cmd := exec.Command("udhcpc", "-i", eth0Interface, "-b", "-p", pidFile)
+	if err := cmd.Start(); err != nil {
+		log.Printf("[netmgr] Failed to start DHCP on eth0: %v", err)
+		return
+	}
+
+	time.Sleep(500 * time.Millisecond)
+
+	// Read PID
+	pidData, err := os.ReadFile(pidFile)
+	if err == nil {
+		fmt.Sscanf(string(pidData), "%d", &nm.eth0DhcpPid)
+	}
+}
+
+func (nm *NetworkManager) configureEth0Static() {
+	nm.stopEth0DHCP()
+
+	// Bring interface up
+	exec.Command("ifconfig", eth0Interface, "up").Run()
+
+	// Configure IP
+	if nm.cfg.Network.Eth0.Address != "" {
+		cmd := exec.Command("ip", "addr", "add", nm.cfg.Network.Eth0.Address, "dev", eth0Interface)
+		if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "exists") {
+			log.Printf("[netmgr] Failed to configure IP %s on eth0: %v", nm.cfg.Network.Eth0.Address, err)
+			return
+		}
+		log.Printf("[netmgr] Configured static IP %s on eth0", nm.cfg.Network.Eth0.Address)
+	}
+
+	// Configure gateway
+	if nm.cfg.Network.Eth0.Gateway != "" {
+		exec.Command("ip", "route", "del", "default").Run()
+		if err := exec.Command("ip", "route", "add", "default", "via", nm.cfg.Network.Eth0.Gateway).Run(); err != nil {
+			log.Printf("[netmgr] Failed to configure gateway %s: %v", nm.cfg.Network.Eth0.Gateway, err)
+		} else {
+			log.Printf("[netmgr] Configured gateway %s", nm.cfg.Network.Eth0.Gateway)
+		}
+	}
+
+	// Configure DNS
+	if nm.cfg.Network.Eth0.DNS != "" {
+		dnsContent := fmt.Sprintf("nameserver %s\n", nm.cfg.Network.Eth0.DNS)
+		os.WriteFile("/etc/resolv.conf", []byte(dnsContent), 0644)
+	}
+}
+
+func (nm *NetworkManager) stopEth0DHCP() {
+	pidFile := fmt.Sprintf("/var/run/udhcpc.%s.pid", eth0Interface)
+	pidData, err := os.ReadFile(pidFile)
+	if err == nil {
+		var pid int
+		if _, err := fmt.Sscanf(string(pidData), "%d", &pid); err == nil {
+			syscall.Kill(pid, syscall.SIGTERM)
+			os.Remove(pidFile)
+		}
+	}
+	exec.Command("killall", "-q", "udhcpc").Run()
+	nm.eth0DhcpPid = 0
+}
+
+func (nm *NetworkManager) stopEth0() {
+	nm.stopEth0DHCP()
+	exec.Command("ip", "addr", "flush", "dev", eth0Interface).Run()
+}
+
+// =======================================================================================
+// wlan0 client management
+// =======================================================================================
+
+func (nm *NetworkManager) connectWLAN0Client() error {
+	// Stop WiFi scanner (can't run both)
+	if nm.scanners.IsWiFiRunning() {
+		log.Println("[netmgr] Stopping WiFi scanner before connecting client")
+		nm.scanners.StopWiFi()
+	}
+
+	// Use wifi-connect.sh script
+	scriptPath := "/opt/mybeacon/bin/wifi-connect.sh"
+	cmd := exec.Command(scriptPath, nm.cfg.WiFi.SSID, nm.cfg.WiFi.PSK)
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("wifi-connect.sh failed: %w (output: %s)", err, string(output))
+	}
+
+	return nil
+}
+
+func (nm *NetworkManager) stopWLAN0Client() {
+	exec.Command("killall", "wpa_supplicant", "udhcpc", "dhcpcd").Run()
+	exec.Command("ip", "addr", "flush", "dev", wlan0Interface).Run()
+	os.RemoveAll("/var/run/wpa_supplicant/wlan0")
+}
+
+// =======================================================================================
+// wlan0 AP fallback management
+// =======================================================================================
+
+func (nm *NetworkManager) startAP() {
+	if nm.apRunning {
+		return
+	}
+
+	log.Println("[netmgr] Starting AP fallback mode...")
+
+	// Stop client if running
+	nm.stopWLAN0Client()
+
+	// Stop WiFi scanner
+	if nm.scanners.IsWiFiRunning() {
+		nm.scanners.StopWiFi()
+	}
+
+	// Create ap0 interface
+	exec.Command("iw", "phy", "phy0", "interface", "add", "ap0", "type", "__ap").Run()
+	time.Sleep(500 * time.Millisecond)
+
+	// Configure AP IP
+	exec.Command("ifconfig", "ap0", "192.168.4.1", "netmask", "255.255.255.0", "up").Run()
+
+	// Start hostapd
+	exec.Command("hostapd", "-B", "/etc/hostapd/hostapd.conf").Start()
+
+	// Start dnsmasq for DHCP
+	exec.Command("dnsmasq", "-C", "/etc/dnsmasq.d/ap0.conf").Start()
+
+	nm.apRunning = true
+	nm.currentState = StateWLAN0APFallback
+	log.Println("[netmgr] AP fallback started: SSID=Luckfox_Setup, IP=192.168.4.1")
+}
+
+func (nm *NetworkManager) stopAP() {
+	if !nm.apRunning {
+		return
+	}
+
+	log.Println("[netmgr] Stopping AP fallback...")
+
+	exec.Command("killall", "hostapd", "dnsmasq").Run()
+	exec.Command("ip", "link", "del", "ap0").Run()
+
+	nm.apRunning = false
+}
+
+// =======================================================================================
+// Helper functions
+// =======================================================================================
+
+func (nm *NetworkManager) checkCarrier(iface string) bool {
+	carrierPath := fmt.Sprintf("/sys/class/net/%s/carrier", iface)
+	data, err := os.ReadFile(carrierPath)
+	if err != nil {
+		return false
+	}
+	return strings.TrimSpace(string(data)) == "1"
+}
+
+func (nm *NetworkManager) hasIP(iface string) bool {
+	cmd := exec.Command("ip", "addr", "show", iface)
+	output, err := cmd.Output()
+	if err != nil {
+		return false
+	}
+	return strings.Contains(string(output), "inet ")
+}

+ 228 - 0
cmd/beacon-daemon/scanner.go

@@ -0,0 +1,228 @@
+package main
+
+import (
+	"log"
+	"os"
+	"os/exec"
+	"sync"
+	"syscall"
+	"time"
+)
+
+// ScannerType identifies scanner type
+type ScannerType string
+
+const (
+	ScannerBLE  ScannerType = "ble"
+	ScannerWiFi ScannerType = "wifi"
+)
+
+// ScannerManager manages BLE and WiFi scanner processes
+type ScannerManager struct {
+	binDir string
+	debug  bool
+
+	bleCmd    *exec.Cmd
+	wifiCmd   *exec.Cmd
+	bleRunning  bool
+	wifiRunning bool
+	mu        sync.Mutex
+
+	stopChan chan struct{}
+}
+
+// NewScannerManager creates a new scanner manager
+func NewScannerManager(binDir string, debug bool) *ScannerManager {
+	return &ScannerManager{
+		binDir:   binDir,
+		debug:    debug,
+		stopChan: make(chan struct{}),
+	}
+}
+
+// StartBLE starts the BLE scanner process
+func (m *ScannerManager) StartBLE(zmqAddr string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if m.bleRunning {
+		return nil
+	}
+
+	binPath := m.binDir + "/ble-scanner"
+	args := []string{"-zmq", zmqAddr}
+	if m.debug {
+		args = append(args, "-debug")
+	}
+
+	cmd := exec.Command(binPath, args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	// Set process group for clean termination
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	m.bleCmd = cmd
+	m.bleRunning = true
+	log.Printf("[scanner] BLE scanner started (pid=%d)", cmd.Process.Pid)
+
+	// Monitor process
+	go m.monitorProcess(ScannerBLE, cmd)
+
+	return nil
+}
+
+// StopBLE stops the BLE scanner process
+func (m *ScannerManager) StopBLE() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if !m.bleRunning || m.bleCmd == nil {
+		return
+	}
+
+	log.Printf("[scanner] Stopping BLE scanner...")
+
+	// Send SIGTERM to process group
+	if m.bleCmd.Process != nil {
+		syscall.Kill(-m.bleCmd.Process.Pid, syscall.SIGTERM)
+	}
+
+	// Wait with timeout
+	done := make(chan error, 1)
+	go func() {
+		done <- m.bleCmd.Wait()
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(3 * time.Second):
+		// Force kill
+		if m.bleCmd.Process != nil {
+			syscall.Kill(-m.bleCmd.Process.Pid, syscall.SIGKILL)
+		}
+	}
+
+	m.bleRunning = false
+	m.bleCmd = nil
+	log.Printf("[scanner] BLE scanner stopped")
+}
+
+// StartWiFi starts the WiFi scanner process
+func (m *ScannerManager) StartWiFi(zmqAddr, iface string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if m.wifiRunning {
+		return nil
+	}
+
+	binPath := m.binDir + "/wifi-scanner"
+	args := []string{"-zmq", zmqAddr, "-iface", iface}
+	if m.debug {
+		args = append(args, "-debug")
+	}
+
+	cmd := exec.Command(binPath, args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	m.wifiCmd = cmd
+	m.wifiRunning = true
+	log.Printf("[scanner] WiFi scanner started (pid=%d)", cmd.Process.Pid)
+
+	// Monitor process
+	go m.monitorProcess(ScannerWiFi, cmd)
+
+	return nil
+}
+
+// StopWiFi stops the WiFi scanner process
+func (m *ScannerManager) StopWiFi() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if !m.wifiRunning || m.wifiCmd == nil {
+		return
+	}
+
+	log.Printf("[scanner] Stopping WiFi scanner...")
+
+	if m.wifiCmd.Process != nil {
+		syscall.Kill(-m.wifiCmd.Process.Pid, syscall.SIGTERM)
+	}
+
+	done := make(chan error, 1)
+	go func() {
+		done <- m.wifiCmd.Wait()
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(3 * time.Second):
+		if m.wifiCmd.Process != nil {
+			syscall.Kill(-m.wifiCmd.Process.Pid, syscall.SIGKILL)
+		}
+	}
+
+	m.wifiRunning = false
+	m.wifiCmd = nil
+	log.Printf("[scanner] WiFi scanner stopped")
+}
+
+// IsBLERunning returns true if BLE scanner is running
+func (m *ScannerManager) IsBLERunning() bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.bleRunning
+}
+
+// IsWiFiRunning returns true if WiFi scanner is running
+func (m *ScannerManager) IsWiFiRunning() bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.wifiRunning
+}
+
+// StopAll stops all scanners
+func (m *ScannerManager) StopAll() {
+	close(m.stopChan)
+	m.StopBLE()
+	m.StopWiFi()
+}
+
+// monitorProcess monitors a scanner process and marks it as stopped when it exits
+func (m *ScannerManager) monitorProcess(typ ScannerType, cmd *exec.Cmd) {
+	err := cmd.Wait()
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	switch typ {
+	case ScannerBLE:
+		m.bleRunning = false
+		m.bleCmd = nil
+		if err != nil {
+			log.Printf("[scanner] BLE scanner exited: %v", err)
+		} else {
+			log.Printf("[scanner] BLE scanner exited normally")
+		}
+	case ScannerWiFi:
+		m.wifiRunning = false
+		m.wifiCmd = nil
+		if err != nil {
+			log.Printf("[scanner] WiFi scanner exited: %v", err)
+		} else {
+			log.Printf("[scanner] WiFi scanner exited normally")
+		}
+	}
+}

+ 154 - 0
cmd/beacon-daemon/spooler.go

@@ -0,0 +1,154 @@
+package main
+
+import (
+	"compress/gzip"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"time"
+)
+
+// Spooler handles offline event storage
+type Spooler struct {
+	dir      string
+	maxBytes int64
+}
+
+// NewSpooler creates a new spooler
+func NewSpooler(dir string, maxBytes int64) (*Spooler, error) {
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, err
+	}
+	return &Spooler{dir: dir, maxBytes: maxBytes}, nil
+}
+
+// Save saves a batch to the spool
+func (s *Spooler) Save(batch *EventBatch, prefix string) error {
+	// Generate filename
+	ts := time.Now().UnixNano()
+	filename := fmt.Sprintf("%s_%d_%d.json.gz", prefix, ts, os.Getpid())
+	path := filepath.Join(s.dir, filename)
+
+	// Create gzipped file
+	f, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	gw := gzip.NewWriter(f)
+	if err := json.NewEncoder(gw).Encode(batch); err != nil {
+		gw.Close()
+		os.Remove(path)
+		return err
+	}
+	if err := gw.Close(); err != nil {
+		os.Remove(path)
+		return err
+	}
+
+	// Trim if needed
+	s.trim()
+
+	return nil
+}
+
+// PopOldest returns and removes the oldest spooled file
+func (s *Spooler) PopOldest() (*EventBatch, error) {
+	files, err := s.listFiles()
+	if err != nil || len(files) == 0 {
+		return nil, err
+	}
+
+	path := files[0]
+	batch, err := s.loadFile(path)
+	if err != nil {
+		// Remove corrupted file
+		os.Remove(path)
+		return nil, err
+	}
+
+	os.Remove(path)
+	return batch, nil
+}
+
+// Size returns the total size of spooled files
+func (s *Spooler) Size() int64 {
+	files, err := s.listFiles()
+	if err != nil {
+		return 0
+	}
+
+	var total int64
+	for _, path := range files {
+		if info, err := os.Stat(path); err == nil {
+			total += info.Size()
+		}
+	}
+	return total
+}
+
+// Count returns the number of spooled files
+func (s *Spooler) Count() int {
+	files, _ := s.listFiles()
+	return len(files)
+}
+
+func (s *Spooler) listFiles() ([]string, error) {
+	entries, err := os.ReadDir(s.dir)
+	if err != nil {
+		return nil, err
+	}
+
+	var files []string
+	for _, entry := range entries {
+		if !entry.IsDir() && filepath.Ext(entry.Name()) == ".gz" {
+			files = append(files, filepath.Join(s.dir, entry.Name()))
+		}
+	}
+
+	// Sort by modification time (oldest first)
+	sort.Slice(files, func(i, j int) bool {
+		infoI, _ := os.Stat(files[i])
+		infoJ, _ := os.Stat(files[j])
+		if infoI == nil || infoJ == nil {
+			return false
+		}
+		return infoI.ModTime().Before(infoJ.ModTime())
+	})
+
+	return files, nil
+}
+
+func (s *Spooler) loadFile(path string) (*EventBatch, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	gr, err := gzip.NewReader(f)
+	if err != nil {
+		return nil, err
+	}
+	defer gr.Close()
+
+	var batch EventBatch
+	if err := json.NewDecoder(gr).Decode(&batch); err != nil {
+		return nil, err
+	}
+
+	return &batch, nil
+}
+
+func (s *Spooler) trim() {
+	for s.Size() > s.maxBytes {
+		files, err := s.listFiles()
+		if err != nil || len(files) == 0 {
+			break
+		}
+		os.Remove(files[0])
+	}
+}

+ 192 - 0
cmd/beacon-daemon/tunnel.go

@@ -0,0 +1,192 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"os"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// SSHTunnel manages a reverse SSH tunnel
+type SSHTunnel struct {
+	cfg     *Config
+	running bool
+	mu      sync.Mutex
+	stop    chan struct{}
+}
+
+// NewSSHTunnel creates a new SSH tunnel manager
+func NewSSHTunnel(cfg *Config) *SSHTunnel {
+	return &SSHTunnel{
+		cfg:  cfg,
+		stop: make(chan struct{}),
+	}
+}
+
+// Start starts the tunnel maintenance loop
+func (t *SSHTunnel) Start() {
+	t.mu.Lock()
+	if t.running {
+		t.mu.Unlock()
+		return
+	}
+	t.running = true
+	t.mu.Unlock()
+
+	go t.maintainLoop()
+}
+
+// Stop stops the tunnel
+func (t *SSHTunnel) Stop() {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	if t.running {
+		close(t.stop)
+		t.running = false
+	}
+}
+
+func (t *SSHTunnel) maintainLoop() {
+	for {
+		select {
+		case <-t.stop:
+			return
+		default:
+		}
+
+		t.mu.Lock()
+		cfg := t.cfg
+		t.mu.Unlock()
+
+		if !cfg.SSHTunnel.Enabled {
+			time.Sleep(10 * time.Second)
+			continue
+		}
+
+		if err := t.runTunnel(); err != nil {
+			log.Printf("SSH tunnel error: %v, reconnecting in %ds...", err, cfg.SSHTunnel.ReconnectDelay)
+		}
+
+		select {
+		case <-t.stop:
+			return
+		case <-time.After(time.Duration(cfg.SSHTunnel.ReconnectDelay) * time.Second):
+		}
+	}
+}
+
+func (t *SSHTunnel) runTunnel() error {
+	cfg := t.cfg.SSHTunnel
+
+	// Load private key
+	keyData, err := os.ReadFile(cfg.KeyPath)
+	if err != nil {
+		return fmt.Errorf("read key: %w", err)
+	}
+
+	signer, err := ssh.ParsePrivateKey(keyData)
+	if err != nil {
+		return fmt.Errorf("parse key: %w", err)
+	}
+
+	// SSH config
+	sshConfig := &ssh.ClientConfig{
+		User: cfg.User,
+		Auth: []ssh.AuthMethod{
+			ssh.PublicKeys(signer),
+		},
+		HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known_hosts
+		Timeout:         30 * time.Second,
+	}
+
+	// Connect to server
+	serverAddr := fmt.Sprintf("%s:%d", cfg.Server, cfg.Port)
+	log.Printf("SSH tunnel connecting to %s...", serverAddr)
+
+	client, err := ssh.Dial("tcp", serverAddr, sshConfig)
+	if err != nil {
+		return fmt.Errorf("ssh dial: %w", err)
+	}
+	defer client.Close()
+
+	// Request remote port forwarding
+	remoteAddr := fmt.Sprintf("127.0.0.1:%d", cfg.RemotePort)
+	listener, err := client.Listen("tcp", remoteAddr)
+	if err != nil {
+		return fmt.Errorf("remote listen: %w", err)
+	}
+	defer listener.Close()
+
+	log.Printf("SSH tunnel established: remote %s -> local :22", remoteAddr)
+
+	// Handle incoming connections
+	errChan := make(chan error, 1)
+	go func() {
+		for {
+			conn, err := listener.Accept()
+			if err != nil {
+				errChan <- err
+				return
+			}
+			go t.handleConnection(conn)
+		}
+	}()
+
+	// Keepalive loop
+	keepaliveTicker := time.NewTicker(time.Duration(cfg.KeepaliveInterval) * time.Second)
+	defer keepaliveTicker.Stop()
+
+	for {
+		select {
+		case <-t.stop:
+			return nil
+		case err := <-errChan:
+			return err
+		case <-keepaliveTicker.C:
+			_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
+			if err != nil {
+				return fmt.Errorf("keepalive: %w", err)
+			}
+		}
+	}
+}
+
+func (t *SSHTunnel) handleConnection(remoteConn net.Conn) {
+	defer remoteConn.Close()
+
+	// Connect to local SSH
+	localConn, err := net.Dial("tcp", "127.0.0.1:22")
+	if err != nil {
+		log.Printf("Failed to connect to local SSH: %v", err)
+		return
+	}
+	defer localConn.Close()
+
+	// Bidirectional copy
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	go func() {
+		defer wg.Done()
+		io.Copy(localConn, remoteConn)
+	}()
+
+	go func() {
+		defer wg.Done()
+		io.Copy(remoteConn, localConn)
+	}()
+
+	wg.Wait()
+}
+
+// UpdateConfig updates the tunnel configuration
+func (t *SSHTunnel) UpdateConfig(cfg *Config) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	t.cfg = cfg
+}

+ 384 - 0
cmd/ble-scanner/main.go

@@ -0,0 +1,384 @@
+// BLE Scanner - scans for BLE advertisements via BlueZ D-Bus and publishes events via ZMQ
+package main
+
+import (
+	"context"
+	"encoding/binary"
+	"encoding/hex"
+	"encoding/json"
+	"flag"
+	"log"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+
+	"mybeacon/internal/protocol"
+
+	"github.com/go-zeromq/zmq4"
+	"github.com/godbus/dbus/v5"
+)
+
+const (
+	defaultZMQAddr = "tcp://127.0.0.1:5555"
+
+	// D-Bus constants
+	bluezBus         = "org.bluez"
+	adapterInterface = "org.bluez.Adapter1"
+	deviceInterface  = "org.bluez.Device1"
+	objectManager    = "org.freedesktop.DBus.ObjectManager"
+	propertiesIface  = "org.freedesktop.DBus.Properties"
+)
+
+func main() {
+	var (
+		zmqAddr = flag.String("zmq", defaultZMQAddr, "ZMQ PUB address")
+		adapter = flag.String("adapter", "hci0", "Bluetooth adapter")
+		debug   = flag.Bool("debug", false, "Enable debug logging")
+	)
+	flag.Parse()
+
+	log.SetFlags(log.Ltime)
+	log.Printf("BLE Scanner starting (adapter=%s, zmq=%s)", *adapter, *zmqAddr)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Create ZMQ publisher socket
+	publisher := zmq4.NewPub(ctx)
+	defer publisher.Close()
+
+	if err := publisher.Listen(*zmqAddr); err != nil {
+		log.Fatalf("ZMQ listen: %v", err)
+	}
+	log.Printf("ZMQ PUB listening on %s", *zmqAddr)
+
+	time.Sleep(100 * time.Millisecond)
+
+	// Connect to system D-Bus
+	conn, err := dbus.SystemBus()
+	if err != nil {
+		log.Fatalf("D-Bus connection: %v", err)
+	}
+	defer conn.Close()
+
+	adapterPath := dbus.ObjectPath("/org/bluez/" + *adapter)
+
+	// Power on adapter
+	adapterObj := conn.Object(bluezBus, adapterPath)
+	if err := adapterObj.Call(propertiesIface+".Set", 0, adapterInterface, "Powered", dbus.MakeVariant(true)).Err; err != nil {
+		log.Printf("Warning: power on adapter: %v", err)
+	}
+
+	// Set discovery filter for LE devices
+	filter := map[string]interface{}{
+		"Transport":     "le",
+		"DuplicateData": true,
+	}
+	if err := adapterObj.Call(adapterInterface+".SetDiscoveryFilter", 0, filter).Err; err != nil {
+		log.Printf("Warning: set discovery filter: %v", err)
+	}
+
+	// Subscribe to InterfacesAdded and PropertiesChanged signals
+	if err := conn.AddMatchSignal(
+		dbus.WithMatchObjectPath("/"),
+		dbus.WithMatchInterface(objectManager),
+		dbus.WithMatchMember("InterfacesAdded"),
+	); err != nil {
+		log.Fatalf("Add InterfacesAdded match: %v", err)
+	}
+
+	if err := conn.AddMatchSignal(
+		dbus.WithMatchInterface(propertiesIface),
+		dbus.WithMatchMember("PropertiesChanged"),
+	); err != nil {
+		log.Fatalf("Add PropertiesChanged match: %v", err)
+	}
+
+	// Start discovery
+	if err := adapterObj.Call(adapterInterface+".StartDiscovery", 0).Err; err != nil {
+		log.Fatalf("Start discovery: %v", err)
+	}
+	log.Printf("BLE discovery started on %s", *adapter)
+
+	// Handle shutdown
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+	go func() {
+		<-sigChan
+		log.Println("Shutting down...")
+		adapterObj.Call(adapterInterface+".StopDiscovery", 0)
+		cancel()
+		os.Exit(0)
+	}()
+
+	// Process D-Bus signals
+	signals := make(chan *dbus.Signal, 100)
+	conn.Signal(signals)
+
+	var eventCount uint64
+
+	for sig := range signals {
+		var ev interface{}
+
+		if *debug {
+			log.Printf("Signal: %s path=%s", sig.Name, sig.Path)
+		}
+
+		switch sig.Name {
+		case objectManager + ".InterfacesAdded":
+			if len(sig.Body) < 2 {
+				continue
+			}
+			ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
+			if !ok {
+				continue
+			}
+			props, ok := ifaces[deviceInterface]
+			if !ok {
+				continue
+			}
+			if *debug {
+				log.Printf("InterfacesAdded: device props=%v", props)
+			}
+			ev = parseDeviceProperties(props, *debug)
+
+		case propertiesIface + ".PropertiesChanged":
+			if len(sig.Body) < 2 {
+				continue
+			}
+			iface, ok := sig.Body[0].(string)
+			if !ok || iface != deviceInterface {
+				continue
+			}
+			props, ok := sig.Body[1].(map[string]dbus.Variant)
+			if !ok {
+				continue
+			}
+			// Get full device properties
+			devicePath := sig.Path
+			deviceObj := conn.Object(bluezBus, devicePath)
+			var allProps map[string]dbus.Variant
+			if err := deviceObj.Call(propertiesIface+".GetAll", 0, deviceInterface).Store(&allProps); err != nil {
+				// Use partial props
+				if *debug {
+					log.Printf("PropertiesChanged (partial): %v", props)
+				}
+				ev = parseDeviceProperties(props, *debug)
+			} else {
+				if *debug {
+					log.Printf("PropertiesChanged (full): addr=%v mfg=%v", allProps["Address"], allProps["ManufacturerData"])
+				}
+				ev = parseDeviceProperties(allProps, *debug)
+			}
+		}
+
+		if ev == nil {
+			continue
+		}
+
+		jsonData, err := json.Marshal(ev)
+		if err != nil {
+			continue
+		}
+
+		var topic string
+		switch ev.(type) {
+		case *protocol.IBeaconEvent:
+			topic = "ble.ibeacon"
+		case *protocol.AccelEvent:
+			topic = "ble.acc"
+		case *protocol.RelayEvent:
+			topic = "ble.relay"
+		default:
+			topic = "ble.unknown"
+		}
+
+		msg := zmq4.NewMsgString(topic + " " + string(jsonData))
+		if err := publisher.Send(msg); err != nil {
+			log.Printf("ZMQ send error: %v", err)
+			continue
+		}
+
+		eventCount++
+		if *debug {
+			log.Printf("[%s] %s", topic, string(jsonData))
+		} else if eventCount%100 == 0 {
+			log.Printf("[ble-scanner] %d events sent to daemon via ZMQ", eventCount)
+		}
+	}
+}
+
+func parseDeviceProperties(props map[string]dbus.Variant, debug bool) interface{} {
+	var mac string
+	var rssi int16
+	manufacturerData := make(map[uint16][]byte)
+
+	if v, ok := props["Address"]; ok {
+		mac, _ = v.Value().(string)
+	}
+	if v, ok := props["RSSI"]; ok {
+		rssi, _ = v.Value().(int16)
+	}
+	if v, ok := props["ManufacturerData"]; ok {
+		// D-Bus returns map[uint16]dbus.Variant where each Variant contains []byte
+		if mfgVariant, ok := v.Value().(map[uint16]dbus.Variant); ok {
+			for companyID, dataVariant := range mfgVariant {
+				if data, ok := dataVariant.Value().([]byte); ok {
+					manufacturerData[companyID] = data
+				}
+			}
+		}
+	}
+
+	if debug {
+		log.Printf("  parseDeviceProperties: mac=%s rssi=%d mfgData=%v", mac, rssi, manufacturerData)
+	}
+
+	if mac == "" {
+		return nil
+	}
+
+	// Normalize MAC address
+	mac = strings.ToLower(strings.ReplaceAll(mac, "-", ":"))
+	ts := time.Now().UnixMilli()
+
+	if len(manufacturerData) == 0 {
+		return nil
+	}
+
+	// Check for iBeacon (Apple company ID 0x004C)
+	if data, ok := manufacturerData[0x004C]; ok {
+		if debug {
+			log.Printf("  Found Apple mfg data: %x", data)
+		}
+		if ev := parseIBeacon(mac, int(rssi), ts, data); ev != nil {
+			return ev
+		}
+	}
+
+	// Check for Nordic/custom (0x0059) - my-beacon_acc and rt_mybeacon
+	if data, ok := manufacturerData[0x0059]; ok {
+		if debug {
+			log.Printf("  Found Nordic mfg data: %x", data)
+		}
+		// Check for acc (0x01 0x15) or relay (0x02 0x15)
+		if len(data) >= 2 {
+			if data[0] == 0x01 && data[1] == 0x15 {
+				if ev := parseAccelBeacon(mac, int(rssi), ts, data); ev != nil {
+					return ev
+				}
+			} else if data[0] == 0x02 && data[1] == 0x15 {
+				if ev := parseRelayBeacon(mac, int(rssi), ts, data); ev != nil {
+					return ev
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func parseIBeacon(mac string, rssi int, ts int64, data []byte) *protocol.IBeaconEvent {
+	// iBeacon format: 0x02 0x15 [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte]
+	if len(data) < 23 || data[0] != 0x02 || data[1] != 0x15 {
+		return nil
+	}
+
+	uuid := hex.EncodeToString(data[2:18])
+	major := binary.BigEndian.Uint16(data[18:20])
+	minor := binary.BigEndian.Uint16(data[20:22])
+
+	return &protocol.IBeaconEvent{
+		BLEEvent: protocol.BLEEvent{
+			Type: protocol.EventIBeacon,
+			MAC:  mac,
+			RSSI: int8(rssi),
+			TsMs: ts,
+		},
+		UUID:  uuid,
+		Major: major,
+		Minor: minor,
+	}
+}
+
+func parseAccelBeacon(mac string, rssi int, ts int64, data []byte) *protocol.AccelEvent {
+	// my-beacon_acc format: 0x01 0x15 [x s8] [y s8] [z s8] [bat u8] [temp s8] [ff u8]
+	// Caller already verified data[0]=0x01, data[1]=0x15
+	if len(data) < 8 {
+		return nil
+	}
+
+	evType := protocol.EventAccel
+	ff := data[7] == 0xff
+	if ff {
+		evType = protocol.EventAccelFF
+	}
+
+	return &protocol.AccelEvent{
+		BLEEvent: protocol.BLEEvent{
+			Type: evType,
+			MAC:  mac,
+			RSSI: int8(rssi),
+			TsMs: ts,
+		},
+		X:    int8(data[2]),
+		Y:    int8(data[3]),
+		Z:    int8(data[4]),
+		Bat:  data[5],
+		Temp: int8(data[6]),
+		FF:   ff,
+	}
+}
+
+func parseRelayBeacon(mac string, rssi int, ts int64, data []byte) *protocol.RelayEvent {
+	// rt_mybeacon format: 0x02 0x15 DE AD BE EF [mac 6] [maj 2] [min 2] [rssi 1] [bat 1] [ib_maj 2] [ib_min 2]
+	// Caller already verified data[0]=0x02, data[1]=0x15
+	// Total: 22 bytes
+	if len(data) < 22 {
+		return nil
+	}
+
+	// Verify DEADBEEF magic
+	if data[2] != 0xDE || data[3] != 0xAD || data[4] != 0xBE || data[5] != 0xEF {
+		return nil
+	}
+
+	origMAC := formatMAC(data[6:12])
+	relayMaj := binary.BigEndian.Uint16(data[12:14])
+	relayMin := binary.BigEndian.Uint16(data[14:16])
+	relayRSSI := int8(data[16])
+	relayBat := data[17]
+	ibMajor := binary.BigEndian.Uint16(data[18:20])
+	ibMinor := binary.BigEndian.Uint16(data[20:22])
+
+	return &protocol.RelayEvent{
+		BLEEvent: protocol.BLEEvent{
+			Type: protocol.EventRelay,
+			MAC:  origMAC,
+			RSSI: relayRSSI,
+			TsMs: ts,
+		},
+		RelayMAC:  mac,
+		RelayMaj:  relayMaj,
+		RelayMin:  relayMin,
+		RelayRSSI: int8(rssi),
+		RelayBat:  relayBat,
+		IBMajor:   ibMajor,
+		IBMinor:   ibMinor,
+	}
+}
+
+func formatMAC(b []byte) string {
+	if len(b) < 6 {
+		return ""
+	}
+	return strings.ToLower(hex.EncodeToString(b[0:1]) + ":" +
+		hex.EncodeToString(b[1:2]) + ":" +
+		hex.EncodeToString(b[2:3]) + ":" +
+		hex.EncodeToString(b[3:4]) + ":" +
+		hex.EncodeToString(b[4:5]) + ":" +
+		hex.EncodeToString(b[5:6]))
+}

+ 136 - 0
cmd/wifi-scanner/main.go

@@ -0,0 +1,136 @@
+// WiFi Scanner - captures WiFi probe requests and publishes events via ZMQ
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"mybeacon/internal/protocol"
+	"mybeacon/internal/wifi"
+
+	"github.com/go-zeromq/zmq4"
+)
+
+const (
+	defaultZMQAddr = "tcp://127.0.0.1:5556"
+	defaultIface   = "wlan0"
+)
+
+var defaultChannels = []int{1, 6, 11}
+
+func main() {
+	var (
+		zmqAddr   = flag.String("zmq", defaultZMQAddr, "ZMQ PUB address")
+		iface     = flag.String("iface", defaultIface, "WiFi interface")
+		dwellMs   = flag.Int("dwell", 400, "Channel dwell time in ms")
+		debug     = flag.Bool("debug", false, "Enable debug logging")
+		noMonitor = flag.Bool("no-monitor", false, "Skip monitor mode setup")
+	)
+	flag.Parse()
+
+	log.SetFlags(log.Ltime | log.Lmicroseconds)
+	log.Printf("WiFi Scanner starting (iface=%s, zmq=%s)", *iface, *zmqAddr)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Create ZMQ publisher socket
+	publisher := zmq4.NewPub(ctx)
+	defer publisher.Close()
+
+	if err := publisher.Listen(*zmqAddr); err != nil {
+		log.Fatalf("ZMQ listen: %v", err)
+	}
+	log.Printf("ZMQ PUB listening on %s", *zmqAddr)
+
+	time.Sleep(100 * time.Millisecond)
+
+	// Enable monitor mode
+	if !*noMonitor {
+		log.Printf("Enabling monitor mode on %s...", *iface)
+		if err := wifi.SetMonitorMode(*iface); err != nil {
+			log.Fatalf("Monitor mode: %v", err)
+		}
+		log.Printf("Monitor mode enabled on %s", *iface)
+	}
+
+	// Create capture socket
+	capture, err := wifi.NewCaptureSocket(*iface)
+	if err != nil {
+		log.Fatalf("Capture socket: %v", err)
+	}
+	defer capture.Close()
+
+	// Start channel hopper
+	hopper := wifi.NewChannelHopper(*iface, defaultChannels, time.Duration(*dwellMs)*time.Millisecond)
+	hopper.Start()
+	defer hopper.Stop()
+
+	log.Printf("Channel hopping: %v (dwell=%dms)", defaultChannels, *dwellMs)
+
+	// Handle shutdown
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+	go func() {
+		<-sigChan
+		log.Println("Shutting down...")
+		hopper.Stop()
+		capture.Close()
+		if !*noMonitor {
+			wifi.SetManagedMode(*iface)
+		}
+		cancel()
+		os.Exit(0)
+	}()
+
+	// Main capture loop
+	buf := make([]byte, 65536)
+	var eventCount uint64
+
+	for {
+		n, err := capture.Read(buf)
+		if err != nil {
+			log.Printf("Read error: %v", err)
+			continue
+		}
+
+		probe, ok := wifi.ParseProbeRequest(buf[:n])
+		if !ok {
+			continue
+		}
+
+		ev := protocol.WiFiProbeEvent{
+			Type: protocol.EventWiFiProbe,
+			MAC:  probe.SourceMAC,
+			RSSI: probe.RSSI,
+			TsMs: probe.Timestamp.UnixMilli(),
+			SSID: probe.SSID,
+		}
+
+		jsonData, err := json.Marshal(ev)
+		if err != nil {
+			continue
+		}
+
+		topic := "wifi.probe"
+		msg := zmq4.NewMsgString(topic + " " + string(jsonData))
+		if err := publisher.Send(msg); err != nil {
+			log.Printf("ZMQ send error: %v", err)
+			continue
+		}
+
+		eventCount++
+		if *debug {
+			log.Printf("[%s] %s", topic, string(jsonData))
+		} else if eventCount%100 == 0 {
+			log.Printf("Published %d probe requests", eventCount)
+		}
+	}
+}

+ 24 - 0
dashboard/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 5 - 0
dashboard/README.md

@@ -0,0 +1,5 @@
+# Vue 3 + Vite
+
+This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

+ 13 - 0
dashboard/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>dashboard</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 1289 - 0
dashboard/package-lock.json

@@ -0,0 +1,1289 @@
+{
+  "name": "dashboard",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dashboard",
+      "version": "0.0.0",
+      "dependencies": {
+        "vue": "^3.5.24"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^6.0.1",
+        "vite": "^7.2.4"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.53",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
+      "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
+      "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
+      "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
+      "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
+      "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
+      "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
+      "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
+      "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
+      "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
+      "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
+      "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
+      "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
+      "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
+      "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
+      "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
+      "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
+      "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
+      "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
+      "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
+      "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
+      "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
+      "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "1.0.0-beta.53"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
+      "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/shared": "3.5.26",
+        "entities": "^7.0.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
+      "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
+      "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/compiler-core": "3.5.26",
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/compiler-ssr": "3.5.26",
+        "@vue/shared": "3.5.26",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
+      "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
+      "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
+      "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
+      "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.26",
+        "@vue/runtime-core": "3.5.26",
+        "@vue/shared": "3.5.26",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
+      "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.26",
+        "@vue/shared": "3.5.26"
+      },
+      "peerDependencies": {
+        "vue": "3.5.26"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
+      "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/entities": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
+      "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.2",
+        "@esbuild/android-arm": "0.27.2",
+        "@esbuild/android-arm64": "0.27.2",
+        "@esbuild/android-x64": "0.27.2",
+        "@esbuild/darwin-arm64": "0.27.2",
+        "@esbuild/darwin-x64": "0.27.2",
+        "@esbuild/freebsd-arm64": "0.27.2",
+        "@esbuild/freebsd-x64": "0.27.2",
+        "@esbuild/linux-arm": "0.27.2",
+        "@esbuild/linux-arm64": "0.27.2",
+        "@esbuild/linux-ia32": "0.27.2",
+        "@esbuild/linux-loong64": "0.27.2",
+        "@esbuild/linux-mips64el": "0.27.2",
+        "@esbuild/linux-ppc64": "0.27.2",
+        "@esbuild/linux-riscv64": "0.27.2",
+        "@esbuild/linux-s390x": "0.27.2",
+        "@esbuild/linux-x64": "0.27.2",
+        "@esbuild/netbsd-arm64": "0.27.2",
+        "@esbuild/netbsd-x64": "0.27.2",
+        "@esbuild/openbsd-arm64": "0.27.2",
+        "@esbuild/openbsd-x64": "0.27.2",
+        "@esbuild/openharmony-arm64": "0.27.2",
+        "@esbuild/sunos-x64": "0.27.2",
+        "@esbuild/win32-arm64": "0.27.2",
+        "@esbuild/win32-ia32": "0.27.2",
+        "@esbuild/win32-x64": "0.27.2"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
+      "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.54.0",
+        "@rollup/rollup-android-arm64": "4.54.0",
+        "@rollup/rollup-darwin-arm64": "4.54.0",
+        "@rollup/rollup-darwin-x64": "4.54.0",
+        "@rollup/rollup-freebsd-arm64": "4.54.0",
+        "@rollup/rollup-freebsd-x64": "4.54.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.54.0",
+        "@rollup/rollup-linux-arm64-musl": "4.54.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.54.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.54.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-musl": "4.54.0",
+        "@rollup/rollup-openharmony-arm64": "4.54.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.54.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.54.0",
+        "@rollup/rollup-win32-x64-gnu": "4.54.0",
+        "@rollup/rollup-win32-x64-msvc": "4.54.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/vite": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
+      "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
+      "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/compiler-sfc": "3.5.26",
+        "@vue/runtime-dom": "3.5.26",
+        "@vue/server-renderer": "3.5.26",
+        "@vue/shared": "3.5.26"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 18 - 0
dashboard/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "dashboard",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.5.24"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.1",
+    "vite": "^7.2.4"
+  }
+}

+ 1 - 0
dashboard/public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 351 - 0
dashboard/src/App.vue

@@ -0,0 +1,351 @@
+<template>
+  <div class="app">
+    <header class="header">
+      <div class="header-left">
+        <span class="status-indicator" :class="daemonOk ? 'ok' : 'error'"></span>
+        <h1>MyBeacon</h1>
+        <span class="device-badge">Device {{ status.device_id || '...' }}</span>
+      </div>
+    </header>
+
+    <nav class="tabs">
+      <button
+        v-for="tab in tabs"
+        :key="tab.id"
+        :class="{ active: activeTab === tab.id }"
+        @click="activeTab = tab.id"
+      >
+        {{ tab.label }}
+      </button>
+    </nav>
+
+    <main class="content">
+      <StatusTab v-if="activeTab === 'status'" :status="status" :metrics="metrics" :bleEvents="bleEvents" />
+      <BLETab v-else-if="activeTab === 'ble'" :events="bleEvents" />
+      <WiFiTab v-else-if="activeTab === 'wifi'" :events="wifiEvents" :wifiClientActive="!!status.network?.wlan0_ip" />
+      <LogTab v-else-if="activeTab === 'log'" :logs="logs" />
+      <SettingsTab v-else-if="activeTab === 'settings'" :config="config" :unlocked="unlocked" :message="settingsMessage" @unlock="handleUnlock" @save="handleSave" />
+    </main>
+
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import StatusTab from './components/StatusTab.vue'
+import BLETab from './components/BLETab.vue'
+import WiFiTab from './components/WiFiTab.vue'
+import LogTab from './components/LogTab.vue'
+import SettingsTab from './components/SettingsTab.vue'
+
+const API_BASE = import.meta.env.VITE_API_BASE || ''
+
+// Dynamic tabs based on scanner status
+const tabs = computed(() => {
+  const result = [{ id: 'status', label: 'Status' }]
+
+  // Show BLE tab only when scanner is running
+  if (status.value?.scanners?.ble_running) {
+    result.push({ id: 'ble', label: 'BLE' })
+  }
+
+  // Show WiFi tab when scanner running OR when client active but monitoring enabled
+  const wifiRunning = status.value?.scanners?.wifi_running
+  const wifiClientActive = !!status.value?.network?.wlan0_ip
+  const wifiMonitorEnabled = config.value?.wifi?.monitor_enabled
+  if (wifiRunning || (wifiClientActive && wifiMonitorEnabled)) {
+    result.push({ id: 'wifi', label: 'WiFi' })
+  }
+
+  result.push({ id: 'log', label: 'Daemon Log' })
+  result.push({ id: 'settings', label: 'Settings' })
+  return result
+})
+
+const activeTab = ref('status')
+const status = ref({})
+const metrics = ref({})
+const config = ref({})
+const bleEvents = ref([])
+const wifiEvents = ref([])
+const logs = ref([])
+const unlocked = ref(false)
+const devicePassword = ref('')
+const settingsMessage = ref({ text: '', type: '' })
+const wsConnected = ref(false)
+
+let ws = null
+let pollInterval = null
+
+// Daemon is OK if we have WS connection and status fetches work
+const daemonOk = computed(() => {
+  return wsConnected.value && status.value.device_id
+})
+
+async function fetchStatus() {
+  try {
+    const res = await fetch(`${API_BASE}/api/status`)
+    status.value = await res.json()
+  } catch (e) {
+    console.error('Status fetch error:', e)
+  }
+}
+
+async function fetchMetrics() {
+  try {
+    const res = await fetch(`${API_BASE}/api/metrics`)
+    metrics.value = await res.json()
+  } catch (e) {
+    console.error('Metrics fetch error:', e)
+  }
+}
+
+async function fetchConfig() {
+  try {
+    const res = await fetch(`${API_BASE}/api/config`)
+    config.value = await res.json()
+  } catch (e) {
+    console.error('Config fetch error:', e)
+  }
+}
+
+async function fetchBLE() {
+  try {
+    const res = await fetch(`${API_BASE}/api/ble/recent`)
+    bleEvents.value = await res.json()
+  } catch (e) {
+    console.error('BLE fetch error:', e)
+  }
+}
+
+async function fetchWiFi() {
+  try {
+    const res = await fetch(`${API_BASE}/api/wifi/recent`)
+    wifiEvents.value = await res.json()
+  } catch (e) {
+    console.error('WiFi fetch error:', e)
+  }
+}
+
+async function fetchLogs() {
+  try {
+    const res = await fetch(`${API_BASE}/api/logs`)
+    const data = await res.json()
+    if (Array.isArray(data)) {
+      logs.value = data
+    }
+  } catch (e) {
+    console.error('Logs fetch error:', e)
+  }
+}
+
+function connectWebSocket() {
+  const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}${API_BASE}/api/ws`
+  ws = new WebSocket(wsUrl)
+
+  ws.onopen = () => {
+    wsConnected.value = true
+  }
+
+  ws.onmessage = (event) => {
+    try {
+      const msg = JSON.parse(event.data)
+      if (msg.type === 'ble') {
+        bleEvents.value.unshift(msg.event)
+        if (bleEvents.value.length > 200) bleEvents.value.pop()
+      } else if (msg.type === 'wifi') {
+        wifiEvents.value.unshift(msg.event)
+        if (wifiEvents.value.length > 200) wifiEvents.value.pop()
+      } else if (msg.type === 'log') {
+        logs.value.unshift(msg.message)
+        if (logs.value.length > 500) logs.value.pop()
+      }
+    } catch (e) {
+      console.error('WS message error:', e)
+    }
+  }
+
+  ws.onclose = () => {
+    wsConnected.value = false
+    setTimeout(connectWebSocket, 3000)
+  }
+
+  ws.onerror = (e) => {
+    console.error('WS error:', e)
+    wsConnected.value = false
+  }
+}
+
+async function handleUnlock(password) {
+  try {
+    const res = await fetch(`${API_BASE}/api/unlock`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ password })
+    })
+    if (res.ok) {
+      unlocked.value = true
+      devicePassword.value = password
+    } else {
+      alert('Invalid password')
+    }
+  } catch (e) {
+    console.error('Unlock error:', e)
+  }
+}
+
+async function handleSave(settings) {
+  settingsMessage.value = { text: 'Applying settings...', type: 'info' }
+  try {
+    const res = await fetch(`${API_BASE}/api/settings`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ password: devicePassword.value, settings })
+    })
+    if (res.ok) {
+      settingsMessage.value = { text: 'Settings applied successfully!', type: 'success' }
+    } else {
+      const text = await res.text()
+      settingsMessage.value = { text: `Error: ${text}`, type: 'error' }
+    }
+  } catch (e) {
+    settingsMessage.value = { text: `Error: ${e.message}`, type: 'error' }
+  }
+  // Clear message after 5 seconds
+  setTimeout(() => { settingsMessage.value = { text: '', type: '' } }, 5000)
+}
+
+onMounted(() => {
+  fetchStatus()
+  fetchMetrics()
+  fetchConfig()
+  fetchBLE()
+  fetchWiFi()
+  fetchLogs()
+
+  pollInterval = setInterval(() => {
+    fetchStatus()
+    fetchMetrics()
+    fetchConfig()
+    fetchLogs()
+  }, 5000)
+
+  connectWebSocket()
+})
+
+onUnmounted(() => {
+  if (pollInterval) clearInterval(pollInterval)
+  if (ws) ws.close()
+})
+</script>
+
+<style>
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html, body, #app {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  background: #0d1117;
+  color: #c9d1d9;
+  height: 100vh;
+  overflow: hidden;
+}
+
+.app {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  width: 100%;
+  overflow: hidden;
+}
+
+.header {
+  background: #161b22;
+  padding: 0.75rem 1.5rem;
+  border-bottom: 1px solid #30363d;
+  width: 100%;
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+}
+
+.status-indicator {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+}
+
+.status-indicator.ok {
+  background: #4ade80;
+  box-shadow: 0 0 8px #4ade80;
+}
+
+.status-indicator.error {
+  background: #ef4444;
+  box-shadow: 0 0 8px #ef4444;
+}
+
+.header h1 {
+  font-size: 1.125rem;
+  font-weight: 600;
+  color: #fff;
+}
+
+.device-badge {
+  font-family: monospace;
+  font-size: 0.75rem;
+  background: #21262d;
+  padding: 0.25rem 0.75rem;
+  border-radius: 12px;
+  color: #8b949e;
+}
+
+.tabs {
+  display: flex;
+  background: #161b22;
+  padding: 0 1.5rem;
+  gap: 0.25rem;
+  width: 100%;
+}
+
+.tabs button {
+  background: #21262d;
+  border: none;
+  color: #8b949e;
+  padding: 0.5rem 1rem;
+  cursor: pointer;
+  font-size: 0.875rem;
+  border-radius: 6px 6px 0 0;
+  transition: all 0.15s;
+}
+
+.tabs button:hover {
+  color: #c9d1d9;
+  background: #30363d;
+}
+
+.tabs button.active {
+  color: #fff;
+  background: #0d1117;
+}
+
+.content {
+  flex: 1;
+  padding: 1.5rem;
+  overflow-y: auto;
+  background: #0d1117;
+  width: 100%;
+}
+</style>

+ 1 - 0
dashboard/src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 294 - 0
dashboard/src/components/BLETab.vue

@@ -0,0 +1,294 @@
+<template>
+  <div class="ble-tab">
+    <!-- Filters -->
+    <div class="filters">
+      <input v-model="filterMac" type="text" placeholder="MAC..." class="filter-input" />
+      <select v-model="filterType" class="filter-select">
+        <option value="">All types</option>
+        <option value="ibeacon">iBeacon</option>
+        <option value="acc">ACC</option>
+        <option value="relay">Relay</option>
+      </select>
+      <input v-model="filterMajor" type="text" placeholder="Major..." class="filter-input small" />
+      <input v-model="filterMinor" type="text" placeholder="Minor..." class="filter-input small" />
+      <input v-model="filterUuid" type="text" placeholder="UUID..." class="filter-input" />
+      <span class="count">{{ filteredEvents.length }} events</span>
+    </div>
+
+    <!-- Event list -->
+    <div class="events-table">
+      <div class="table-header">
+        <span class="col-time">Time</span>
+        <span class="col-type">Type</span>
+        <span class="col-mac">MAC</span>
+        <span class="col-rssi">RSSI</span>
+        <span class="col-uuid">UUID</span>
+        <span class="col-majmin">Maj/Min</span>
+        <span class="col-data">Data</span>
+      </div>
+      <div class="table-body">
+        <div
+          v-for="(event, i) in filteredEvents.slice(0, 100)"
+          :key="i"
+          class="event-row"
+          :class="getEventClass(event.type)"
+        >
+          <span class="col-time">{{ formatTime(event.ts || event.ts_ms) }}</span>
+          <span class="col-type">
+            <span class="type-badge" :class="getEventClass(event.type)">{{ formatType(event.type) }}</span>
+          </span>
+          <span class="col-mac">{{ event.mac }}</span>
+          <span class="col-rssi" :class="getRssiClass(event.rssi)">{{ event.rssi }} dBm</span>
+          <span class="col-uuid">{{ event.uuid ? formatUuid(event.uuid) : '—' }}</span>
+          <span class="col-majmin">
+            <template v-if="event.major !== undefined">{{ event.major }}/{{ event.minor }}</template>
+            <template v-else>—</template>
+          </span>
+          <span class="col-data">
+            <template v-if="isAccType(event.type)">
+              x={{ event.x }} y={{ event.y }} z={{ event.z }} bat={{ event.bat }}%
+            </template>
+            <template v-else-if="event.type === 'rt_mybeacon'">
+              via {{ event.relay_mac }}
+            </template>
+            <template v-else>—</template>
+          </span>
+        </div>
+        <div v-if="filteredEvents.length === 0" class="no-events">
+          No events matching filters
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+
+const props = defineProps({
+  events: Array
+})
+
+const filterMac = ref('')
+const filterType = ref('')
+const filterMajor = ref('')
+const filterMinor = ref('')
+const filterUuid = ref('')
+
+const filteredEvents = computed(() => {
+  return (props.events || []).filter(e => {
+    if (filterMac.value && !e.mac?.toLowerCase().includes(filterMac.value.toLowerCase())) return false
+    if (filterType.value) {
+      if (filterType.value === 'ibeacon' && e.type !== 'ibeacon') return false
+      if (filterType.value === 'acc' && !isAccType(e.type)) return false
+      if (filterType.value === 'relay' && e.type !== 'rt_mybeacon') return false
+    }
+    if (filterMajor.value && String(e.major) !== filterMajor.value) return false
+    if (filterMinor.value && String(e.minor) !== filterMinor.value) return false
+    if (filterUuid.value && !e.uuid?.toLowerCase().includes(filterUuid.value.toLowerCase().replace(/-/g, ''))) return false
+    return true
+  })
+})
+
+function formatTime(ts) {
+  if (!ts) return ''
+  const d = new Date(ts)
+  return d.toLocaleTimeString()
+}
+
+function formatType(type) {
+  if (type === 'my-beacon_acc' || type === 'my-beacon_acc_ff') return 'ACC'
+  if (type === 'rt_mybeacon') return 'RELAY'
+  if (type === 'ibeacon') return 'iBEACON'
+  return type?.toUpperCase() || ''
+}
+
+function formatUuid(uuid) {
+  if (!uuid || uuid.length !== 32) return uuid
+  return `${uuid.slice(0,8)}-${uuid.slice(8,12)}-${uuid.slice(12,16)}-${uuid.slice(16,20)}-${uuid.slice(20)}`
+}
+
+function getEventClass(type) {
+  if (type === 'ibeacon') return 'ibeacon'
+  if (type === 'my-beacon_acc' || type === 'my-beacon_acc_ff') return 'acc'
+  if (type === 'rt_mybeacon') return 'relay'
+  return ''
+}
+
+function isAccType(type) {
+  return type === 'my-beacon_acc' || type === 'my-beacon_acc_ff'
+}
+
+function getRssiClass(rssi) {
+  if (rssi >= -50) return 'excellent'
+  if (rssi >= -65) return 'good'
+  if (rssi >= -80) return 'fair'
+  return 'poor'
+}
+</script>
+
+<style scoped>
+.ble-tab {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  height: 100%;
+}
+
+.filters {
+  display: flex;
+  gap: 0.5rem;
+  align-items: center;
+  flex-wrap: wrap;
+  padding: 0.75rem;
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+}
+
+.filter-input {
+  padding: 0.4rem 0.6rem;
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  color: #c9d1d9;
+  font-size: 0.8rem;
+  width: 120px;
+}
+
+.filter-input.small {
+  width: 80px;
+}
+
+.filter-input::placeholder {
+  color: #6e7681;
+}
+
+.filter-select {
+  padding: 0.4rem 0.6rem;
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  color: #c9d1d9;
+  font-size: 0.8rem;
+}
+
+.count {
+  margin-left: auto;
+  color: #8b949e;
+  font-size: 0.8rem;
+}
+
+/* Events Table */
+.events-table {
+  flex: 1;
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+
+.table-header {
+  display: grid;
+  grid-template-columns: 90px 70px 140px 70px 1fr 80px 180px;
+  gap: 0.5rem;
+  padding: 0.6rem 1rem;
+  background: #21262d;
+  font-size: 0.7rem;
+  font-weight: 600;
+  color: #8b949e;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  border-bottom: 1px solid #30363d;
+}
+
+.table-body {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.event-row {
+  display: grid;
+  grid-template-columns: 90px 70px 140px 70px 1fr 80px 180px;
+  gap: 0.5rem;
+  padding: 0.5rem 1rem;
+  font-size: 0.8rem;
+  border-bottom: 1px solid #21262d;
+  align-items: center;
+}
+
+.event-row:hover {
+  background: #1c2128;
+}
+
+.event-row:last-child {
+  border-bottom: none;
+}
+
+.col-time {
+  font-family: monospace;
+  font-size: 0.75rem;
+  color: #6e7681;
+}
+
+.col-type {
+  display: flex;
+}
+
+.type-badge {
+  font-size: 0.65rem;
+  padding: 0.15rem 0.4rem;
+  border-radius: 3px;
+  font-weight: 600;
+}
+
+.type-badge.ibeacon { background: #1e3a5f; color: #58a6ff; }
+.type-badge.acc { background: #1e3a1e; color: #4ade80; }
+.type-badge.relay { background: #3a2e1e; color: #f59e0b; }
+
+.col-mac {
+  font-family: monospace;
+  color: #c9d1d9;
+}
+
+.col-rssi {
+  font-family: monospace;
+  font-weight: 600;
+}
+
+.col-rssi.excellent { color: #4ade80; }
+.col-rssi.good { color: #86efac; }
+.col-rssi.fair { color: #fbbf24; }
+.col-rssi.poor { color: #ef4444; }
+
+.col-uuid {
+  font-family: monospace;
+  font-size: 0.7rem;
+  color: #6e7681;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.col-majmin {
+  font-family: monospace;
+  color: #f59e0b;
+}
+
+.col-data {
+  font-family: monospace;
+  font-size: 0.75rem;
+  color: #8b949e;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.no-events {
+  padding: 2rem;
+  text-align: center;
+  color: #6e7681;
+}
+</style>

+ 128 - 0
dashboard/src/components/LogTab.vue

@@ -0,0 +1,128 @@
+<template>
+  <div class="log-tab">
+    <div class="toolbar">
+      <input v-model="filter" type="text" placeholder="Filter logs..." class="filter-input" />
+      <span class="count">{{ filteredLogs.length }} lines</span>
+    </div>
+
+    <div class="log-container" ref="logContainer">
+      <div v-for="(line, i) in filteredLogs" :key="i" class="log-line" :class="getLogLevel(line)">
+        {{ line }}
+      </div>
+      <div v-if="filteredLogs.length === 0" class="no-logs">
+        No daemon logs available. Logs will appear here when the daemon outputs to stdout/stderr.
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch, nextTick } from 'vue'
+
+const props = defineProps({
+  logs: Array
+})
+
+const filter = ref('')
+const logContainer = ref(null)
+
+const filteredLogs = computed(() => {
+  const f = filter.value.toLowerCase()
+  return (props.logs || []).filter(line =>
+    !f || line.toLowerCase().includes(f)
+  )
+})
+
+function scrollToBottom() {
+  if (logContainer.value) {
+    logContainer.value.scrollTop = logContainer.value.scrollHeight
+  }
+}
+
+onMounted(() => {
+  nextTick(scrollToBottom)
+})
+
+// Auto-scroll when new logs arrive
+watch(() => props.logs?.length, () => {
+  nextTick(scrollToBottom)
+})
+
+function getLogLevel(line) {
+  const l = line.toLowerCase()
+  if (l.includes('error') || l.includes('failed') || l.includes('fatal')) return 'error'
+  if (l.includes('warn')) return 'warn'
+  if (l.includes('debug')) return 'debug'
+  return ''
+}
+</script>
+
+<style scoped>
+.log-tab {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  height: 100%;
+}
+
+.toolbar {
+  display: flex;
+  gap: 1rem;
+  align-items: center;
+  padding: 0.75rem;
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+}
+
+.filter-input {
+  flex: 1;
+  max-width: 300px;
+  padding: 0.4rem 0.6rem;
+  background: #0d1117;
+  border: 1px solid #30363d;
+  border-radius: 4px;
+  color: #c9d1d9;
+  font-size: 0.8rem;
+}
+
+.filter-input::placeholder {
+  color: #6e7681;
+}
+
+.count {
+  margin-left: auto;
+  color: #8b949e;
+  font-size: 0.8rem;
+}
+
+.log-container {
+  flex: 1;
+  background: #0d1117;
+  border-radius: 6px;
+  border: 1px solid #30363d;
+  padding: 1rem;
+  font-family: monospace;
+  font-size: 0.75rem;
+  overflow-y: auto;
+  max-height: calc(100vh - 250px);
+  text-align: left;
+}
+
+.log-line {
+  padding: 0.2rem 0;
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: #c9d1d9;
+}
+
+.log-line.error { color: #ef4444; }
+.log-line.warn { color: #f59e0b; }
+.log-line.debug { color: #6e7681; }
+
+.no-logs {
+  color: #6e7681;
+  text-align: left;
+  padding: 2rem;
+}
+</style>

+ 334 - 0
dashboard/src/components/SettingsTab.vue

@@ -0,0 +1,334 @@
+<template>
+  <div class="settings-tab">
+    <!-- Unlock form -->
+    <div v-if="!unlocked" class="unlock-form">
+      <h3>Enter Password to Access Settings</h3>
+      <div class="form-row">
+        <input
+          v-model="password"
+          type="password"
+          placeholder="Device password"
+          class="input"
+          @keyup.enter="unlock"
+        />
+        <button @click="unlock" class="btn primary">Unlock</button>
+      </div>
+    </div>
+
+    <!-- Settings form -->
+    <div v-else class="settings-form">
+      <div class="section">
+        <h3>Mode</h3>
+        <div class="radio-group">
+          <label>
+            <input type="radio" v-model="settings.mode" value="cloud" />
+            Cloud Mode - config from server
+          </label>
+          <label>
+            <input type="radio" v-model="settings.mode" value="lan" />
+            LAN Mode - local config only
+          </label>
+        </div>
+      </div>
+
+      <div class="section">
+        <h3>Network - eth0</h3>
+        <div class="form-row">
+          <label>Mode</label>
+          <select v-model="settings.eth0_mode" class="input">
+            <option value="dhcp">DHCP</option>
+            <option value="static">Static</option>
+          </select>
+        </div>
+        <template v-if="settings.eth0_mode === 'static'">
+          <div class="form-row">
+            <label>IP Address</label>
+            <input v-model="settings.eth0_ip" type="text" class="input" placeholder="192.168.1.100/24" />
+          </div>
+          <div class="form-row">
+            <label>Gateway</label>
+            <input v-model="settings.eth0_gateway" type="text" class="input" placeholder="192.168.1.1" />
+          </div>
+          <div class="form-row">
+            <label>DNS</label>
+            <input v-model="settings.eth0_dns" type="text" class="input" placeholder="8.8.8.8" />
+          </div>
+        </template>
+      </div>
+
+      <div class="section">
+        <h3>WiFi Client</h3>
+        <div class="form-row">
+          <label>SSID</label>
+          <input v-model="settings.wifi_ssid" type="text" class="input" placeholder="Network name" />
+        </div>
+        <div class="form-row">
+          <label>Password</label>
+          <input v-model="settings.wifi_psk" type="password" class="input" placeholder="WiFi password" />
+        </div>
+      </div>
+
+      <div class="section">
+        <h3>NTP</h3>
+        <div class="form-row">
+          <label>Servers</label>
+          <input v-model="settings.ntp_servers" type="text" class="input" placeholder="pool.ntp.org, time.google.com" />
+        </div>
+      </div>
+
+      <div class="section" v-if="settings.mode === 'lan'">
+        <h3>Endpoints (LAN Mode)</h3>
+        <div class="form-row">
+          <label>BLE Endpoint</label>
+          <input v-model="settings.endpoint_ble" type="text" class="input" placeholder="http://192.168.1.10:5000/ble" />
+        </div>
+        <div class="form-row">
+          <label>WiFi Endpoint</label>
+          <input v-model="settings.endpoint_wifi" type="text" class="input" placeholder="http://192.168.1.10:5000/wifi" />
+        </div>
+      </div>
+
+      <div class="actions">
+        <button @click="save" class="btn primary">Apply Settings</button>
+        <button @click="reset" class="btn">Reset</button>
+      </div>
+
+      <div v-if="message?.text" class="message" :class="message.type">
+        <span v-if="message.type === 'info'" class="spinner"></span>
+        <span v-else-if="message.type === 'success'" class="icon">&#10004;</span>
+        <span v-else-if="message.type === 'error'" class="icon">&#10006;</span>
+        {{ message.text }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, watch } from 'vue'
+
+const props = defineProps({
+  config: Object,
+  unlocked: Boolean,
+  message: Object
+})
+
+const emit = defineEmits(['unlock', 'save'])
+
+const password = ref('')
+const settingsLoaded = ref(false)
+const settings = reactive({
+  mode: 'cloud',
+  eth0_mode: 'dhcp',
+  eth0_ip: '',
+  eth0_gateway: '',
+  eth0_dns: '',
+  wifi_ssid: '',
+  wifi_psk: '',
+  ntp_servers: 'pool.ntp.org',
+  endpoint_ble: '',
+  endpoint_wifi: ''
+})
+
+// Load settings only ONCE when unlocked (not on every config poll)
+watch(() => props.unlocked, (unlocked) => {
+  if (unlocked && !settingsLoaded.value && props.config) {
+    loadFromConfig(props.config)
+    settingsLoaded.value = true
+  }
+}, { immediate: true })
+
+function loadFromConfig(cfg) {
+  if (!cfg) return
+  settings.mode = cfg.mode || 'cloud'
+  if (cfg.network) {
+    settings.eth0_mode = cfg.network.eth0?.mode || 'dhcp'
+    settings.eth0_ip = cfg.network.eth0?.static?.address || ''
+    settings.eth0_gateway = cfg.network.eth0?.static?.gateway || ''
+    settings.eth0_dns = cfg.network.eth0?.static?.dns?.join(', ') || ''
+    settings.wifi_ssid = cfg.network.wifi?.ssid || ''
+    settings.ntp_servers = cfg.network.ntp?.servers?.join(', ') || 'pool.ntp.org'
+  }
+}
+
+function unlock() {
+  emit('unlock', password.value)
+}
+
+function save() {
+  emit('save', { ...settings })
+}
+
+function reset() {
+  // Reset to config values
+  loadFromConfig(props.config)
+}
+</script>
+
+<style scoped>
+.settings-tab {
+  max-width: 600px;
+}
+
+.unlock-form {
+  background: #16213e;
+  border-radius: 8px;
+  padding: 2rem;
+  text-align: center;
+}
+
+.unlock-form h3 {
+  margin-bottom: 1rem;
+  color: #888;
+}
+
+.settings-form {
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+}
+
+.section {
+  background: #16213e;
+  border-radius: 8px;
+  padding: 1rem;
+  border: 1px solid #0f3460;
+}
+
+.section h3 {
+  font-size: 0.875rem;
+  color: #888;
+  margin-bottom: 1rem;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+.form-row {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  margin-bottom: 0.75rem;
+}
+
+.form-row:last-child {
+  margin-bottom: 0;
+}
+
+.form-row label {
+  min-width: 100px;
+  font-size: 0.875rem;
+  color: #aaa;
+}
+
+.input {
+  flex: 1;
+  padding: 0.5rem;
+  background: #0f3460;
+  border: 1px solid #1a4a7a;
+  border-radius: 4px;
+  color: #eee;
+  font-size: 0.875rem;
+}
+
+.input:focus {
+  outline: none;
+  border-color: #e94560;
+}
+
+select.input {
+  cursor: pointer;
+}
+
+.radio-group {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.radio-group label {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  font-size: 0.875rem;
+  color: #aaa;
+  cursor: pointer;
+}
+
+.radio-group input[type="radio"] {
+  accent-color: #e94560;
+}
+
+.actions {
+  display: flex;
+  gap: 1rem;
+  margin-top: 1rem;
+}
+
+.btn {
+  padding: 0.75rem 1.5rem;
+  background: #0f3460;
+  border: 1px solid #1a4a7a;
+  border-radius: 4px;
+  color: #eee;
+  cursor: pointer;
+  font-size: 0.875rem;
+  transition: all 0.2s;
+}
+
+.btn:hover {
+  background: #1a4a7a;
+}
+
+.btn.primary {
+  background: #e94560;
+  border-color: #e94560;
+}
+
+.btn.primary:hover {
+  background: #d63850;
+}
+
+.message {
+  margin-top: 1rem;
+  padding: 0.75rem 1rem;
+  border-radius: 6px;
+  font-size: 0.875rem;
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.spinner {
+  width: 16px;
+  height: 16px;
+  border: 2px solid #3b82f6;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.icon {
+  font-size: 1rem;
+}
+
+.message.info {
+  background: #1e3a5f;
+  border: 1px solid #3b82f6;
+  color: #93c5fd;
+}
+
+.message.success {
+  background: #14532d;
+  border: 1px solid #22c55e;
+  color: #86efac;
+}
+
+.message.error {
+  background: #450a0a;
+  border: 1px solid #ef4444;
+  color: #fca5a5;
+}
+</style>

+ 490 - 0
dashboard/src/components/StatusTab.vue

@@ -0,0 +1,490 @@
+<template>
+  <div class="status-tab">
+    <!-- Top section: System + Interfaces -->
+    <div class="top-section">
+      <div class="info-panel">
+        <div class="panel-title">SYSTEM</div>
+        <div class="info-row">
+          <span class="label">CPU Usage</span>
+          <span class="badge cpu">{{ metrics.cpu_percent?.toFixed(0) || 0 }}%</span>
+        </div>
+        <div class="info-row">
+          <span class="label">Temperature</span>
+          <span class="badge temp">{{ metrics.temperature?.toFixed(1) || 0 }}°C</span>
+        </div>
+        <div class="info-row">
+          <span class="label">Load Average</span>
+          <span class="badge load">{{ metrics.load_1m?.toFixed(2) || 0 }} / {{ metrics.load_5m?.toFixed(2) || 0 }} / {{ metrics.load_15m?.toFixed(2) || 0 }}</span>
+        </div>
+        <div class="info-row">
+          <span class="label">Memory</span>
+          <span class="badge mem">{{ metrics.mem_used_mb?.toFixed(0) || 0 }} MB / {{ metrics.mem_total_mb?.toFixed(0) || 0 }} MB ({{ memPercent }}%)</span>
+        </div>
+        <div class="info-row">
+          <span class="label">Uptime</span>
+          <span>{{ formatUptime(status.uptime_sec) }}</span>
+        </div>
+        <div class="info-row">
+          <span class="label">Mode</span>
+          <span>{{ status.mode || 'cloud' }}</span>
+        </div>
+      </div>
+
+      <div class="interfaces-panel">
+        <div class="panel-title">INTERFACES</div>
+        <div class="common-row">
+          <span>NTP Server</span>
+          <span>{{ status.network?.ntp || 'pool.ntp.org' }}</span>
+        </div>
+        <div class="iface-grid">
+          <div class="iface-card">
+            <div class="iface-name">Ethernet</div>
+            <div class="iface-row"><span>Address</span><span>{{ status.network?.eth0_ip || 'N/A' }}</span></div>
+            <div class="iface-row"><span>Gateway</span><span>{{ status.network?.gateway || 'N/A' }}</span></div>
+            <div class="iface-row"><span>DNS</span><span>{{ status.network?.dns || 'N/A' }}</span></div>
+            <div class="iface-row"><span>RX Total</span><span>{{ formatBytes(status.network?.eth0_rx) }}</span></div>
+            <div class="iface-row"><span>TX Total</span><span>{{ formatBytes(status.network?.eth0_tx) }}</span></div>
+          </div>
+          <div class="iface-card">
+            <div class="iface-name">Wireless</div>
+            <div class="iface-row"><span>Address</span><span>{{ status.network?.wlan0_ip || 'N/A' }}</span></div>
+            <div class="iface-row"><span>SSID</span><span>{{ wlanConnected ? status.network?.wlan0_ssid : 'N/A' }}</span></div>
+            <div class="iface-row"><span>Gateway</span><span>{{ wlanConnected ? status.network?.wlan0_gateway : 'N/A' }}</span></div>
+            <div class="iface-row"><span>DNS</span><span>{{ wlanConnected ? status.network?.wlan0_dns : 'N/A' }}</span></div>
+            <div class="iface-row"><span>Channel</span><span>{{ wlanConnected ? status.network?.wlan0_channel : 'N/A' }}</span></div>
+            <div class="iface-row"><span>RSSI</span><span>{{ wlanConnected && status.network?.wlan0_signal ? status.network.wlan0_signal + ' dBm' : 'N/A' }}</span></div>
+            <div class="iface-row"><span>RX Total</span><span>{{ wlanConnected ? formatBytes(status.network?.wlan0_rx) : 'N/A' }}</span></div>
+            <div class="iface-row"><span>TX Total</span><span>{{ wlanConnected ? formatBytes(status.network?.wlan0_tx) : 'N/A' }}</span></div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Live BLE Devices -->
+    <div class="devices-panel">
+      <div class="panel-title">LIVE BLE DEVICES (LAST 15 S)</div>
+      <div class="devices-grid">
+        <div
+          v-for="device in mergedDevices"
+          :key="device.mac"
+          class="device-card"
+        >
+          <div class="device-icon">
+            <!-- Beacon/wireless icon -->
+            <svg viewBox="-5.5 0 32 32" fill="currentColor">
+              <path d="M13.68 24.8h-2.28v-11.56c0-0.48-0.36-0.84-0.84-0.84s-0.84 0.36-0.84 0.84v11.56h-2.28c-0.48 0-0.84 0.36-0.84 0.84s0.36 0.84 0.84 0.84h6.24c0.44 0 0.84-0.4 0.84-0.84 0-0.48-0.36-0.84-0.84-0.84zM12.88 16.4c-0.2 0-0.44-0.080-0.6-0.24-0.32-0.32-0.32-0.84 0-1.2 0.48-0.48 0.72-1.080 0.72-1.72s-0.24-1.28-0.72-1.72c-0.32-0.32-0.32-0.84 0-1.2 0.32-0.32 0.84-0.32 1.2 0 0.76 0.76 1.2 1.8 1.2 2.92s-0.44 2.12-1.2 2.92c-0.16 0.16-0.4 0.24-0.6 0.24zM15.2 18.72c-0.2 0-0.44-0.080-0.6-0.24-0.32-0.32-0.32-0.84 0-1.2 1.080-1.080 1.68-2.52 1.68-4.040s-0.6-2.96-1.68-4.080c-0.32-0.32-0.32-0.84 0-1.2 0.32-0.32 0.84-0.32 1.2 0 1.4 1.4 2.16 3.28 2.16 5.24 0 2-0.76 3.84-2.16 5.24-0.16 0.2-0.36 0.28-0.6 0.28zM17.44 20.96c-0.2 0-0.44-0.080-0.6-0.24-0.32-0.32-0.32-0.84 0-1.2 1.68-1.68 2.6-3.92 2.6-6.28s-0.92-4.6-2.6-6.28c-0.32-0.32-0.32-0.84 0-1.2 0.32-0.32 0.84-0.32 1.2 0 2 2 3.080 4.64 3.080 7.48 0 2.8-1.080 5.48-3.080 7.48-0.2 0.16-0.4 0.24-0.6 0.24zM7.64 16.16c-0.76-0.8-1.2-1.8-1.2-2.92s0.44-2.16 1.2-2.92c0.36-0.32 0.88-0.32 1.2 0 0.32 0.36 0.32 0.88 0 1.2-0.48 0.44-0.72 1.080-0.72 1.72s0.24 1.24 0.72 1.72c0.32 0.36 0.32 0.88 0 1.2-0.16 0.16-0.4 0.24-0.6 0.24s-0.44-0.080-0.6-0.24zM5.32 18.44c-1.4-1.4-2.16-3.24-2.16-5.24 0-1.96 0.76-3.84 2.16-5.24 0.36-0.32 0.88-0.32 1.2 0 0.32 0.36 0.32 0.88 0 1.2-1.080 1.12-1.68 2.56-1.68 4.080s0.6 2.96 1.68 4.040c0.32 0.36 0.32 0.88 0 1.2-0.16 0.16-0.4 0.24-0.6 0.24-0.24 0-0.44-0.080-0.6-0.28zM3.080 20.72c-2-2-3.080-4.68-3.080-7.48 0-2.84 1.080-5.48 3.080-7.48 0.36-0.32 0.88-0.32 1.2 0 0.32 0.36 0.32 0.88 0 1.2-1.68 1.68-2.6 3.92-2.6 6.28s0.92 4.6 2.6 6.28c0.32 0.36 0.32 0.88 0 1.2-0.16 0.16-0.4 0.24-0.6 0.24s-0.4-0.080-0.6-0.24z"/>
+            </svg>
+          </div>
+          <div class="device-info">
+            <div class="device-main">
+              <span class="device-mac">{{ device.mac }}</span>
+              <span class="device-types">
+                <span v-if="device.hasIbeacon" class="type-tag ibeacon">iBeacon</span>
+                <span v-if="device.hasAcc" class="type-tag acc">ACC</span>
+                <span v-if="device.hasRelay" class="type-tag relay">Relay</span>
+              </span>
+            </div>
+            <div class="device-uuid" v-if="device.uuid">{{ formatUuid(device.uuid) }}</div>
+            <div class="device-details">
+              <span class="rssi-badge" :class="getRssiClass(device.rssi)">{{ device.rssi }} dBm</span>
+              <span class="bat-badge" v-if="device.bat !== undefined">{{ device.bat }}%</span>
+              <span class="majmin" v-if="device.major !== undefined">maj:{{ device.major }} min:{{ device.minor }}</span>
+              <span class="accel" v-if="device.x !== undefined">x={{ device.x }} y={{ device.y }} z={{ device.z }}</span>
+            </div>
+          </div>
+          <div class="ttl-bar">
+            <div class="ttl-fill" :style="{ width: device.ttlPercent + '%', transition: 'width 1s linear' }"></div>
+          </div>
+        </div>
+        <div v-if="mergedDevices.length === 0" class="no-devices">
+          No devices detected
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, onMounted, onUnmounted } from 'vue'
+
+const props = defineProps({
+  status: Object,
+  metrics: Object,
+  bleEvents: Array
+})
+
+// For live TTL updates
+const now = ref(Date.now())
+let tickInterval = null
+
+// Track order of device appearance (MAC addresses in order they first appeared)
+const deviceOrder = ref([])
+
+onMounted(() => {
+  tickInterval = setInterval(() => {
+    now.value = Date.now()
+  }, 1000)
+})
+
+onUnmounted(() => {
+  if (tickInterval) clearInterval(tickInterval)
+})
+
+const memPercent = computed(() => {
+  if (!props.metrics?.mem_total_mb) return 0
+  return Math.round((props.metrics.mem_used_mb / props.metrics.mem_total_mb) * 100)
+})
+
+const wlanConnected = computed(() => {
+  return !!props.status?.network?.wlan0_ip
+})
+
+
+// Merge acc and ibeacon events from same device
+const mergedDevices = computed(() => {
+  const events = props.bleEvents || []
+  if (events.length === 0) {
+    deviceOrder.value = []
+    return []
+  }
+
+  const currentTime = now.value
+  const ttl = 15000
+  const devices = new Map()
+
+  // Find max timestamp from events
+  const maxTs = Math.max(...events.map(e => e.ts || e.ts_ms || 0))
+
+  // Use current time if events are recent, otherwise use maxTs
+  const refTime = (currentTime - maxTs) < 30000 ? currentTime : maxTs
+
+  for (const event of events) {
+    const ts = event.ts || event.ts_ms || 0
+    const age = refTime - ts
+    if (age > ttl) continue
+
+    const mac = event.mac
+    if (!devices.has(mac)) {
+      devices.set(mac, {
+        mac,
+        rssi: event.rssi,
+        ts,
+        age,
+        ttlPercent: Math.max(0, Math.min(100, ((ttl - age) / ttl) * 100)),
+        hasIbeacon: false,
+        hasAcc: false,
+        hasRelay: false
+      })
+
+      // Add to order list if new device
+      if (!deviceOrder.value.includes(mac)) {
+        deviceOrder.value.push(mac)
+      }
+    }
+
+    const dev = devices.get(mac)
+
+    // Update if newer
+    if (ts > dev.ts) {
+      dev.rssi = event.rssi
+      dev.ts = ts
+      dev.age = refTime - ts
+      dev.ttlPercent = Math.max(0, Math.min(100, ((ttl - dev.age) / ttl) * 100))
+    }
+
+    // Merge data based on type
+    if (event.type === 'ibeacon') {
+      dev.hasIbeacon = true
+      dev.uuid = event.uuid
+      dev.major = event.major
+      dev.minor = event.minor
+    } else if (event.type === 'my-beacon_acc' || event.type === 'my-beacon_acc_ff') {
+      dev.hasAcc = true
+      dev.x = event.x
+      dev.y = event.y
+      dev.z = event.z
+      dev.bat = event.bat
+      dev.temp = event.temp
+      dev.ff = event.ff
+    } else if (event.type === 'rt_mybeacon') {
+      dev.hasRelay = true
+      dev.relay_mac = event.relay_mac
+    }
+  }
+
+  // Remove expired devices from order list
+  deviceOrder.value = deviceOrder.value.filter(mac => devices.has(mac))
+
+  // Return devices in order of first appearance
+  return deviceOrder.value.map(mac => devices.get(mac)).filter(d => d)
+})
+
+function formatUuid(uuid) {
+  if (!uuid || uuid.length !== 32) return uuid
+  return `${uuid.slice(0,8)}-${uuid.slice(8,12)}-${uuid.slice(12,16)}-${uuid.slice(16,20)}-${uuid.slice(20)}`
+}
+
+function formatUptime(seconds) {
+  if (!seconds) return '—'
+  const d = Math.floor(seconds / 86400)
+  const h = Math.floor((seconds % 86400) / 3600)
+  const m = Math.floor((seconds % 3600) / 60)
+  const s = seconds % 60
+  if (d > 0) return `${d}d ${h}h ${m}m`
+  if (h > 0) return `${h}h ${m}m ${s}s`
+  return `${m}m ${s}s`
+}
+
+function formatBytes(bytes) {
+  if (bytes === undefined || bytes === null) return 'N/A'
+  if (bytes < 1024) return bytes + ' B'
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
+  if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
+  return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
+}
+
+function getRssiClass(rssi) {
+  if (rssi >= -50) return 'excellent'
+  if (rssi >= -65) return 'good'
+  if (rssi >= -80) return 'fair'
+  return 'poor'
+}
+</script>
+
+<style scoped>
+.status-tab {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.top-section {
+  display: grid;
+  grid-template-columns: 1fr 2fr;
+  gap: 1rem;
+}
+
+.info-panel, .interfaces-panel, .devices-panel {
+  background: #161b22;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  padding: 1rem;
+}
+
+.panel-title {
+  font-size: 0.75rem;
+  color: #666;
+  margin-bottom: 0.75rem;
+  letter-spacing: 0.05em;
+}
+
+.common-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 0.5rem 0.75rem;
+  margin-bottom: 0.75rem;
+  background: #0d1117;
+  border: 1px solid #21262d;
+  border-radius: 4px;
+  font-size: 0.85rem;
+  color: #aaa;
+}
+
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 0.3rem 0;
+  font-size: 0.875rem;
+}
+
+.info-row .label {
+  color: #888;
+}
+
+.badge {
+  background: #2d4a6f;
+  padding: 0.125rem 0.5rem;
+  border-radius: 3px;
+  font-size: 0.8rem;
+}
+
+.badge.cpu { background: #4a2d6f; }
+.badge.temp { background: #1e5631; }
+.badge.load { background: #1e4a5a; }
+.badge.mem { background: #2d3a4a; }
+
+/* Interfaces */
+.iface-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 1rem;
+}
+
+.iface-card {
+  background: #0d1117;
+  border: 1px solid #21262d;
+  border-radius: 4px;
+  padding: 0.75rem;
+}
+
+.iface-name {
+  font-weight: 600;
+  margin-bottom: 0.5rem;
+  padding-bottom: 0.5rem;
+  border-bottom: 1px solid #30363d;
+}
+
+.iface-row {
+  display: flex;
+  justify-content: space-between;
+  font-size: 0.8rem;
+  padding: 0.2rem 0;
+  color: #aaa;
+}
+
+/* Devices Grid */
+.devices-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
+  gap: 0.75rem;
+}
+
+.device-card {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.75rem;
+  background: #0d1117;
+  border: 1px solid #21262d;
+  border-radius: 4px;
+  padding: 0.75rem;
+  position: relative;
+  overflow: hidden;
+}
+
+.device-icon {
+  width: 28px;
+  height: 28px;
+  color: #58a6ff;
+  flex-shrink: 0;
+}
+
+.device-icon svg {
+  width: 100%;
+  height: 100%;
+}
+
+.device-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.device-main {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex-wrap: wrap;
+}
+
+.device-mac {
+  font-family: monospace;
+  font-size: 0.9rem;
+  font-weight: 500;
+  color: #c9d1d9;
+}
+
+.device-types {
+  display: flex;
+  gap: 0.25rem;
+}
+
+.type-tag {
+  font-size: 0.65rem;
+  padding: 0.1rem 0.35rem;
+  border-radius: 3px;
+  font-weight: 600;
+  text-transform: uppercase;
+}
+
+.type-tag.ibeacon { background: #1e3a5f; color: #58a6ff; }
+.type-tag.acc { background: #1e3a1e; color: #4ade80; }
+.type-tag.relay { background: #3a2e1e; color: #f59e0b; }
+
+.device-uuid {
+  font-family: monospace;
+  font-size: 0.7rem;
+  color: #6e7681;
+  margin-top: 0.25rem;
+  word-break: break-all;
+}
+
+.device-details {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-top: 0.35rem;
+  flex-wrap: wrap;
+}
+
+.rssi-badge {
+  font-size: 0.75rem;
+  font-weight: 600;
+}
+
+.rssi-badge.excellent { color: #4ade80; }
+.rssi-badge.good { color: #86efac; }
+.rssi-badge.fair { color: #fbbf24; }
+.rssi-badge.poor { color: #ef4444; }
+
+.bat-badge {
+  font-size: 0.7rem;
+  background: #1e3a1e;
+  color: #4ade80;
+  padding: 0.1rem 0.35rem;
+  border-radius: 3px;
+}
+
+.majmin {
+  font-size: 0.75rem;
+  color: #f59e0b;
+}
+
+.accel {
+  font-size: 0.7rem;
+  color: #8b949e;
+  font-family: monospace;
+}
+
+.ttl-bar {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 4px;
+  background: #21262d;
+}
+
+.ttl-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #ef4444 0%, #f59e0b 30%, #4ade80 100%);
+  border-radius: 0 2px 2px 0;
+}
+
+.no-devices {
+  color: #666;
+  text-align: center;
+  padding: 2rem;
+  grid-column: 1 / -1;
+}
+
+@media (max-width: 768px) {
+  .top-section {
+    grid-template-columns: 1fr;
+  }
+  .iface-grid {
+    grid-template-columns: 1fr;
+  }
+  .devices-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 153 - 0
dashboard/src/components/WiFiTab.vue

@@ -0,0 +1,153 @@
+<template>
+  <div class="wifi-tab">
+    <!-- Warning when WiFi client is active -->
+    <div v-if="wifiClientActive" class="client-warning">
+      <svg class="warning-icon" viewBox="0 0 24 24" fill="currentColor">
+        <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
+      </svg>
+      <span>WiFi scanning unavailable — radio module is busy with client connection</span>
+    </div>
+
+    <div class="toolbar" v-if="!wifiClientActive">
+      <input v-model="filter" type="text" placeholder="Filter by MAC or SSID..." class="filter-input" />
+      <span class="count">{{ filteredEvents.length }} events</span>
+    </div>
+
+    <div class="events-list" v-if="!wifiClientActive">
+      <div v-for="(event, i) in filteredEvents" :key="i" class="event-row">
+        <span class="time">{{ formatTime(event.ts_ms) }}</span>
+        <span class="mac">{{ event.mac }}</span>
+        <span class="rssi">{{ event.rssi }} dBm</span>
+        <span class="ssid">{{ event.ssid || '(hidden)' }}</span>
+      </div>
+      <div v-if="filteredEvents.length === 0" class="no-events">
+        No WiFi probe requests captured
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+
+const props = defineProps({
+  events: Array,
+  wifiClientActive: Boolean
+})
+
+const filter = ref('')
+
+const filteredEvents = computed(() => {
+  const f = filter.value.toLowerCase()
+  return (props.events || []).filter(e =>
+    !f || e.mac?.toLowerCase().includes(f) || e.ssid?.toLowerCase().includes(f)
+  )
+})
+
+function formatTime(ts) {
+  if (!ts) return ''
+  const d = new Date(ts)
+  return d.toLocaleTimeString()
+}
+</script>
+
+<style scoped>
+.wifi-tab {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.toolbar {
+  display: flex;
+  gap: 1rem;
+  align-items: center;
+}
+
+.filter-input {
+  flex: 1;
+  max-width: 300px;
+  padding: 0.5rem;
+  background: #16213e;
+  border: 1px solid #0f3460;
+  border-radius: 4px;
+  color: #eee;
+  font-size: 0.875rem;
+}
+
+.filter-input::placeholder {
+  color: #666;
+}
+
+.count {
+  color: #888;
+  font-size: 0.875rem;
+}
+
+.events-list {
+  background: #16213e;
+  border-radius: 8px;
+  border: 1px solid #0f3460;
+  overflow: hidden;
+}
+
+.event-row {
+  display: grid;
+  grid-template-columns: 80px 140px 70px 1fr;
+  gap: 0.5rem;
+  padding: 0.5rem 1rem;
+  border-bottom: 1px solid #0f3460;
+  font-size: 0.8125rem;
+  align-items: center;
+}
+
+.event-row:last-child {
+  border-bottom: none;
+}
+
+.time {
+  color: #666;
+  font-family: monospace;
+}
+
+.mac {
+  font-family: monospace;
+  color: #aaa;
+}
+
+.rssi {
+  text-align: right;
+}
+
+.ssid {
+  color: #4ade80;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.no-events {
+  padding: 2rem;
+  text-align: center;
+  color: #666;
+}
+
+.client-warning {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+  padding: 1rem 1.25rem;
+  background: #3a2e1e;
+  border: 1px solid #f59e0b;
+  border-radius: 8px;
+  color: #fbbf24;
+  font-size: 0.9rem;
+}
+
+.warning-icon {
+  width: 24px;
+  height: 24px;
+  flex-shrink: 0;
+  color: #f59e0b;
+}
+</style>

+ 5 - 0
dashboard/src/main.js

@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import './style.css'
+import App from './App.vue'
+
+createApp(App).mount('#app')

+ 79 - 0
dashboard/src/style.css

@@ -0,0 +1,79 @@
+:root {
+  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+  line-height: 1.5;
+  font-weight: 400;
+
+  color-scheme: light dark;
+  color: rgba(255, 255, 255, 0.87);
+  background-color: #242424;
+
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+  font-weight: 500;
+  color: #646cff;
+  text-decoration: inherit;
+}
+a:hover {
+  color: #535bf2;
+}
+
+body {
+  margin: 0;
+  display: flex;
+  place-items: center;
+  min-width: 320px;
+  min-height: 100vh;
+}
+
+h1 {
+  font-size: 3.2em;
+  line-height: 1.1;
+}
+
+button {
+  border-radius: 8px;
+  border: 1px solid transparent;
+  padding: 0.6em 1.2em;
+  font-size: 1em;
+  font-weight: 500;
+  font-family: inherit;
+  background-color: #1a1a1a;
+  cursor: pointer;
+  transition: border-color 0.25s;
+}
+button:hover {
+  border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+  outline: 4px auto -webkit-focus-ring-color;
+}
+
+.card {
+  padding: 2em;
+}
+
+#app {
+  max-width: 1280px;
+  margin: 0 auto;
+  padding: 2rem;
+  text-align: center;
+}
+
+@media (prefers-color-scheme: light) {
+  :root {
+    color: #213547;
+    background-color: #ffffff;
+  }
+  a:hover {
+    color: #747bff;
+  }
+  button {
+    background-color: #f9f9f9;
+  }
+}

+ 22 - 0
dashboard/vite.config.js

@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  server: {
+    host: '0.0.0.0',
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'http://192.168.5.244:8080',
+        changeOrigin: true,
+        ws: true,
+      },
+    },
+  },
+  build: {
+    outDir: 'dist',
+    assetsDir: 'assets',
+  },
+})

+ 17 - 0
go.mod

@@ -0,0 +1,17 @@
+module mybeacon
+
+go 1.21
+
+require (
+	github.com/go-zeromq/zmq4 v0.16.0
+	github.com/godbus/dbus/v5 v5.0.3
+	golang.org/x/crypto v0.17.0
+)
+
+require (
+	github.com/go-zeromq/goczmq/v4 v4.2.2 // indirect
+	github.com/gorilla/websocket v1.5.3 // indirect
+	golang.org/x/sync v0.5.0 // indirect
+	golang.org/x/sys v0.15.0 // indirect
+	golang.org/x/text v0.14.0 // indirect
+)

+ 18 - 0
go.sum

@@ -0,0 +1,18 @@
+github.com/go-zeromq/goczmq/v4 v4.2.2 h1:HAJN+i+3NW55ijMJJhk7oWxHKXgAuSBkoFfvr8bYj4U=
+github.com/go-zeromq/goczmq/v4 v4.2.2/go.mod h1:Sm/lxrfxP/Oxqs0tnHD6WAhwkWrx+S+1MRrKzcxoaYE=
+github.com/go-zeromq/zmq4 v0.16.0 h1:D6oIPWSdkY/4DJu4tBUmo28P3WRq4F4Ji4/iQ/fJHc0=
+github.com/go-zeromq/zmq4 v0.16.0/go.mod h1:8c3aXloJBRPba1AqWMJK4vypniM+yC+JKqi8KpRaDFc=
+github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME=
+github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
+golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
+golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

+ 167 - 0
internal/ble/hci.go

@@ -0,0 +1,167 @@
+// Package ble provides BLE scanning via raw HCI sockets
+package ble
+
+import (
+	"encoding/binary"
+	"fmt"
+	"syscall"
+	"unsafe"
+)
+
+// HCI constants
+const (
+	AF_BLUETOOTH    = 31
+	BTPROTO_HCI     = 1
+	SOL_HCI         = 0
+	HCI_FILTER      = 2
+
+	// HCI packet types
+	HCI_COMMAND_PKT = 0x01
+	HCI_EVENT_PKT   = 0x04
+
+	// HCI events
+	EVT_LE_META     = 0x3E
+	EVT_CMD_COMPLETE = 0x0E
+
+	// LE Meta sub-events
+	EVT_LE_ADVERTISING_REPORT = 0x02
+
+	// OGF (Opcode Group Field)
+	OGF_LE_CTL = 0x08
+
+	// OCF (Opcode Command Field)
+	OCF_LE_SET_SCAN_PARAMETERS = 0x000B
+	OCF_LE_SET_SCAN_ENABLE     = 0x000C
+
+	// Scan types
+	SCAN_TYPE_PASSIVE = 0x00
+	SCAN_TYPE_ACTIVE  = 0x01
+)
+
+// sockaddrHCI is the HCI socket address
+type sockaddrHCI struct {
+	Family  uint16
+	Dev     uint16
+	Channel uint16
+}
+
+// hciFilter is the HCI event filter
+type hciFilter struct {
+	TypeMask  uint32
+	EventMask [2]uint32
+	Opcode    uint16
+}
+
+// HCISocket represents a raw HCI socket
+type HCISocket struct {
+	fd      int
+	devID   int
+}
+
+// NewHCISocket creates a new HCI socket for the given device
+func NewHCISocket(devID int) (*HCISocket, error) {
+	fd, err := syscall.Socket(AF_BLUETOOTH, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, BTPROTO_HCI)
+	if err != nil {
+		return nil, fmt.Errorf("socket: %w", err)
+	}
+
+	// Bind to device
+	addr := sockaddrHCI{
+		Family:  AF_BLUETOOTH,
+		Dev:     uint16(devID),
+		Channel: 0, // HCI_CHANNEL_RAW
+	}
+
+	_, _, errno := syscall.Syscall(
+		syscall.SYS_BIND,
+		uintptr(fd),
+		uintptr(unsafe.Pointer(&addr)),
+		unsafe.Sizeof(addr),
+	)
+	if errno != 0 {
+		syscall.Close(fd)
+		return nil, fmt.Errorf("bind: %v", errno)
+	}
+
+	return &HCISocket{fd: fd, devID: devID}, nil
+}
+
+// SetEventFilter sets the HCI event filter to receive LE Meta events
+func (h *HCISocket) SetEventFilter() error {
+	var filter hciFilter
+
+	// Enable HCI_EVENT_PKT
+	filter.TypeMask = 1 << HCI_EVENT_PKT
+
+	// Enable EVT_LE_META (0x3E = 62) and EVT_CMD_COMPLETE (0x0E = 14) events
+	// EventMask is split: [0] = events 0-31, [1] = events 32-63
+	filter.EventMask[0] = 1 << EVT_CMD_COMPLETE  // bit 14
+	filter.EventMask[1] = 1 << (EVT_LE_META - 32) // bit 30 in second word (62-32=30)
+
+	_, _, errno := syscall.Syscall6(
+		syscall.SYS_SETSOCKOPT,
+		uintptr(h.fd),
+		SOL_HCI,
+		HCI_FILTER,
+		uintptr(unsafe.Pointer(&filter)),
+		unsafe.Sizeof(filter),
+		0,
+	)
+	if errno != 0 {
+		return fmt.Errorf("setsockopt HCI_FILTER: %v", errno)
+	}
+	return nil
+}
+
+// SendCommand sends an HCI command
+func (h *HCISocket) SendCommand(ogf, ocf uint16, params []byte) error {
+	opcode := uint16(ocf) | (uint16(ogf) << 10)
+
+	buf := make([]byte, 4+len(params))
+	buf[0] = HCI_COMMAND_PKT
+	binary.LittleEndian.PutUint16(buf[1:3], opcode)
+	buf[3] = byte(len(params))
+	copy(buf[4:], params)
+
+	_, err := syscall.Write(h.fd, buf)
+	return err
+}
+
+// SetScanParameters sets LE scan parameters
+func (h *HCISocket) SetScanParameters(scanType byte, interval, window uint16) error {
+	params := make([]byte, 7)
+	params[0] = scanType
+	binary.LittleEndian.PutUint16(params[1:3], interval) // interval (units of 0.625ms)
+	binary.LittleEndian.PutUint16(params[3:5], window)   // window (units of 0.625ms)
+	params[5] = 0x00 // own address type: public
+	params[6] = 0x00 // filter policy: accept all
+
+	return h.SendCommand(OGF_LE_CTL, OCF_LE_SET_SCAN_PARAMETERS, params)
+}
+
+// SetScanEnable enables or disables LE scanning
+func (h *HCISocket) SetScanEnable(enable bool, filterDuplicates bool) error {
+	params := make([]byte, 2)
+	if enable {
+		params[0] = 0x01
+	}
+	if filterDuplicates {
+		params[1] = 0x01
+	}
+	return h.SendCommand(OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, params)
+}
+
+// Read reads an HCI event packet
+func (h *HCISocket) Read(buf []byte) (int, error) {
+	return syscall.Read(h.fd, buf)
+}
+
+// Close closes the HCI socket
+func (h *HCISocket) Close() error {
+	return syscall.Close(h.fd)
+}
+
+// Fd returns the file descriptor
+func (h *HCISocket) Fd() int {
+	return h.fd
+}

+ 250 - 0
internal/ble/parser.go

@@ -0,0 +1,250 @@
+package ble
+
+import (
+	"encoding/binary"
+	"fmt"
+	"time"
+
+	"mybeacon/internal/protocol"
+)
+
+// Advertisement data types (EIR/AD)
+const (
+	ADTypeFlags              = 0x01
+	ADTypeIncompleteUUID16   = 0x02
+	ADTypeCompleteUUID16     = 0x03
+	ADTypeShortenedName      = 0x08
+	ADTypeCompleteName       = 0x09
+	ADTypeManufacturerData   = 0xFF
+)
+
+// Manufacturer IDs
+const (
+	MfgApple  = 0x004C
+	MfgNordic = 0x0059
+)
+
+// AdvReport represents a parsed LE Advertising Report
+type AdvReport struct {
+	EventType byte
+	AddrType  byte
+	Addr      [6]byte
+	Data      []byte
+	RSSI      int8
+}
+
+// ParseLEAdvertisingReport parses LE Meta Event sub-event 0x02
+// Format: subevent(1) + num_reports(1) + reports...
+func ParseLEAdvertisingReport(data []byte) ([]AdvReport, error) {
+	if len(data) < 2 {
+		return nil, fmt.Errorf("data too short")
+	}
+
+	subevent := data[0]
+	if subevent != EVT_LE_ADVERTISING_REPORT {
+		return nil, nil // not an advertising report
+	}
+
+	numReports := int(data[1])
+	if numReports == 0 {
+		return nil, nil
+	}
+
+	reports := make([]AdvReport, 0, numReports)
+	offset := 2
+
+	// Parse each report
+	for i := 0; i < numReports && offset < len(data); i++ {
+		if offset+9 > len(data) {
+			break
+		}
+
+		report := AdvReport{
+			EventType: data[offset],
+			AddrType:  data[offset+1],
+		}
+		copy(report.Addr[:], data[offset+2:offset+8])
+
+		dataLen := int(data[offset+8])
+		offset += 9
+
+		if offset+dataLen > len(data) {
+			break
+		}
+
+		report.Data = make([]byte, dataLen)
+		copy(report.Data, data[offset:offset+dataLen])
+		offset += dataLen
+
+		if offset < len(data) {
+			report.RSSI = int8(data[offset])
+			offset++
+		}
+
+		reports = append(reports, report)
+	}
+
+	return reports, nil
+}
+
+// MACString converts a Bluetooth address to string (reversed byte order)
+func MACString(addr [6]byte) string {
+	return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
+		addr[5], addr[4], addr[3], addr[2], addr[1], addr[0])
+}
+
+// ParseManufacturerData extracts manufacturer data from advertisement
+// Returns: manufacturer ID, payload (without ID), found
+func ParseManufacturerData(advData []byte) (uint16, []byte, bool) {
+	offset := 0
+	for offset < len(advData) {
+		if offset+1 >= len(advData) {
+			break
+		}
+		length := int(advData[offset])
+		if length == 0 {
+			break
+		}
+		if offset+1+length > len(advData) {
+			break
+		}
+		adType := advData[offset+1]
+		adData := advData[offset+2 : offset+1+length]
+
+		if adType == ADTypeManufacturerData && len(adData) >= 2 {
+			mfgID := binary.LittleEndian.Uint16(adData[0:2])
+			return mfgID, adData[2:], true
+		}
+
+		offset += 1 + length
+	}
+	return 0, nil, false
+}
+
+// ParseIBeacon parses Apple iBeacon manufacturer data
+// Format: 0x02 0x15 + UUID(16) + Major(2) + Minor(2) + TxPower(1)
+func ParseIBeacon(payload []byte) (*protocol.IBeaconEvent, bool) {
+	if len(payload) < 23 {
+		return nil, false
+	}
+	if payload[0] != 0x02 || payload[1] != 0x15 {
+		return nil, false
+	}
+
+	uuid := fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
+		payload[2], payload[3], payload[4], payload[5],
+		payload[6], payload[7],
+		payload[8], payload[9],
+		payload[10], payload[11],
+		payload[12], payload[13], payload[14], payload[15], payload[16], payload[17])
+
+	major := binary.BigEndian.Uint16(payload[18:20])
+	minor := binary.BigEndian.Uint16(payload[20:22])
+
+	return &protocol.IBeaconEvent{
+		BLEEvent: protocol.BLEEvent{
+			Type: protocol.EventIBeacon,
+			TsMs: time.Now().UnixMilli(),
+		},
+		UUID:  uuid,
+		Major: major,
+		Minor: minor,
+	}, true
+}
+
+// ParseMyBeaconAcc parses my-beacon accelerometer data (Nordic 0x0059)
+// Format: 0x01 0x15 + X(s8) + Y(s8) + Z(s8) + Bat(u8) + Temp(s8) + FF(u8)
+func ParseMyBeaconAcc(payload []byte) (*protocol.AccelEvent, bool) {
+	if len(payload) < 8 {
+		return nil, false
+	}
+	if payload[0] != 0x01 || payload[1] != 0x15 {
+		return nil, false
+	}
+
+	ff := payload[7] != 0
+	evType := protocol.EventAccel
+	if ff {
+		evType = protocol.EventAccelFF
+	}
+
+	return &protocol.AccelEvent{
+		BLEEvent: protocol.BLEEvent{
+			Type: evType,
+			TsMs: time.Now().UnixMilli(),
+		},
+		X:    int8(payload[2]),
+		Y:    int8(payload[3]),
+		Z:    int8(payload[4]),
+		Bat:  payload[5],
+		Temp: int8(payload[6]),
+		FF:   ff,
+	}, true
+}
+
+// ParseMyBeaconRelay parses rt_mybeacon relay data (Nordic 0x0059)
+// Format: 0x02 0x15 + DEADBEEF(4) + RelayMAC(6) + RelayMaj(2) + RelayMin(2) + RelayRSSI(1) + RelayBat(1) + IBMaj(2) + IBMin(2)
+func ParseMyBeaconRelay(payload []byte) (*protocol.RelayEvent, bool) {
+	if len(payload) < 22 {
+		return nil, false
+	}
+	if payload[0] != 0x02 || payload[1] != 0x15 {
+		return nil, false
+	}
+	// Check magic DEADBEEF
+	if payload[2] != 0xDE || payload[3] != 0xAD || payload[4] != 0xBE || payload[5] != 0xEF {
+		return nil, false
+	}
+
+	relayMAC := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
+		payload[6], payload[7], payload[8], payload[9], payload[10], payload[11])
+
+	return &protocol.RelayEvent{
+		BLEEvent: protocol.BLEEvent{
+			Type: protocol.EventRelay,
+			TsMs: time.Now().UnixMilli(),
+		},
+		RelayMAC:  relayMAC,
+		RelayMaj:  binary.BigEndian.Uint16(payload[12:14]),
+		RelayMin:  binary.BigEndian.Uint16(payload[14:16]),
+		RelayRSSI: int8(payload[16]),
+		RelayBat:  payload[17],
+		IBMajor:   binary.BigEndian.Uint16(payload[18:20]),
+		IBMinor:   binary.BigEndian.Uint16(payload[20:22]),
+	}, true
+}
+
+// ParseAdvertisement parses a BLE advertisement and returns any recognized events
+func ParseAdvertisement(report AdvReport) interface{} {
+	mac := MACString(report.Addr)
+
+	mfgID, payload, found := ParseManufacturerData(report.Data)
+	if !found {
+		return nil
+	}
+
+	switch mfgID {
+	case MfgApple:
+		if ev, ok := ParseIBeacon(payload); ok {
+			ev.MAC = mac
+			ev.RSSI = report.RSSI
+			return ev
+		}
+
+	case MfgNordic:
+		// Try accelerometer first
+		if ev, ok := ParseMyBeaconAcc(payload); ok {
+			ev.MAC = mac
+			ev.RSSI = report.RSSI
+			return ev
+		}
+		// Try relay
+		if ev, ok := ParseMyBeaconRelay(payload); ok {
+			ev.MAC = mac
+			ev.RSSI = report.RSSI
+			return ev
+		}
+	}
+
+	return nil
+}

+ 61 - 0
internal/protocol/events.go

@@ -0,0 +1,61 @@
+// Package protocol defines shared event types for ZMQ messages
+package protocol
+
+// EventType identifies the type of beacon event
+type EventType string
+
+const (
+	EventIBeacon    EventType = "ibeacon"
+	EventAccel      EventType = "my-beacon_acc"
+	EventAccelFF    EventType = "my-beacon_acc_ff"
+	EventRelay      EventType = "rt_mybeacon"
+	EventWiFiProbe  EventType = "wifi_probe"
+)
+
+// BLEEvent is the base structure for all BLE events
+type BLEEvent struct {
+	Type  EventType `json:"type"`
+	MAC   string    `json:"mac"`
+	RSSI  int8      `json:"rssi"`
+	TsMs  int64     `json:"ts"`
+}
+
+// IBeaconEvent represents an iBeacon advertisement
+type IBeaconEvent struct {
+	BLEEvent
+	UUID  string `json:"uuid"`
+	Major uint16 `json:"major"`
+	Minor uint16 `json:"minor"`
+}
+
+// AccelEvent represents my-beacon accelerometer data
+type AccelEvent struct {
+	BLEEvent
+	X    int8 `json:"x"`
+	Y    int8 `json:"y"`
+	Z    int8 `json:"z"`
+	Bat  uint8 `json:"bat"`
+	Temp int8  `json:"temp"`
+	FF   bool  `json:"ff"`
+}
+
+// RelayEvent represents rt_mybeacon relay data
+type RelayEvent struct {
+	BLEEvent
+	RelayMAC  string `json:"relay_mac"`
+	RelayMaj  uint16 `json:"relay_major"`
+	RelayMin  uint16 `json:"relay_minor"`
+	RelayRSSI int8   `json:"relay_rssi"`
+	RelayBat  uint8  `json:"relay_bat"`
+	IBMajor   uint16 `json:"ib_major"`
+	IBMinor   uint16 `json:"ib_minor"`
+}
+
+// WiFiProbeEvent represents a WiFi probe request
+type WiFiProbeEvent struct {
+	Type EventType `json:"type"`
+	MAC  string    `json:"mac"`
+	RSSI int8      `json:"rssi"`
+	TsMs int64     `json:"ts"`
+	SSID string    `json:"ssid,omitempty"`
+}

+ 277 - 0
internal/wifi/capture.go

@@ -0,0 +1,277 @@
+// Package wifi provides WiFi packet capture and parsing
+package wifi
+
+import (
+	"encoding/binary"
+	"fmt"
+	"net"
+	"os/exec"
+	"syscall"
+	"time"
+)
+
+// 802.11 Frame Control field
+const (
+	FrameTypeManagement = 0x00
+	FrameSubtypeProbe   = 0x04
+)
+
+// Radiotap constants
+const (
+	RadiotapPresent_TSFT      = 1 << 0
+	RadiotapPresent_Flags     = 1 << 1
+	RadiotapPresent_Rate      = 1 << 2
+	RadiotapPresent_Channel   = 1 << 3
+	RadiotapPresent_FHSS      = 1 << 4
+	RadiotapPresent_DBM_Antsignal = 1 << 5
+)
+
+// CaptureSocket represents a raw packet capture socket
+type CaptureSocket struct {
+	fd    int
+	iface string
+}
+
+// SetMonitorMode enables monitor mode on a WiFi interface
+func SetMonitorMode(iface string) error {
+	// Bring interface down
+	if err := exec.Command("ip", "link", "set", iface, "down").Run(); err != nil {
+		return fmt.Errorf("ip link down: %w", err)
+	}
+
+	// Set monitor mode
+	if err := exec.Command("iw", "dev", iface, "set", "type", "monitor").Run(); err != nil {
+		return fmt.Errorf("iw set monitor: %w", err)
+	}
+
+	// Set monitor flags
+	exec.Command("iw", "dev", iface, "set", "monitor", "control", "otherbss").Run()
+
+	// Bring interface up
+	if err := exec.Command("ip", "link", "set", iface, "up").Run(); err != nil {
+		return fmt.Errorf("ip link up: %w", err)
+	}
+
+	return nil
+}
+
+// SetManagedMode restores managed mode on a WiFi interface
+func SetManagedMode(iface string) error {
+	exec.Command("ip", "link", "set", iface, "down").Run()
+	exec.Command("iw", "dev", iface, "set", "type", "managed").Run()
+	exec.Command("ip", "link", "set", iface, "up").Run()
+	return nil
+}
+
+// SetChannel sets the WiFi channel
+func SetChannel(iface string, channel int) error {
+	return exec.Command("iw", "dev", iface, "set", "channel", fmt.Sprintf("%d", channel)).Run()
+}
+
+// NewCaptureSocket creates a raw packet capture socket
+func NewCaptureSocket(iface string) (*CaptureSocket, error) {
+	// Get interface index
+	ifi, err := net.InterfaceByName(iface)
+	if err != nil {
+		return nil, fmt.Errorf("interface %s: %w", iface, err)
+	}
+
+	// Create raw socket (ETH_P_ALL = 0x0003)
+	fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(0x0003)))
+	if err != nil {
+		return nil, fmt.Errorf("socket: %w", err)
+	}
+
+	// Bind to interface
+	addr := syscall.SockaddrLinklayer{
+		Protocol: htons(0x0003),
+		Ifindex:  ifi.Index,
+	}
+	if err := syscall.Bind(fd, &addr); err != nil {
+		syscall.Close(fd)
+		return nil, fmt.Errorf("bind: %w", err)
+	}
+
+	// Note: promiscuous mode not needed when interface is in monitor mode
+	// Monitor mode already captures all packets
+
+	return &CaptureSocket{fd: fd, iface: iface}, nil
+}
+
+// Read reads a packet from the socket
+func (c *CaptureSocket) Read(buf []byte) (int, error) {
+	return syscall.Read(c.fd, buf)
+}
+
+// Close closes the capture socket
+func (c *CaptureSocket) Close() error {
+	return syscall.Close(c.fd)
+}
+
+// Fd returns the file descriptor
+func (c *CaptureSocket) Fd() int {
+	return c.fd
+}
+
+// htons converts a short (uint16) from host to network byte order
+func htons(i uint16) uint16 {
+	return (i<<8)&0xff00 | i>>8
+}
+
+// ProbeRequest represents a parsed WiFi probe request
+type ProbeRequest struct {
+	SourceMAC string
+	RSSI      int8
+	SSID      string
+	Timestamp time.Time
+}
+
+// ParseRadiotapRSSI extracts RSSI from radiotap header
+func ParseRadiotapRSSI(data []byte) (headerLen int, rssi int8, ok bool) {
+	if len(data) < 8 {
+		return 0, 0, false
+	}
+
+	// Radiotap header: version(1) + pad(1) + length(2) + present_flags(4+)
+	// version := data[0]
+	headerLen = int(binary.LittleEndian.Uint16(data[2:4]))
+	if headerLen > len(data) {
+		return 0, 0, false
+	}
+
+	presentFlags := binary.LittleEndian.Uint32(data[4:8])
+	offset := 8
+
+	// Skip extended present flags if any
+	for presentFlags&(1<<31) != 0 && offset+4 <= headerLen {
+		presentFlags = binary.LittleEndian.Uint32(data[offset : offset+4])
+		offset += 4
+	}
+
+	// Re-read first present flags
+	presentFlags = binary.LittleEndian.Uint32(data[4:8])
+
+	// Walk through present fields to find dBm antenna signal
+	if presentFlags&RadiotapPresent_TSFT != 0 {
+		offset = (offset + 7) &^ 7 // Align to 8 bytes
+		offset += 8
+	}
+	if presentFlags&RadiotapPresent_Flags != 0 {
+		offset += 1
+	}
+	if presentFlags&RadiotapPresent_Rate != 0 {
+		offset += 1
+	}
+	if presentFlags&RadiotapPresent_Channel != 0 {
+		offset = (offset + 1) &^ 1 // Align to 2 bytes
+		offset += 4
+	}
+	if presentFlags&RadiotapPresent_FHSS != 0 {
+		offset += 2
+	}
+	if presentFlags&RadiotapPresent_DBM_Antsignal != 0 {
+		if offset < headerLen {
+			rssi = int8(data[offset])
+			return headerLen, rssi, true
+		}
+	}
+
+	return headerLen, -100, true // Default RSSI if not found
+}
+
+// ParseProbeRequest parses a WiFi frame for probe requests
+func ParseProbeRequest(data []byte) (*ProbeRequest, bool) {
+	// Parse radiotap header
+	rtLen, rssi, ok := ParseRadiotapRSSI(data)
+	if !ok || rtLen >= len(data) {
+		return nil, false
+	}
+
+	frame := data[rtLen:]
+	if len(frame) < 24 {
+		return nil, false
+	}
+
+	// 802.11 header
+	// Frame Control: type/subtype(2) + duration(2) + addr1(6) + addr2(6) + addr3(6) + seq(2)
+	frameControl := binary.LittleEndian.Uint16(frame[0:2])
+
+	// Check if it's a Probe Request (type=0, subtype=4)
+	frameType := (frameControl >> 2) & 0x03
+	frameSubtype := (frameControl >> 4) & 0x0F
+
+	if frameType != FrameTypeManagement || frameSubtype != FrameSubtypeProbe {
+		return nil, false
+	}
+
+	// Extract Source Address (addr2 at offset 10)
+	srcMAC := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
+		frame[10], frame[11], frame[12], frame[13], frame[14], frame[15])
+
+	// Parse management frame body (after 24-byte header)
+	body := frame[24:]
+
+	// Parse information elements to find SSID
+	ssid := ""
+	offset := 0
+	for offset+2 <= len(body) {
+		elementID := body[offset]
+		elementLen := int(body[offset+1])
+		if offset+2+elementLen > len(body) {
+			break
+		}
+		if elementID == 0 { // SSID
+			ssidBytes := body[offset+2 : offset+2+elementLen]
+			ssid = string(ssidBytes) // Best-effort UTF-8
+		}
+		offset += 2 + elementLen
+	}
+
+	return &ProbeRequest{
+		SourceMAC: srcMAC,
+		RSSI:      rssi,
+		SSID:      ssid,
+		Timestamp: time.Now(),
+	}, true
+}
+
+// ChannelHopper cycles through WiFi channels
+type ChannelHopper struct {
+	iface    string
+	channels []int
+	dwell    time.Duration
+	stop     chan struct{}
+}
+
+// NewChannelHopper creates a new channel hopper
+func NewChannelHopper(iface string, channels []int, dwell time.Duration) *ChannelHopper {
+	return &ChannelHopper{
+		iface:    iface,
+		channels: channels,
+		dwell:    dwell,
+		stop:     make(chan struct{}),
+	}
+}
+
+// Start starts channel hopping
+func (h *ChannelHopper) Start() {
+	go func() {
+		i := 0
+		for {
+			select {
+			case <-h.stop:
+				return
+			default:
+				ch := h.channels[i%len(h.channels)]
+				SetChannel(h.iface, ch)
+				i++
+				time.Sleep(h.dwell)
+			}
+		}
+	}()
+}
+
+// Stop stops channel hopping
+func (h *ChannelHopper) Stop() {
+	close(h.stop)
+}