1 Commits

Author SHA1 Message Date
9b3cec6db0 electron 2026-04-04 12:57:34 +02:00
33 changed files with 154 additions and 1549 deletions

View File

@@ -1,87 +0,0 @@
# Variables
BINARY_NAME=ShackMaster.exe
FRONTEND_DIR=web
BACKEND_DIR=cmd/server
DIST_DIR=$(FRONTEND_DIR)/dist
GO_FILES=$(shell find . -name '*.go' -not -path "./$(FRONTEND_DIR)/*")
CGO_ENABLED ?= 1
GOFLAGS = CGO_ENABLED=$(CGO_ENABLED)
.PHONY: all build frontend backend run clean dev help install-deps
# Commande par défaut
all: build
## help: Affiche cette aide
help:
@echo "ShackMaster - Makefile"
@echo ""
@echo "Commandes disponibles:"
@echo " make build - Build complet (frontend + backend)"
@echo " make frontend - Build uniquement le frontend"
@echo " make backend - Build uniquement le backend Go"
@echo " make run - Build et lance l'application"
@echo " make dev - Lance le frontend en mode dev"
@echo " make clean - Nettoie les fichiers générés"
@echo " make install-deps - Installe toutes les dépendances"
@echo " make help - Affiche cette aide"
## install-deps: Installe les dépendances npm
install-deps:
@echo "[1/2] Installation des dependances npm..."
cd $(FRONTEND_DIR) && npm install
@echo "Dependances installees"
@echo ""
@echo "[2/2] Verification de Go..."
@go version
@echo "Go est installe"
## frontend: Build le frontend Svelte
frontend:
@echo "Building frontend..."
cd $(FRONTEND_DIR) && npm run build
xcopy /E /I /Y web\dist cmd\server\web\dist
@echo "Frontend built successfully"
## backend: Build le backend Go
backend: frontend
@echo "Building Go binary..."
cd $(BACKEND_DIR) && go build -o ../../SMaster.exe -ldflags -H=windowsgui .
@echo "Backend built successfully"
## build: Build complet (frontend + backend)
build: install-deps frontend backend
@echo ""
@echo "====================================="
@echo " BUILD COMPLETE!"
@echo "====================================="
@echo ""
@echo "Run: ./$(BINARY_NAME)"
@echo ""
## run: Build et lance l'application
run: build
@echo "Starting ShackMaster..."
@echo ""
./$(BINARY_NAME)
## dev: Lance le frontend en mode développement (hot reload)
dev:
@echo "Starting frontend dev server..."
@echo "Frontend: http://localhost:3000"
@echo "Backend: http://localhost:8080"
@echo ""
cd $(FRONTEND_DIR) && npm run dev
## clean: Nettoie les fichiers générés
clean:
@echo "Cleaning build files..."
@if exist $(BINARY_NAME) del /f /q $(BINARY_NAME)
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
@echo "Clean complete"
## watch: Build auto lors des changements (nécessite watchexec)
watch:
@echo "Watching for changes..."
@echo "Install watchexec: choco install watchexec"
watchexec -w . -e go -- make build

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -44,11 +44,8 @@ func main() {
log.Fatalf("Failed to start device manager: %v", err) log.Fatalf("Failed to start device manager: %v", err)
} }
// Channel de shutdown partagé entre main et le handler API
shutdownChan := make(chan struct{})
// Create HTTP server with embedded files // Create HTTP server with embedded files
server := api.NewServer(deviceManager, hub, cfg, shutdownChan) server := api.NewServer(deviceManager, hub, cfg)
mux := server.SetupRoutes() mux := server.SetupRoutes()
// Serve embedded static files // Serve embedded static files
@@ -79,17 +76,12 @@ func main() {
} }
}() }()
// Wait for interrupt signal or API shutdown request // Wait for interrupt signal
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
select { log.Println("Shutting down server...")
case <-quit:
log.Println("Signal received, shutting down...")
case <-shutdownChan:
log.Println("API shutdown requested, shutting down...")
}
deviceManager.Stop() deviceManager.Stop()
log.Println("Server stopped") log.Println("Server stopped")
} }

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShackMaster - F4BPO Shack</title> <title>ShackMaster - F4BPO Shack</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/main-CEFSEmZ6.js"></script> <script type="module" crossorigin src="/assets/index-BqlArLJ0.js"></script>
<link rel="modulepreload" crossorigin href="/assets/api-C_k14kaa.js"> <link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
<link rel="stylesheet" crossorigin href="/assets/main-CuAW62oI.css"> </head>
</head> <body>
<body> <div id="app"></div>
<div id="app"></div>
</body>
</html> </html>

View File

@@ -121,9 +121,6 @@ func (dm *DeviceManager) Initialize() error {
dm.config.Devices.FlexRadio.Port, dm.config.Devices.FlexRadio.Port,
) )
dm.flexRadio.SetReconnectInterval(3 * time.Second) // Retry every 3 seconds
dm.flexRadio.SetMaxReconnectDelay(30 * time.Second) // Max 30 second delay
// Set callback for immediate frequency changes (no waiting for update cycle) // Set callback for immediate frequency changes (no waiting for update cycle)
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) { dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
dm.handleFrequencyChange(freqMHz) dm.handleFrequencyChange(freqMHz)
@@ -424,7 +421,9 @@ func (dm *DeviceManager) updateStatus() {
// Check cooldown to prevent rapid fire commands // Check cooldown to prevent rapid fire commands
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime) timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
if timeSinceLastUpdate > dm.freqUpdateCooldown { if timeSinceLastUpdate < dm.freqUpdateCooldown {
log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate)
} else {
log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse) log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse)
// Send to Ultrabeam with saved or current direction // Send to Ultrabeam with saved or current direction

View File

@@ -5,7 +5,6 @@ import (
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.rouggy.com/rouggy/ShackMaster/internal/config" "git.rouggy.com/rouggy/ShackMaster/internal/config"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -16,15 +15,13 @@ type Server struct {
hub *Hub hub *Hub
config *config.Config config *config.Config
upgrader websocket.Upgrader upgrader websocket.Upgrader
shutdownChan chan struct{}
} }
func NewServer(dm *DeviceManager, hub *Hub, cfg *config.Config, shutdownChan chan struct{}) *Server { func NewServer(dm *DeviceManager, hub *Hub, cfg *config.Config) *Server {
return &Server{ return &Server{
deviceManager: dm, deviceManager: dm,
hub: hub, hub: hub,
config: cfg, config: cfg,
shutdownChan: shutdownChan,
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
@@ -77,9 +74,6 @@ func (s *Server) SetupRoutes() *http.ServeMux {
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode) mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
mux.HandleFunc("/api/power/operate", s.handlePowerOperate) mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
// Shutdown endpoint
mux.HandleFunc("/api/shutdown", s.handleShutdown)
// Note: Static files are now served from embedded FS in main.go // Note: Static files are now served from embedded FS in main.go
return mux return mux
@@ -516,21 +510,6 @@ func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request
s.sendJSON(w, map[string]string{"status": "ok"}) s.sendJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
s.sendJSON(w, map[string]string{"status": "shutting down"})
go func() {
time.Sleep(200 * time.Millisecond)
log.Println("Shutdown requested via API")
close(s.shutdownChan)
}()
}
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) { func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)

View File

@@ -9,7 +9,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
) )
type Client struct { type Client struct {
@@ -18,8 +17,8 @@ type Client struct {
conn net.Conn conn net.Conn
reader *bufio.Reader reader *bufio.Reader
connMu sync.Mutex connMu sync.Mutex // For connection management
writeMu sync.Mutex writeMu sync.Mutex // For writing to connection (separate from reads)
lastStatus *Status lastStatus *Status
statusMu sync.RWMutex statusMu sync.RWMutex
@@ -30,57 +29,22 @@ type Client struct {
running bool running bool
stopChan chan struct{} stopChan chan struct{}
reconnectInterval time.Duration // Callbacks
reconnectAttempts int
maxReconnectDelay time.Duration
radioInfo map[string]string
radioInfoMu sync.RWMutex
lastInfoCheck time.Time
infoCheckTimer *time.Timer
activeSlices []int
activeSlicesMu sync.RWMutex
sliceListTimer *time.Timer
onFrequencyChange func(freqMHz float64) onFrequencyChange func(freqMHz float64)
checkTransmitAllowed func() bool checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
// Track current slice frequency
currentFreq float64
currentFreqMu sync.RWMutex
} }
func New(host string, port int) *Client { func New(host string, port int) *Client {
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
reconnectInterval: 5 * time.Second,
maxReconnectDelay: 60 * time.Second,
radioInfo: make(map[string]string),
activeSlices: []int{},
lastStatus: &Status{ lastStatus: &Status{
Connected: false, Connected: false,
RadioOn: false,
Tx: false, // Initialisé à false
ActiveSlices: 0,
Frequency: 0,
}, },
currentFreq: 0,
} }
} }
// SetReconnectInterval sets the reconnection interval
func (c *Client) SetReconnectInterval(interval time.Duration) {
c.reconnectInterval = interval
}
// SetMaxReconnectDelay sets the maximum delay for exponential backoff
func (c *Client) SetMaxReconnectDelay(delay time.Duration) {
c.maxReconnectDelay = delay
}
// SetFrequencyChangeCallback sets the callback function called when frequency changes // SetFrequencyChangeCallback sets the callback function called when frequency changes
func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) { func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) {
c.onFrequencyChange = callback c.onFrequencyChange = callback
@@ -109,9 +73,8 @@ func (c *Client) Connect() error {
c.conn = conn c.conn = conn
c.reader = bufio.NewReader(conn) c.reader = bufio.NewReader(conn)
c.reconnectAttempts = 0
log.Println("FlexRadio: TCP connection established") log.Println("FlexRadio: Connected successfully")
return nil return nil
} }
@@ -120,16 +83,14 @@ func (c *Client) Start() error {
return nil return nil
} }
// Try initial connection
if err := c.Connect(); err != nil { if err := c.Connect(); err != nil {
log.Printf("FlexRadio: Initial connection failed: %v", err) return err
} }
// Update connected status // Update connected status
c.statusMu.Lock() c.statusMu.Lock()
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Connected = (c.conn != nil) c.lastStatus.Connected = true
c.lastStatus.RadioOn = false
} }
c.statusMu.Unlock() c.statusMu.Unlock()
@@ -138,27 +99,11 @@ func (c *Client) Start() error {
// Start message listener // Start message listener
go c.messageLoop() go c.messageLoop()
// Start reconnection monitor // Subscribe to slice updates for frequency tracking
go c.reconnectionMonitor() log.Println("FlexRadio: Subscribing to slice updates...")
_, err := c.sendCommand("sub slice all")
// Start radio status checker if err != nil {
go c.radioStatusChecker() log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err)
// Start slice list checker
go c.sliceListChecker()
// Try to get initial radio info and subscribe to slices
if c.conn != nil {
go func() {
time.Sleep(500 * time.Millisecond)
c.SendInfo()
time.Sleep(500 * time.Millisecond)
c.SendSliceList()
time.Sleep(500 * time.Millisecond)
c.SubscribeToSlices()
}()
} }
return nil return nil
@@ -172,14 +117,6 @@ func (c *Client) Stop() {
c.running = false c.running = false
close(c.stopChan) close(c.stopChan)
// Stop timers
if c.infoCheckTimer != nil {
c.infoCheckTimer.Stop()
}
if c.sliceListTimer != nil {
c.sliceListTimer.Stop()
}
c.connMu.Lock() c.connMu.Lock()
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -188,34 +125,23 @@ func (c *Client) Stop() {
} }
c.connMu.Unlock() c.connMu.Unlock()
// Update status // Update connected status
c.statusMu.Lock() c.statusMu.Lock()
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Connected = false c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Disconnected"
c.lastStatus.ActiveSlices = 0
c.lastStatus.Frequency = 0
c.lastStatus.Mode = ""
c.lastStatus.Tx = false
} }
c.statusMu.Unlock() c.statusMu.Unlock()
} }
// Helper functions for common commands func (c *Client) getNextSeq() int {
func (c *Client) SendInfo() error { c.cmdSeqMu.Lock()
return c.sendCommand("info") defer c.cmdSeqMu.Unlock()
c.cmdSeq++
return c.cmdSeq
} }
func (c *Client) SendSliceList() error { func (c *Client) sendCommand(cmd string) (string, error) {
return c.sendCommand("slice list") // Use writeMu instead of connMu to avoid blocking on messageLoop reads
}
func (c *Client) SubscribeToSlices() error {
return c.sendCommand("sub slice all")
}
func (c *Client) sendCommand(cmd string) error {
c.writeMu.Lock() c.writeMu.Lock()
defer c.writeMu.Unlock() defer c.writeMu.Unlock()
@@ -224,7 +150,7 @@ func (c *Client) sendCommand(cmd string) error {
c.connMu.Unlock() c.connMu.Unlock()
if conn == nil { if conn == nil {
return fmt.Errorf("not connected") return "", fmt.Errorf("not connected")
} }
seq := c.getNextSeq() seq := c.getNextSeq()
@@ -234,35 +160,14 @@ func (c *Client) sendCommand(cmd string) error {
_, err := conn.Write([]byte(fullCmd)) _, err := conn.Write([]byte(fullCmd))
if err != nil { if err != nil {
// Mark connection as broken
c.connMu.Lock() c.connMu.Lock()
if c.conn != nil { c.conn = nil
c.conn.Close() c.reader = nil
c.conn = nil
c.reader = nil
}
c.connMu.Unlock() c.connMu.Unlock()
return "", fmt.Errorf("failed to send command: %w", err)
// Update status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Connection lost"
}
c.statusMu.Unlock()
return fmt.Errorf("failed to send command: %w", err)
} }
return nil return "", nil
}
func (c *Client) getNextSeq() int {
c.cmdSeqMu.Lock()
defer c.cmdSeqMu.Unlock()
c.cmdSeq++
return c.cmdSeq
} }
func (c *Client) messageLoop() { func (c *Client) messageLoop() {
@@ -273,20 +178,25 @@ func (c *Client) messageLoop() {
if c.conn == nil || c.reader == nil { if c.conn == nil || c.reader == nil {
c.connMu.Unlock() c.connMu.Unlock()
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
if err := c.Connect(); err != nil {
log.Printf("FlexRadio: Reconnect failed: %v", err)
continue
}
continue continue
} }
// Set read deadline to allow periodic checks
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
line, err := c.reader.ReadString('\n') line, err := c.reader.ReadString('\n')
c.connMu.Unlock() c.connMu.Unlock()
if err != nil { if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() { if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout is expected, continue
continue continue
} }
log.Printf("FlexRadio: Read error: %v", err) log.Printf("FlexRadio: Read error: %v", err)
c.connMu.Lock() c.connMu.Lock()
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -295,11 +205,10 @@ func (c *Client) messageLoop() {
} }
c.connMu.Unlock() c.connMu.Unlock()
// Update connected status
c.statusMu.Lock() c.statusMu.Lock()
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Connected = false c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Connection lost"
} }
c.statusMu.Unlock() c.statusMu.Unlock()
continue continue
@@ -316,495 +225,87 @@ func (c *Client) messageLoop() {
log.Println("FlexRadio: Message loop stopped") log.Println("FlexRadio: Message loop stopped")
} }
// Message handling - SIMPLIFIED VERSION
func (c *Client) handleMessage(msg string) { func (c *Client) handleMessage(msg string) {
msg = strings.TrimSpace(msg) // Response format: R<seq>|<status>|<data>
if msg == "" { if strings.HasPrefix(msg, "R") {
c.handleResponse(msg)
return return
} }
// DEBUG: Log tous les messages reçus // Status format: S<handle>|<key>=<value> ...
log.Printf("FlexRadio RAW: %s", msg) if strings.HasPrefix(msg, "S") {
c.handleStatus(msg)
// Vérifier le type de message
if len(msg) < 2 {
return return
} }
// Messages commençant par R (réponses) // Version/handle format: V<version>|H<handle>
if msg[0] == 'R' { if strings.HasPrefix(msg, "V") {
c.handleCommandResponse(msg) log.Printf("FlexRadio: Version/Handle received: %s", msg)
return return
} }
// Messages commençant par S (statut) // Message format: M<handle>|<message>
if msg[0] == 'S' { if strings.HasPrefix(msg, "M") {
// Enlever le préfixe S
msg = msg[1:]
// Séparer handle et données
parts := strings.SplitN(msg, "|", 2)
if len(parts) < 2 {
return
}
handle := parts[0]
data := parts[1]
// Parser les paires clé=valeur
statusMap := make(map[string]string)
pairs := strings.Fields(data)
for _, pair := range pairs {
if kv := strings.SplitN(pair, "=", 2); len(kv) == 2 {
statusMap[kv[0]] = kv[1]
}
}
// Identifier le type de message
if strings.Contains(data, "interlock") {
c.handleInterlockStatus(handle, statusMap)
} else if strings.Contains(data, "slice") {
// Extraire le numéro de slice depuis le message (ex: "slice 0 RF_frequency=14.225")
sliceNum := -1
fields := strings.Fields(data)
for i, f := range fields {
if f == "slice" && i+1 < len(fields) {
if n, err := strconv.Atoi(fields[i+1]); err == nil {
sliceNum = n
}
break
}
}
c.handleSliceStatus(handle, statusMap, sliceNum)
} else if strings.Contains(data, "radio") {
c.handleRadioStatus(handle, statusMap)
} else {
// Vérifier si c'est une mise à jour de fréquence
if freqStr, ok := statusMap["RF_frequency"]; ok {
c.handleFrequencyUpdate(handle, freqStr, statusMap)
} else {
log.Printf("FlexRadio: Message inconnu (handle=%s): %s", handle, data)
}
}
return
}
// Autres types de messages
switch msg[0] {
case 'V':
log.Printf("FlexRadio: Version/Handle: %s", msg)
case 'M':
log.Printf("FlexRadio: Message: %s", msg) log.Printf("FlexRadio: Message: %s", msg)
default:
log.Printf("FlexRadio: Type de message inconnu: %s", msg)
}
}
func (c *Client) handleSliceStatus(handle string, statusMap map[string]string, sliceNum int) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return return
} }
// Mettre à jour le nombre de slices actives
c.lastStatus.ActiveSlices = 1
// Mettre à jour la fréquence
if rfFreq, ok := statusMap["RF_frequency"]; ok {
if freq, err := strconv.ParseFloat(rfFreq, 64); err == nil && freq > 0 {
oldFreq := c.lastStatus.Frequency
// Mettre à jour la fréquence affichée uniquement si c'est slice 0
if sliceNum == 0 || sliceNum == -1 {
c.lastStatus.Frequency = freq
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", freq)
}
// Déclencher le callback UNIQUEMENT pour la slice 0
// Les slices 1, 2, 3 ne contrôlent pas l'Ultrabeam
if sliceNum == 0 && oldFreq != freq && c.onFrequencyChange != nil {
log.Printf("FlexRadio: Slice 0 frequency changed to %.3f MHz -> triggering Ultrabeam callback", freq)
go c.onFrequencyChange(freq)
} else if sliceNum > 0 {
log.Printf("FlexRadio: Slice %d frequency changed to %.3f MHz -> ignored for Ultrabeam", sliceNum, freq)
}
} else if freq == 0 {
// Fréquence 0 = slice inactive
if sliceNum == 0 || sliceNum == -1 {
c.lastStatus.Frequency = 0
c.lastStatus.RadioInfo = "Slice inactive"
}
}
}
// Mettre à jour le mode
if mode, ok := statusMap["mode"]; ok {
c.lastStatus.Mode = mode
}
// NE PAS utiliser tx du slice pour l'état TX réel
// tx=1 dans le slice signifie seulement "capable de TX", pas "en train de TX"
// L'état TX réel vient de l'interlock
} }
func (c *Client) handleCommandResponse(msg string) { func (c *Client) handleResponse(msg string) {
// Format: R<seq>|<status>|<data> // Format: R<seq>|<status>|<data>
// Example: R21|0|000000F4
parts := strings.SplitN(msg, "|", 3) parts := strings.SplitN(msg, "|", 3)
if len(parts) < 3 { if len(parts) < 2 {
log.Printf("FlexRadio: Malformed response: %s", msg)
return return
} }
seqStr := strings.TrimPrefix(parts[0], "R")
status := parts[1] status := parts[1]
data := parts[2]
seq, _ := strconv.Atoi(seqStr)
if status != "0" { if status != "0" {
log.Printf("FlexRadio: Command error (seq=%d): %s", seq, msg) log.Printf("FlexRadio: Command error: status=%s, message=%s", status, msg)
return
}
}
func (c *Client) handleStatus(msg string) {
// Format: S<handle>|<key>=<value> ...
parts := strings.SplitN(msg, "|", 2)
if len(parts) < 2 {
return return
} }
log.Printf("FlexRadio: Command success (seq=%d)", seq) data := parts[1]
// Identifier le type de réponse par son contenu // Parse key=value pairs
switch { pairs := strings.Fields(data)
case strings.Contains(data, "model="): statusMap := make(map[string]string)
c.parseInfoResponse(data)
case isSliceListResponse(data):
c.parseSliceListResponse(data)
default:
log.Printf("FlexRadio: Generic response: %s", data)
}
}
func isSliceListResponse(data string) bool {
data = strings.TrimSpace(data)
if data == "" {
return true
}
for _, char := range data {
if !unicode.IsDigit(char) && char != ' ' {
return false
}
}
return true
}
func (c *Client) handleInterlockStatus(handle string, statusMap map[string]string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if state, ok := statusMap["state"]; ok {
c.lastStatus.Tx = (state == "TRANSMITTING" || state == "TUNE")
log.Printf("FlexRadio: Interlock state=%s, TX=%v", state, c.lastStatus.Tx)
}
}
func (c *Client) handleRadioStatus(handle string, statusMap map[string]string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
// Mettre à jour les informations radio
c.lastStatus.RadioOn = true
c.lastStatus.Connected = true
// Mettre à jour le nombre de slices
if slices, ok := statusMap["slices"]; ok {
if num, err := strconv.Atoi(slices); err == nil {
c.lastStatus.NumSlices = num
}
}
// Mettre à jour le callsign
if callsign, ok := statusMap["callsign"]; ok {
c.lastStatus.Callsign = callsign
}
// Mettre à jour les autres infos
if nickname, ok := statusMap["nickname"]; ok {
c.lastStatus.RadioInfo = fmt.Sprintf("Radio: %s", nickname)
}
}
func (c *Client) handleFrequencyUpdate(handle string, freqStr string, statusMap map[string]string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
// Parser la fréquence
// Note: ce chemin est un fallback sans numéro de slice connu.
// On met à jour l'affichage mais on ne déclenche PAS le callback Ultrabeam
// (les vrais changements de slice 0 passent par handleSliceStatus)
if freq, err := strconv.ParseFloat(freqStr, 64); err == nil && freq > 0 {
c.lastStatus.Frequency = freq
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", freq)
}
log.Printf("FlexRadio: Frequency update: %s MHz", freqStr)
}
func (c *Client) parseInfoResponse(data string) {
log.Printf("FlexRadio: Parsing info response: %s", data)
pairs := []string{}
current := ""
inQuotes := false
for _, char := range data {
if char == '"' {
inQuotes = !inQuotes
}
if char == ',' && !inQuotes {
pairs = append(pairs, strings.TrimSpace(current))
current = ""
} else {
current += string(char)
}
}
if current != "" {
pairs = append(pairs, strings.TrimSpace(current))
}
c.radioInfoMu.Lock()
c.radioInfo = make(map[string]string)
for _, pair := range pairs { for _, pair := range pairs {
kv := strings.SplitN(pair, "=", 2) kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 { if len(kv) == 2 {
key := strings.TrimSpace(kv[0]) statusMap[kv[0]] = kv[1]
value := strings.TrimSpace(kv[1])
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
c.radioInfo[key] = value
} }
} }
c.radioInfoMu.Unlock() // Check for slice updates (frequency changes)
if strings.Contains(msg, "slice") {
if rfFreq, ok := statusMap["RF_frequency"]; ok {
freq, err := strconv.ParseFloat(rfFreq, 64)
if err == nil {
c.statusMu.Lock()
oldFreq := c.lastStatus.Frequency
c.lastStatus.Frequency = freq
c.statusMu.Unlock()
// Mettre à jour le statut // Only log significant frequency changes (> 1 kHz)
c.updateRadioStatus(true, "Radio is on") if oldFreq == 0 || (freq-oldFreq)*(freq-oldFreq) > 0.001 {
log.Printf("FlexRadio: Frequency updated to %.6f MHz", freq)
go func() { // Trigger callback for immediate auto-track
time.Sleep(300 * time.Millisecond) if c.onFrequencyChange != nil {
c.SendSliceList() go c.onFrequencyChange(freq)
}
// S'abonner aux mises à jour
time.Sleep(200 * time.Millisecond)
c.sendCommand("sub slice all")
time.Sleep(100 * time.Millisecond)
c.sendCommand("sub interlock 0")
}()
}
func (c *Client) parseSliceListResponse(data string) {
slices := []int{}
if strings.TrimSpace(data) != "" {
parts := strings.Fields(data)
for _, part := range parts {
if sliceNum, err := strconv.Atoi(part); err == nil {
slices = append(slices, sliceNum)
}
}
}
c.activeSlicesMu.Lock()
c.activeSlices = slices
c.activeSlicesMu.Unlock()
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.ActiveSlices = len(slices)
// NE PAS effacer la fréquence ici !
// La fréquence est gérée par handleSliceStatus
// Seulement mettre à jour RadioInfo si vraiment pas de slices
if len(slices) == 0 && c.lastStatus.Frequency == 0 {
c.lastStatus.RadioInfo = "Radio is on without any active slice"
} else if len(slices) == 0 && c.lastStatus.Frequency > 0 {
// Cas spécial : fréquence mais pas de slice dans la liste
// Peut arriver temporairement, garder l'info actuelle
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", c.lastStatus.Frequency)
}
}
c.statusMu.Unlock()
log.Printf("FlexRadio: Active slices updated: %v (total: %d)", slices, len(slices))
}
func (c *Client) updateRadioStatus(isOn bool, info string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
if c.lastStatus == nil {
return
}
c.lastStatus.RadioOn = isOn
c.lastStatus.RadioInfo = info
c.radioInfoMu.RLock()
if callsign, ok := c.radioInfo["callsign"]; ok {
c.lastStatus.Callsign = callsign
}
if model, ok := c.radioInfo["model"]; ok {
c.lastStatus.Model = model
}
if softwareVer, ok := c.radioInfo["software_ver"]; ok {
c.lastStatus.SoftwareVer = softwareVer
}
if numSlicesStr, ok := c.radioInfo["num_slice"]; ok {
if numSlices, err := strconv.Atoi(numSlicesStr); err == nil {
c.lastStatus.NumSlices = numSlices
}
}
c.radioInfoMu.RUnlock()
if isOn && c.lastStatus.Frequency == 0 && c.lastStatus.ActiveSlices == 0 {
c.lastStatus.RadioInfo = "Radio is on without any active slice"
}
}
func (c *Client) reconnectionMonitor() {
log.Println("FlexRadio: Reconnection monitor started")
for c.running {
c.connMu.Lock()
connected := (c.conn != nil)
c.connMu.Unlock()
if !connected {
c.reconnectAttempts++
delay := c.calculateReconnectDelay()
log.Printf("FlexRadio: Attempting to reconnect in %v (attempt %d)...", delay, c.reconnectAttempts)
select {
case <-time.After(delay):
if err := c.reconnect(); err != nil {
log.Printf("FlexRadio: Reconnection attempt %d failed: %v", c.reconnectAttempts, err)
} else {
log.Printf("FlexRadio: Reconnected successfully on attempt %d", c.reconnectAttempts)
c.reconnectAttempts = 0
go func() {
time.Sleep(500 * time.Millisecond)
c.SendInfo()
}()
} }
case <-c.stopChan:
return
} }
} else {
select {
case <-time.After(10 * time.Second):
case <-c.stopChan:
return
}
}
}
}
func (c *Client) calculateReconnectDelay() time.Duration {
delay := c.reconnectInterval
if c.reconnectAttempts > 1 {
multiplier := 1 << (c.reconnectAttempts - 1)
delay = c.reconnectInterval * time.Duration(multiplier)
if delay > c.maxReconnectDelay {
delay = c.maxReconnectDelay
}
}
return delay
}
func (c *Client) reconnect() error {
c.connMu.Lock()
defer c.connMu.Unlock()
// Close existing connection if any
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
addr := fmt.Sprintf("%s:%d", c.host, c.port)
log.Printf("FlexRadio: Reconnecting to %s...", addr)
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
c.lastStatus.RadioOn = false
c.lastStatus.RadioInfo = "Disconnected"
}
c.statusMu.Unlock()
return fmt.Errorf("reconnect failed: %w", err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = true
c.lastStatus.RadioInfo = "TCP connected, checking radio..."
}
c.statusMu.Unlock()
log.Println("FlexRadio: TCP connection reestablished")
return nil
}
func (c *Client) radioStatusChecker() {
c.infoCheckTimer = time.NewTimer(10 * time.Second)
for c.running {
select {
case <-c.infoCheckTimer.C:
c.SendInfo()
c.infoCheckTimer.Reset(10 * time.Second)
case <-c.stopChan:
return
}
}
}
func (c *Client) sliceListChecker() {
c.sliceListTimer = time.NewTimer(10 * time.Second)
for c.running {
select {
case <-c.sliceListTimer.C:
if c.IsRadioOn() {
c.SendSliceList()
}
c.sliceListTimer.Reset(10 * time.Second)
case <-c.stopChan:
return
} }
} }
} }
@@ -814,27 +315,14 @@ func (c *Client) GetStatus() (*Status, error) {
defer c.statusMu.RUnlock() defer c.statusMu.RUnlock()
if c.lastStatus == nil { if c.lastStatus == nil {
return &Status{ return &Status{Connected: false}, nil
Connected: false,
RadioOn: false,
Tx: false,
RadioInfo: "Not initialized",
}, nil
} }
// Créer une copie // Create a copy
status := *c.lastStatus status := *c.lastStatus
// DON'T lock connMu here - it causes 4-second blocking!
// The messageLoop updates Connected status, and we trust the cached value
return &status, nil return &status, nil
} }
// IsRadioOn returns true if radio is powered on and responding
func (c *Client) IsRadioOn() bool {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return false
}
return c.lastStatus.RadioOn
}

View File

@@ -2,17 +2,13 @@ package flexradio
// Status represents the FlexRadio status // Status represents the FlexRadio status
type Status struct { type Status struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
RadioOn bool `json:"radio_on"` InterlockID string `json:"interlock_id"`
RadioInfo string `json:"radio_info"` InterlockState string `json:"interlock_state"`
Frequency float64 `json:"frequency"` // Primary frequency in MHz Frequency float64 `json:"frequency"` // MHz
Mode string `json:"mode"` Model string `json:"model"`
Tx bool `json:"tx"` // Actually transmitting Serial string `json:"serial"`
ActiveSlices int `json:"active_slices"` Version string `json:"version"`
NumSlices int `json:"num_slices"`
Callsign string `json:"callsign"`
Model string `json:"model"`
SoftwareVer string `json:"software_ver"`
} }
// InterlockState represents possible interlock states // InterlockState represents possible interlock states

View File

@@ -101,14 +101,13 @@ func (c *Client) GetWeatherData() (*WeatherData, error) {
} }
// Convert to our structure // Convert to our structure
// OWM retourne wind_speed et gust en m/s — conversion en km/h
weatherData := &WeatherData{ weatherData := &WeatherData{
Temperature: owmData.Main.Temp, Temperature: owmData.Main.Temp,
FeelsLike: owmData.Main.FeelsLike, FeelsLike: owmData.Main.FeelsLike,
Humidity: owmData.Main.Humidity, Humidity: owmData.Main.Humidity,
Pressure: owmData.Main.Pressure, Pressure: owmData.Main.Pressure,
WindSpeed: owmData.Wind.Speed * 3.6, WindSpeed: owmData.Wind.Speed,
WindGust: owmData.Wind.Gust * 3.6, WindGust: owmData.Wind.Gust,
WindDeg: owmData.Wind.Deg, WindDeg: owmData.Wind.Deg,
Clouds: owmData.Clouds.All, Clouds: owmData.Clouds.All,
UpdatedAt: time.Unix(owmData.Dt, 0).Format(time.RFC3339), UpdatedAt: time.Unix(owmData.Dt, 0).Format(time.RFC3339),

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rotator Control</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f1923; overflow: hidden; }
</style>
</head>
<body>
<div id="popup-app"></div>
<script type="module" src="/src/popup.js"></script>
</body>
</html>

View File

@@ -71,14 +71,6 @@
return date.toTimeString().slice(0, 8); return date.toTimeString().slice(0, 8);
} }
async function shutdown() {
try {
await api.shutdown();
} catch (e) {
// Connexion coupee apres shutdown, c'est normal
}
}
// Weather data from status // Weather data from status
$: weatherData = status?.weather || { $: weatherData = status?.weather || {
wind_speed: 0, wind_speed: 0,
@@ -91,14 +83,11 @@
<div class="app"> <div class="app">
<header> <header>
<div class="header-left"> <div class="header-left">
<h1>{callsign}'s Shack</h1> <h1>{callsign} Shack</h1>
<div class="connection-status"> <div class="connection-status">
<span class="status-indicator" class:status-online={isConnected} class:status-offline={!isConnected}></span> <span class="status-indicator" class:status-online={isConnected} class:status-offline={!isConnected}></span>
{isConnected ? 'Connected' : 'Disconnected'} {isConnected ? 'Connected' : 'Disconnected'}
</div> </div>
<button class="shutdown-btn" on:click={shutdown} title="Fermer ShackMaster">
⏻ Shutdown
</button>
</div> </div>
<div class="header-center"> <div class="header-center">
@@ -194,25 +183,6 @@
border-radius: 16px; border-radius: 16px;
} }
.shutdown-btn {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
padding: 6px 12px;
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 16px;
cursor: pointer;
transition: background 0.2s;
}
.shutdown-btn:hover {
background: rgba(239, 68, 68, 0.35);
color: #fca5a5;
}
.header-center { .header-center {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -312,7 +282,6 @@
display: flex; display: flex;
gap: 24px; gap: 24px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: stretch;
} }
.row > :global(*) { .row > :global(*) {

View File

@@ -1,448 +0,0 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { api } from './lib/api.js';
import { wsService, connected, systemStatus } from './lib/websocket.js';
let status = null;
let isConnected = false;
const unsubStatus = systemStatus.subscribe(v => { status = v; });
const unsubConn = connected.subscribe(v => { isConnected = v; });
// Rotator state
let heading = null;
let localTargetHeading = null;
$: rotator = status?.rotator_genius;
$: ultrabeam = status?.ultrabeam;
$: ultrabeamDirection = ultrabeam?.direction ?? 0;
$: if (rotator?.heading !== undefined && rotator?.heading !== null) {
const newHeading = rotator.heading;
if (heading === null) {
heading = newHeading;
} else if (newHeading === 0 && heading > 10 && heading < 350) {
// ignore glitch
} else {
heading = newHeading;
}
}
$: displayHeading = heading !== null ? heading : 0;
$: connected2 = rotator?.connected || false;
$: statusTargetHeading = rotator?.target_heading ?? null;
$: isMovingFromStatus = statusTargetHeading !== null &&
heading !== null &&
(() => {
const diff = Math.abs(statusTargetHeading - heading);
return Math.min(diff, 360 - diff) > 2;
})();
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
const diff = Math.abs(activeTargetHeading - heading);
return Math.min(diff, 360 - diff) > 2;
})();
$: if (localTargetHeading !== null && heading !== null) {
const diff = Math.abs(heading - localTargetHeading);
if (Math.min(diff, 360 - diff) < 3) {
localTargetHeading = null;
}
}
// Ultrabeam direction state (local copy for immediate UI feedback)
let targetDirection = 0;
$: targetDirection = ultrabeamDirection;
onMount(() => {
wsService.connect();
});
onDestroy(() => {
wsService.disconnect();
unsubStatus();
unsubConn();
});
async function rotateCW() {
try { await api.rotator.rotateCW(); } catch (e) {}
}
async function rotateCCW() {
try { await api.rotator.rotateCCW(); } catch (e) {}
}
async function stop() {
localTargetHeading = null;
try { await api.rotator.stop(); } catch (e) {}
}
async function handleCompassClick(event) {
const svg = event.currentTarget;
const rect = svg.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
let angle = Math.atan2(x, -y) * (180 / Math.PI);
if (angle < 0) angle += 360;
const adjusted = (Math.round(angle / 5) * 5 + 360) % 360;
try {
await api.rotator.setHeading(adjusted);
localTargetHeading = adjusted;
} catch (e) {}
}
async function setDirection(dir) {
targetDirection = dir;
try {
const freq = ultrabeam?.frequency || 0;
if (freq > 0) {
await api.ultrabeam.setFrequency(freq, dir);
}
await api.ultrabeam.setDirection(dir);
} catch (e) {}
}
</script>
<div class="popup-root">
<!-- Header -->
<div class="header">
<div class="header-left">
<span class="status-dot" class:disconnected={!connected2}></span>
<span class="title">Rotator Control</span>
</div>
<div class="heading-value">
{displayHeading}°
{#if hasTarget && activeTargetHeading !== null}
<span class="target-indicator">{activeTargetHeading}°</span>
{/if}
</div>
<div class="controls-compact">
<button class="btn-mini ccw" on:click={rotateCCW} title="Rotate CCW"></button>
<button class="btn-mini stop-btn" on:click={stop} title="Stop"></button>
<button class="btn-mini cw" on:click={rotateCW} title="Rotate CW"></button>
</div>
</div>
<!-- Compass Map -->
<div class="map-container">
<svg viewBox="0 0 300 300" class="map-svg"
on:click={handleCompassClick}
on:keydown={(e) => e.key === 'Enter' && handleCompassClick(e)}
role="button"
tabindex="0"
aria-label="Click to rotate antenna">
<defs>
<radialGradient id="beamGrad">
<stop offset="0%" style="stop-color:rgba(79,195,247,0.7);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(79,195,247,0);stop-opacity:0" />
</radialGradient>
</defs>
<circle cx="150" cy="150" r="140" fill="rgba(30,64,175,0.15)" stroke="rgba(79,195,247,0.4)" stroke-width="2"/>
<circle cx="150" cy="150" r="105" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<circle cx="150" cy="150" r="70" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<circle cx="150" cy="150" r="35" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<g transform="translate(150,150)">
<!-- Physical antenna indicator (180° / Bi-Dir) -->
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
<g transform="rotate({displayHeading})">
<line x1="0" y1="0" x2="0" y2="-125"
stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="5,5" opacity="0.6"/>
<g transform="translate(0,-125)">
<polygon points="0,-8 -5,5 5,5"
fill="rgba(255,255,255,0.4)" stroke="rgba(255,255,255,0.5)" stroke-width="1"/>
</g>
</g>
{/if}
<g transform="rotate({displayHeading})">
{#if ultrabeamDirection === 0}
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,1 {Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.85"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<g transform="translate(0,-110)">
<polygon points="0,-20 -8,5 0,0 8,5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
{/if}
{#if ultrabeamDirection === 1}
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,0 {Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.85"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<g transform="translate(0,110)">
<polygon points="0,20 -8,-5 0,0 8,-5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
{/if}
{#if ultrabeamDirection === 2}
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,1 {Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.7"/>
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,0 {Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.7"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<g transform="translate(0,-110)">
<polygon points="0,-20 -8,5 0,0 8,5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
<g transform="translate(0,110)">
<polygon points="0,20 -8,-5 0,0 8,-5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
{/if}
</g>
<!-- Target arrow -->
{#if hasTarget && activeTargetHeading !== null}
<g transform="rotate({activeTargetHeading})">
<line x1="0" y1="0" x2="0" y2="-135"
stroke="#ffc107" stroke-width="3" stroke-dasharray="8,4" opacity="0.9"/>
<g transform="translate(0,-135)">
<polygon points="0,-12 -8,6 0,2 8,6"
fill="#ffc107" stroke="#ff9800" stroke-width="1.5"
style="filter:drop-shadow(0 0 10px rgba(255,193,7,0.8))">
<animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
</polygon>
</g>
</g>
{/if}
<!-- QTH dot -->
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="0" cy="0" r="10" fill="none" stroke="#f44336" stroke-width="1.5" opacity="0.5">
<animate attributeName="r" values="10;16;10" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</g>
<!-- Cardinals -->
<text x="150" y="20" text-anchor="middle" class="cardinal">N</text>
<text x="280" y="155" text-anchor="middle" class="cardinal">E</text>
<text x="150" y="285" text-anchor="middle" class="cardinal">S</text>
<text x="20" y="155" text-anchor="middle" class="cardinal">W</text>
{#each [45,135,225,315] as angle}
{@const x = 150 + 125 * Math.sin(angle * Math.PI / 180)}
{@const y = 150 - 125 * Math.cos(angle * Math.PI / 180)}
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="degree-label">{angle}°</text>
{/each}
</svg>
</div>
<!-- Ultrabeam Direction Buttons -->
<div class="dir-row">
<button class="dir-btn" class:active={targetDirection === 0} on:click={() => setDirection(0)}>Normal</button>
<button class="dir-btn" class:active={targetDirection === 1} on:click={() => setDirection(1)}>180°</button>
<button class="dir-btn" class:active={targetDirection === 2} on:click={() => setDirection(2)}>Bi-Dir</button>
</div>
</div>
<style>
:global(html, body) {
background: #0f1923;
color: #e0e0e0;
font-family: 'Roboto', sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
height: 100%;
}
.popup-root {
display: flex;
flex-direction: column;
height: 100vh;
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
user-select: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(79,195,247,0.05);
border-bottom: 1px solid #2d3748;
gap: 8px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
color: #4fc3f7;
letter-spacing: 0.5px;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #4caf50;
box-shadow: 0 0 6px #4caf50;
flex-shrink: 0;
}
.status-dot.disconnected {
background: #f44336;
box-shadow: 0 0 6px #f44336;
}
.heading-value {
font-size: 28px;
font-weight: 200;
color: #4fc3f7;
text-shadow: 0 0 15px rgba(79,195,247,0.5);
white-space: nowrap;
}
.target-indicator {
font-size: 16px;
font-weight: 400;
color: #ffc107;
margin-left: 8px;
text-shadow: 0 0 10px rgba(255,193,7,0.6);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
.controls-compact {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.btn-mini {
width: 30px;
height: 30px;
border: 2px solid rgba(79,195,247,0.3);
border-radius: 5px;
font-size: 17px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.7);
background: rgba(79,195,247,0.08);
transition: all 0.2s;
}
.btn-mini:hover {
border-color: rgba(79,195,247,0.6);
color: rgba(255,255,255,0.9);
background: rgba(79,195,247,0.15);
}
.btn-mini.stop-btn:hover {
border-color: #f44336;
color: #f44336;
background: rgba(244,67,54,0.15);
}
.map-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: rgba(10,22,40,0.6);
min-height: 0;
}
.map-svg {
width: 100%;
height: 100%;
max-width: 360px;
max-height: 360px;
cursor: crosshair;
outline: none;
}
.map-svg:hover {
filter: brightness(1.1);
}
.cardinal {
fill: #4fc3f7;
font-size: 16px;
font-weight: 700;
}
.degree-label {
fill: rgba(79,195,247,0.7);
font-size: 12px;
font-weight: 600;
}
.dir-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 8px;
border-top: 1px solid #2d3748;
flex-shrink: 0;
}
.dir-btn {
padding: 8px 0;
border: 2px solid rgba(79,195,247,0.3);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
color: rgba(255,255,255,0.7);
background: rgba(79,195,247,0.08);
letter-spacing: 0.5px;
transition: all 0.2s;
}
.dir-btn:hover {
border-color: rgba(79,195,247,0.6);
color: rgba(255,255,255,0.9);
background: rgba(79,195,247,0.15);
}
.dir-btn.active {
border-color: #4fc3f7;
color: #4fc3f7;
background: rgba(79,195,247,0.2);
box-shadow: 0 0 15px rgba(79,195,247,0.3);
font-weight: 700;
}
</style>

View File

@@ -148,9 +148,6 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
} }
.card-header { .card-header {
@@ -165,7 +162,6 @@
h2 { h2 {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 1;
color: var(--accent-cyan); color: var(--accent-cyan);
margin: 0; margin: 0;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -189,7 +185,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
flex: 1;
} }
/* Sources */ /* Sources */

View File

@@ -151,9 +151,6 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
} }
.card-header { .card-header {
@@ -225,7 +222,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex: 1;
} }
/* Power Display */ /* Power Display */

View File

@@ -93,27 +93,6 @@
} }
// Handle click on compass to set heading // Handle click on compass to set heading
let popupWindow = null;
function openPopup() {
const features = [
'width=380',
'height=460',
'toolbar=no',
'menubar=no',
'scrollbars=no',
'resizable=yes',
'status=no',
'location=no',
'popup=yes',
].join(',');
if (popupWindow && !popupWindow.closed) {
popupWindow.focus();
return;
}
popupWindow = window.open('/popup.html', 'rotator-popup', features);
}
async function handleCompassClick(event) { async function handleCompassClick(event) {
const svg = event.currentTarget; const svg = event.currentTarget;
const rect = svg.getBoundingClientRect(); const rect = svg.getBoundingClientRect();
@@ -146,10 +125,7 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>Rotator Genius</h2> <h2>Rotator Genius</h2>
<div class="header-right"> <span class="status-dot" class:disconnected={!connected}></span>
<button class="btn-popup" on:click={openPopup} title="Open popup window"></button>
<span class="status-dot" class:disconnected={!connected}></span>
</div>
</div> </div>
<div class="metrics"> <div class="metrics">
@@ -392,9 +368,6 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
} }
.card-header { .card-header {
@@ -409,41 +382,11 @@
h2 { h2 {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: 1;
color: var(--accent-cyan); color: var(--accent-cyan);
margin: 0; margin: 0;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn-popup {
width: 24px;
height: 24px;
border: 1px solid rgba(79,195,247,0.3);
border-radius: 4px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: rgba(79,195,247,0.6);
background: transparent;
padding: 0;
line-height: 1;
transition: all 0.2s;
}
.btn-popup:hover {
border-color: rgba(79,195,247,0.7);
color: #4fc3f7;
background: rgba(79,195,247,0.1);
}
.status-dot { .status-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -462,7 +405,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex: 1;
} }
.heading-controls-row { .heading-controls-row {
@@ -607,4 +549,4 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
</style> </style>

View File

@@ -9,16 +9,11 @@
export let gustWarningThreshold = 50; // km/h export let gustWarningThreshold = 50; // km/h
export let graylineWindow = 30; // minutes avant/après sunrise/sunset export let graylineWindow = 30; // minutes avant/après sunrise/sunset
// FlexRadio status // FlexRadio frequency and mode
$: frequency = flexradio?.frequency || 0; $: frequency = flexradio?.frequency || 0;
$: mode = flexradio?.mode || ''; $: mode = flexradio?.mode || '';
$: txEnabled = flexradio?.tx || false; $: txEnabled = flexradio?.tx || false;
$: connected = flexradio?.connected || false; $: connected = flexradio?.connected || false;
$: radioOn = flexradio?.radio_on || false;
$: radioInfo = flexradio?.radio_info || '';
$: callsign = flexradio?.callsign || '';
$: model = flexradio?.model || '';
$: activeSlices = flexradio?.active_slices || 0;
// Grayline calculation // Grayline calculation
let sunrise = null; let sunrise = null;
@@ -60,16 +55,6 @@
updateTimeToNextEvent(); updateTimeToNextEvent();
} }
$: console.log('FlexRadio status:', {
connected,
radioOn,
frequency,
activeSlices,
radioInfo,
callsign,
model
});
// Simplified sun calculation (based on NOAA algorithm) // Simplified sun calculation (based on NOAA algorithm)
function getSunTimes(date, lat, lon) { function getSunTimes(date, lat, lon) {
const rad = Math.PI / 180; const rad = Math.PI / 180;
@@ -243,24 +228,16 @@
$: currentBand = getBand(frequency); $: currentBand = getBand(frequency);
$: bandColor = getBandColor(currentBand); $: bandColor = getBandColor(currentBand);
// Determine what to show for FlexRadio - MODIFIÉ
$: showFrequency = radioOn && frequency > 0;
$: showRadioOnNoSlice = radioOn && frequency === 0 && activeSlices === 0;
$: showRadioOnWithSliceNoFreq = radioOn && frequency === 0 && activeSlices > 0;
$: showNotConnected = !connected;
$: showConnectedNoRadio = connected && !radioOn;
</script> </script>
<div class="status-banner" class:has-warning={hasAnyWarning}> <div class="status-banner" class:has-warning={hasAnyWarning}>
<!-- FlexRadio Section --> <!-- FlexRadio Section -->
<div class="flex-section"> <div class="flex-section">
<div class="flex-icon" class:connected={connected} class:disconnected={!connected}> <div class="flex-icon" class:connected={connected} class:disconnected={!connected}>
📻 📻
</div> </div>
{#if showFrequency} {#if connected && frequency > 0}
<!-- Radio is on and has active slice with frequency -->
<div class="frequency-display"> <div class="frequency-display">
<span class="frequency" style="--band-color: {bandColor}"> <span class="frequency" style="--band-color: {bandColor}">
{formatFrequency(frequency)} {formatFrequency(frequency)}
@@ -279,58 +256,17 @@
{mode} {mode}
</span> </span>
{/if} {/if}
<!-- AFFICHAGE TX - SEULEMENT SI TX EST VRAI -->
{#if txEnabled} {#if txEnabled}
<span class="tx-indicator"> <span class="tx-indicator">
TX TX
</span> </span>
{/if} {/if}
{:else if showRadioOnWithSliceNoFreq}
<!-- Radio is on with slice but frequency is 0 (maybe slice just created) -->
<div class="radio-status">
<span class="radio-on-indicator"></span>
<span class="radio-status-text">Slice active, waiting for frequency...</span>
{#if model}
<span class="model-badge">{model}</span>
{/if}
{#if callsign}
<span class="callsign-badge">{callsign}</span>
{/if}
</div>
{:else if showRadioOnNoSlice}
<!-- Radio is on but no active slice -->
<div class="radio-status">
<span class="radio-on-indicator"></span>
<span class="radio-status-text">{radioInfo || 'Radio is on'}</span>
{#if model}
<span class="model-badge">{model}</span>
{/if}
{#if callsign}
<span class="callsign-badge">{callsign}</span>
{/if}
</div>
{:else if showConnectedNoRadio}
<!-- TCP connected but radio not responding -->
<div class="radio-status">
<span class="radio-off-indicator"></span>
<span class="radio-status-text">TCP connected, radio off</span>
</div>
{:else if showNotConnected}
<!-- Not connected at all -->
<span class="no-signal">FlexRadio not connected</span>
{:else} {:else}
<!-- Default/unknown state --> <span class="no-signal">FlexRadio non connecté</span>
<span class="no-signal">Checking FlexRadio...</span>
{/if} {/if}
</div> </div>
<!-- Separator --> <!-- Separator -->
<div class="separator"></div> <div class="separator"></div>
@@ -368,7 +304,7 @@
{#if isGrayline} {#if isGrayline}
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}> <span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
✨ Grayline ✨ GRAYLINE
</span> </span>
{:else if timeToNextEvent} {:else if timeToNextEvent}
<span class="next-event"> <span class="next-event">
@@ -376,7 +312,7 @@
</span> </span>
{/if} {/if}
{:else} {:else}
<span class="no-location">📍 Position not set</span> <span class="no-location">📍 Position non configurée</span>
{/if} {/if}
</div> </div>
@@ -406,7 +342,7 @@
{#if !hasAnyWarning} {#if !hasAnyWarning}
<div class="status-ok"> <div class="status-ok">
<span class="ok-icon"></span> <span class="ok-icon"></span>
<span class="ok-text">Weather OK</span> <span class="ok-text">Météo OK</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -508,53 +444,6 @@
50% { opacity: 0.6; } 50% { opacity: 0.6; }
} }
/* Radio status indicators */
.radio-status {
display: flex;
align-items: center;
gap: 8px;
}
.radio-on-indicator {
color: #22c55e;
font-size: 16px;
animation: pulse 2s infinite;
}
.radio-off-indicator {
color: #ef4444;
font-size: 16px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.radio-status-text {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.model-badge {
padding: 3px 8px;
background: rgba(79, 195, 247, 0.2);
border: 1px solid rgba(79, 195, 247, 0.4);
border-radius: 4px;
font-size: 12px;
color: #4fc3f7;
}
.callsign-badge {
padding: 3px 8px;
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.4);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #22c55e;
}
.no-signal { .no-signal {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
font-size: 14px; font-size: 14px;

View File

@@ -140,9 +140,6 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
} }
.card-header { .card-header {
@@ -208,7 +205,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex: 1;
} }
/* Power Display */ /* Power Display */
@@ -381,7 +377,6 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
flex: 1;
} }
.control-btn { .control-btn {

View File

@@ -162,8 +162,10 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title">Ultrabeam VL2.3</div> <h2>Ultrabeam VL2.3</h2>
<span class="status-dot" class:disconnected={!connected}></span> <div class="header-right">
<span class="status-dot" class:disconnected={!connected}></span>
</div>
</div> </div>
<div class="metrics"> <div class="metrics">
@@ -321,24 +323,20 @@
<style> <style>
.card { .card {
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%); background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
border: 1px solid #2d3748; border-radius: 16px;
border-radius: 8px; padding: 16px;
padding: 0; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
overflow: hidden; border: 1px solid rgba(79, 195, 247, 0.2);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
} }
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 16px; margin-bottom: 24px;
background: rgba(79, 195, 247, 0.05); padding-bottom: 16px;
border-bottom: 1px solid #2d3748; border-bottom: 2px solid rgba(79, 195, 247, 0.3);
} }
.header-right { .header-right {
@@ -347,13 +345,14 @@
gap: 12px; gap: 12px;
} }
.card-title { h2 {
margin: 0; margin: 0;
font-size: 14px; font-size: 20px;
font-weight: 600; font-weight: 600;
line-height: 1; background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
color: #4fc3f7; -webkit-background-clip: text;
letter-spacing: 0.5px; -webkit-text-fill-color: transparent;
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
} }
h3 { h3 {
@@ -364,11 +363,11 @@
} }
.status-dot { .status-dot {
width: 8px; width: 12px;
height: 8px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #4caf50; background: #4caf50;
box-shadow: 0 0 8px #4caf50; box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
@@ -387,8 +386,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 16px;
flex: 1;
} }
/* Status Grid */ /* Status Grid */
@@ -562,4 +559,4 @@
gap: 12px; gap: 12px;
} }
</style> </style>

View File

@@ -106,9 +106,6 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
height: 100%;
display: flex;
flex-direction: column;
} }
.card-header { .card-header {
@@ -146,7 +143,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
flex: 1;
} }
/* Relays */ /* Relays */
@@ -257,7 +253,6 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
flex: 1;
} }
.control-btn { .control-btn {

View File

@@ -110,6 +110,4 @@ export const api = {
body: JSON.stringify({ direction }), body: JSON.stringify({ direction }),
}), }),
}, },
// Shutdown
shutdown: () => request('/shutdown', { method: 'POST' }),
}; };

View File

@@ -1,7 +0,0 @@
import PopupApp from './PopupApp.svelte';
const app = new PopupApp({
target: document.getElementById('popup-app'),
});
export default app;

View File

@@ -4,13 +4,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
build: { build: {
outDir: 'dist', outDir: 'dist'
rollupOptions: {
input: {
main: 'index.html',
popup: 'popup.html',
}
}
}, },
server: { server: {
port: 5173, port: 5173,