Compare commits
1 Commits
flexradio/
...
electron/n
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b3cec6db0 |
87
Makefile
87
Makefile
@@ -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 |
@@ -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
11
cmd/server/web/dist/assets/index-C9xq6o2j.js
vendored
11
cmd/server/web/dist/assets/index-C9xq6o2j.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
11
cmd/server/web/dist/assets/index-CZJHCP4k.js
vendored
11
cmd/server/web/dist/assets/index-CZJHCP4k.js
vendored
File diff suppressed because one or more lines are too long
11
cmd/server/web/dist/assets/index-Ci4y1GIJ.js
vendored
11
cmd/server/web/dist/assets/index-Ci4y1GIJ.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
11
cmd/server/web/dist/assets/index-Drom3Zfz.js
vendored
11
cmd/server/web/dist/assets/index-Drom3Zfz.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
11
cmd/server/web/dist/assets/index-iLwnoiIw.js
vendored
11
cmd/server/web/dist/assets/index-iLwnoiIw.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
cmd/server/web/dist/index.html
vendored
6
cmd/server/web/dist/index.html
vendored
@@ -7,11 +7,11 @@
|
|||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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(*) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -280,57 +257,16 @@
|
|||||||
</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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -110,6 +110,4 @@ export const api = {
|
|||||||
body: JSON.stringify({ direction }),
|
body: JSON.stringify({ direction }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Shutdown
|
|
||||||
shutdown: () => request('/shutdown', { method: 'POST' }),
|
|
||||||
};
|
};
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import PopupApp from './PopupApp.svelte';
|
|
||||||
|
|
||||||
const app = new PopupApp({
|
|
||||||
target: document.getElementById('popup-app'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user