22 Commits

Author SHA1 Message Date
1e5423c4db popup 2026-04-04 12:52:08 +02:00
02fec72c43 up 2026-02-28 11:49:23 +01:00
08bbaab94b update 2026-02-28 11:01:03 +01:00
238716fdae up 2026-02-28 10:52:04 +01:00
0f2dc76d55 up 2026-01-16 01:28:28 +01:00
5ced01c010 update 2026-01-16 01:17:28 +01:00
30688ad644 working reconnect and slices 2026-01-15 22:39:39 +01:00
3e169fe615 update 2026-01-15 22:34:44 +01:00
21db2addff up 2026-01-15 22:19:32 +01:00
130efeee83 up 2026-01-15 20:30:38 +01:00
4eeec6bdf6 working 2026-01-15 06:51:25 +01:00
de3fda2648 Revert "updated frontend"
This reverts commit b8884d89e3.
2026-01-15 06:44:29 +01:00
c6ceeb103b update sunset 2026-01-15 06:26:49 +01:00
b8884d89e3 updated frontend 2026-01-14 17:35:07 +01:00
5332ab9dc1 update km/h 2026-01-14 14:29:47 +01:00
b8db847343 u 2026-01-13 23:11:58 +01:00
0cb83157de up 2026-01-13 23:10:43 +01:00
4f484b0091 up 2026-01-12 22:34:14 +01:00
6b5508802a up 2026-01-12 22:34:04 +01:00
51e08d9463 working tx inhibit 2026-01-12 22:07:54 +01:00
2bec98a080 Merge branch 'main' of https://git.rouggy.com/rouggy/ShackMaster 2026-01-12 21:40:30 +01:00
431c17347d corrected autotrack still working when deactivated
change track to radio
2026-01-12 21:40:14 +01:00
35 changed files with 2286 additions and 223 deletions

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ web/dist/
web/build/ web/build/
web/.svelte-kit/ web/.svelte-kit/
web/package-lock.json web/package-lock.json
cmd/server/web
# Logs # Logs
*.log *.log

87
Makefile Normal file
View File

@@ -0,0 +1,87 @@
# 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

BIN
cmd/server/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -44,8 +44,11 @@ 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) server := api.NewServer(deviceManager, hub, cfg, shutdownChan)
mux := server.SetupRoutes() mux := server.SetupRoutes()
// Serve embedded static files // Serve embedded static files
@@ -76,12 +79,17 @@ func main() {
} }
}() }()
// Wait for interrupt signal // Wait for interrupt signal or API shutdown request
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
log.Println("Shutting down server...") select {
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")
} }

BIN
cmd/server/resource.syso Normal file

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

@@ -7,8 +7,9 @@
<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/index-ghAyyhf_.js"></script> <script type="module" crossorigin src="/assets/main-CEFSEmZ6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oYZfaWiS.css"> <link rel="modulepreload" crossorigin href="/assets/api-C_k14kaa.js">
<link rel="stylesheet" crossorigin href="/assets/main-CuAW62oI.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -119,13 +119,35 @@ func (dm *DeviceManager) Initialize() error {
dm.flexRadio = flexradio.New( dm.flexRadio = flexradio.New(
dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Host,
dm.config.Devices.FlexRadio.Port, dm.config.Devices.FlexRadio.Port,
dm.config.Devices.FlexRadio.InterlockName,
) )
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)
}) })
// Set callback to check if transmit is allowed (based on Ultrabeam motors)
dm.flexRadio.SetTransmitCheckCallback(func() bool {
// Get current Ultrabeam status
ubStatus, err := dm.ultrabeam.GetStatus()
if err != nil || ubStatus == nil {
// If we cannot get status, allow transmit (fail-safe)
return true
}
// Block transmit if motors are moving
motorsMoving := ubStatus.MotorsMoving != 0
if motorsMoving {
log.Printf("FlexRadio PTT check: Motors moving (bitmask=%d) - BLOCKING", ubStatus.MotorsMoving)
} else {
log.Printf("FlexRadio PTT check: Motors stopped - ALLOWING")
}
return !motorsMoving
})
} }
// Initialize Solar data client // Initialize Solar data client
@@ -402,9 +424,7 @@ 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,6 +5,7 @@ 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"
@@ -15,13 +16,15 @@ 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) *Server { func NewServer(dm *DeviceManager, hub *Hub, cfg *config.Config, shutdownChan chan struct{}) *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,
@@ -74,6 +77,9 @@ 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
@@ -510,6 +516,21 @@ 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,6 +9,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
) )
type Client struct { type Client struct {
@@ -17,8 +18,8 @@ type Client struct {
conn net.Conn conn net.Conn
reader *bufio.Reader reader *bufio.Reader
connMu sync.Mutex // For connection management connMu sync.Mutex
writeMu sync.Mutex // For writing to connection (separate from reads) writeMu sync.Mutex
lastStatus *Status lastStatus *Status
statusMu sync.RWMutex statusMu sync.RWMutex
@@ -29,26 +30,67 @@ type Client struct {
running bool running bool
stopChan chan struct{} stopChan chan struct{}
// Callbacks reconnectInterval time.Duration
onFrequencyChange func(freqMHz float64) 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)
checkTransmitAllowed func() bool
// Track current slice frequency
currentFreq float64
currentFreqMu sync.RWMutex
} }
func New(host string, port int, interlockName string) *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
} }
// SetTransmitCheckCallback sets the callback to check if transmit is allowed
func (c *Client) SetTransmitCheckCallback(callback func() bool) {
c.checkTransmitAllowed = callback
}
func (c *Client) Connect() error { func (c *Client) Connect() error {
c.connMu.Lock() c.connMu.Lock()
defer c.connMu.Unlock() defer c.connMu.Unlock()
@@ -67,8 +109,9 @@ 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: Connected successfully") log.Println("FlexRadio: TCP connection established")
return nil return nil
} }
@@ -77,14 +120,16 @@ func (c *Client) Start() error {
return nil return nil
} }
// Try initial connection
if err := c.Connect(); err != nil { if err := c.Connect(); err != nil {
return err log.Printf("FlexRadio: Initial connection failed: %v", err)
} }
// Update connected status // Update connected status
c.statusMu.Lock() c.statusMu.Lock()
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Connected = true c.lastStatus.Connected = (c.conn != nil)
c.lastStatus.RadioOn = false
} }
c.statusMu.Unlock() c.statusMu.Unlock()
@@ -93,11 +138,27 @@ func (c *Client) Start() error {
// Start message listener // Start message listener
go c.messageLoop() go c.messageLoop()
// Subscribe to slice updates for frequency tracking // Start reconnection monitor
log.Println("FlexRadio: Subscribing to slice updates...") go c.reconnectionMonitor()
_, err := c.sendCommand("sub slice all")
if err != nil { // Start radio status checker
log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err) go c.radioStatusChecker()
// 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
@@ -111,6 +172,14 @@ 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()
@@ -119,23 +188,34 @@ func (c *Client) Stop() {
} }
c.connMu.Unlock() c.connMu.Unlock()
// Update connected status // Update 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()
} }
func (c *Client) getNextSeq() int { // Helper functions for common commands
c.cmdSeqMu.Lock() func (c *Client) SendInfo() error {
defer c.cmdSeqMu.Unlock() return c.sendCommand("info")
c.cmdSeq++
return c.cmdSeq
} }
func (c *Client) sendCommand(cmd string) (string, error) { func (c *Client) SendSliceList() error {
// Use writeMu instead of connMu to avoid blocking on messageLoop reads return c.sendCommand("slice list")
}
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()
@@ -144,7 +224,7 @@ func (c *Client) sendCommand(cmd string) (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()
@@ -154,14 +234,35 @@ func (c *Client) sendCommand(cmd string) (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()
c.conn = nil if c.conn != nil {
c.reader = nil c.conn.Close()
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() {
@@ -172,25 +273,20 @@ 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()
@@ -199,10 +295,11 @@ 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
@@ -219,73 +316,497 @@ 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) {
// Response format: R<seq>|<status>|<data> msg = strings.TrimSpace(msg)
if strings.HasPrefix(msg, "R") { if msg == "" {
return return
} }
// Status format: S<handle>|<key>=<value> ... // DEBUG: Log tous les messages reçus
if strings.HasPrefix(msg, "S") { log.Printf("FlexRadio RAW: %s", msg)
c.handleStatus(msg)
// Vérifier le type de message
if len(msg) < 2 {
return return
} }
// Version/handle format: V<version>|H<handle> // Messages commençant par R (réponses)
if strings.HasPrefix(msg, "V") { if msg[0] == 'R' {
log.Printf("FlexRadio: Version/Handle received: %s", msg) c.handleCommandResponse(msg)
return return
} }
// Message format: M<handle>|<message> // Messages commençant par S (statut)
if strings.HasPrefix(msg, "M") { if msg[0] == 'S' {
// 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)
return default:
log.Printf("FlexRadio: Type de message inconnu: %s", msg)
} }
} }
func (c *Client) handleStatus(msg string) { func (c *Client) handleSliceStatus(handle string, statusMap map[string]string, sliceNum int) {
// Format: S<handle>|<key>=<value> ... c.statusMu.Lock()
parts := strings.SplitN(msg, "|", 2) defer c.statusMu.Unlock()
if len(parts) < 2 {
if c.lastStatus == nil {
return return
} }
data := parts[1] // Mettre à jour le nombre de slices actives
c.lastStatus.ActiveSlices = 1
// Parse key=value pairs // Mettre à jour la fréquence
pairs := strings.Fields(data) if rfFreq, ok := statusMap["RF_frequency"]; ok {
statusMap := make(map[string]string) 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) {
// Format: R<seq>|<status>|<data>
parts := strings.SplitN(msg, "|", 3)
if len(parts) < 3 {
log.Printf("FlexRadio: Malformed response: %s", msg)
return
}
seqStr := strings.TrimPrefix(parts[0], "R")
status := parts[1]
data := parts[2]
seq, _ := strconv.Atoi(seqStr)
if status != "0" {
log.Printf("FlexRadio: Command error (seq=%d): %s", seq, msg)
return
}
log.Printf("FlexRadio: Command success (seq=%d)", seq)
// Identifier le type de réponse par son contenu
switch {
case strings.Contains(data, "model="):
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 {
statusMap[kv[0]] = kv[1] key := strings.TrimSpace(kv[0])
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
} }
} }
// Check for slice updates (frequency changes) c.radioInfoMu.Unlock()
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()
// Only log significant frequency changes (> 1 kHz) // Mettre à jour le statut
if oldFreq == 0 || (freq-oldFreq)*(freq-oldFreq) > 0.001 { c.updateRadioStatus(true, "Radio is on")
log.Printf("FlexRadio: Frequency updated to %.6f MHz", freq)
// Trigger callback for immediate auto-track go func() {
if c.onFrequencyChange != nil { time.Sleep(300 * time.Millisecond)
go c.onFrequencyChange(freq) c.SendSliceList()
}
} // 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
}
}
} }
func (c *Client) GetStatus() (*Status, error) { func (c *Client) GetStatus() (*Status, error) {
@@ -293,14 +814,27 @@ func (c *Client) GetStatus() (*Status, error) {
defer c.statusMu.RUnlock() defer c.statusMu.RUnlock()
if c.lastStatus == nil { if c.lastStatus == nil {
return &Status{Connected: false}, nil return &Status{
Connected: false,
RadioOn: false,
Tx: false,
RadioInfo: "Not initialized",
}, nil
} }
// Create a copy // Créer une copie
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,13 +2,17 @@ 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"`
InterlockID string `json:"interlock_id"` RadioOn bool `json:"radio_on"`
InterlockState string `json:"interlock_state"` RadioInfo string `json:"radio_info"`
Frequency float64 `json:"frequency"` // MHz Frequency float64 `json:"frequency"` // Primary frequency in MHz
Model string `json:"model"` Mode string `json:"mode"`
Serial string `json:"serial"` Tx bool `json:"tx"` // Actually transmitting
Version string `json:"version"` ActiveSlices int `json:"active_slices"`
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

@@ -23,8 +23,9 @@ type Client struct {
} }
type Status struct { type Status struct {
Heading int `json:"heading"` Heading int `json:"heading"`
Connected bool `json:"connected"` TargetHeading int `json:"target_heading"`
Connected bool `json:"connected"`
} }
func New(host string, port int) *Client { func New(host string, port int) *Client {
@@ -211,6 +212,11 @@ func (c *Client) parseStatus(response string) *Status {
if err == nil { if err == nil {
status.Heading = heading status.Heading = heading
} }
targetStr := response[19:22]
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
if err == nil {
status.TargetHeading = targetHeading
}
} }
return status return status

View File

@@ -101,13 +101,14 @@ 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, WindSpeed: owmData.Wind.Speed * 3.6,
WindGust: owmData.Wind.Gust, WindGust: owmData.Wind.Gust * 3.6,
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),

16
web/popup.html Normal file
View File

@@ -0,0 +1,16 @@
<!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

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { wsService, connected, systemStatus } from './lib/websocket.js'; import { wsService, connected, systemStatus } from './lib/websocket.js';
import { api } from './lib/api.js'; import { api } from './lib/api.js';
import StatusBanner from './components/StatusBanner.svelte';
import WebSwitch from './components/WebSwitch.svelte'; import WebSwitch from './components/WebSwitch.svelte';
import PowerGenius from './components/PowerGenius.svelte'; import PowerGenius from './components/PowerGenius.svelte';
import TunerGenius from './components/TunerGenius.svelte'; import TunerGenius from './components/TunerGenius.svelte';
@@ -13,6 +14,8 @@
let isConnected = false; let isConnected = false;
let currentTime = new Date(); let currentTime = new Date();
let callsign = 'F4BPO'; // Default let callsign = 'F4BPO'; // Default
let latitude = null;
let longitude = null;
const unsubscribeStatus = systemStatus.subscribe(value => { const unsubscribeStatus = systemStatus.subscribe(value => {
status = value; status = value;
@@ -40,6 +43,10 @@
if (config.callsign) { if (config.callsign) {
callsign = config.callsign; callsign = config.callsign;
} }
if (config.location) {
latitude = config.location.latitude;
longitude = config.location.longitude;
}
} catch (err) { } catch (err) {
console.error('Failed to fetch config:', err); console.error('Failed to fetch config:', err);
} }
@@ -64,6 +71,14 @@
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,
@@ -76,11 +91,14 @@
<div class="app"> <div class="app">
<header> <header>
<div class="header-left"> <div class="header-left">
<h1>{callsign} Shack</h1> <h1>{callsign}'s 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">
@@ -95,10 +113,10 @@
<div class="header-right"> <div class="header-right">
<div class="weather-info"> <div class="weather-info">
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)}m/s</span> <span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)} km/h</span>
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)}m/s</span> <span title="Gust">💨 {weatherData.wind_gust.toFixed(1)} km/h</span>
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)}°C</span> <span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
<span title="Feels like">{weatherData.feels_like.toFixed(1)}°C</span> <span title="Feels like">{weatherData.feels_like.toFixed(1)} °C</span>
</div> </div>
<div class="clock"> <div class="clock">
<span class="time">{formatTime(currentTime)}</span> <span class="time">{formatTime(currentTime)}</span>
@@ -107,6 +125,16 @@
</div> </div>
</header> </header>
<!-- ✅ NOUVEAU : Bandeau de statut avec fréquence FlexRadio et alertes météo -->
<StatusBanner
flexradio={status?.flexradio}
weather={status?.weather}
{latitude}
{longitude}
windWarningThreshold={30}
gustWarningThreshold={50}
/>
<main> <main>
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="row"> <div class="row">
@@ -132,12 +160,13 @@
} }
header { header {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: 16px 24px; padding: 8px 24px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
} }
@@ -165,6 +194,25 @@
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;
@@ -243,6 +291,7 @@
.date { .date {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
padding-top: 0px;
} }
main { main {
@@ -263,6 +312,7 @@
display: flex; display: flex;
gap: 24px; gap: 24px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: stretch;
} }
.row > :global(*) { .row > :global(*) {

448
web/src/PopupApp.svelte Normal file
View File

@@ -0,0 +1,448 @@
<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,6 +148,9 @@
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 {
@@ -162,6 +165,7 @@
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;
@@ -185,6 +189,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
flex: 1;
} }
/* Sources */ /* Sources */

View File

@@ -73,7 +73,6 @@
<div class="power-bar-container"> <div class="power-bar-container">
<div class="power-bar-bg"> <div class="power-bar-bg">
<div class="power-bar-fill" style="width: {powerPercent}%"> <div class="power-bar-fill" style="width: {powerPercent}%">
<div class="power-bar-glow"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -152,6 +151,9 @@
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 {
@@ -223,6 +225,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex: 1;
} }
/* Power Display */ /* Power Display */
@@ -282,16 +285,6 @@
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.power-bar-glow {
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
animation: shimmer 2s infinite;
}
@keyframes shimmer { @keyframes shimmer {
0% { transform: translateX(-100%); } 0% { transform: translateX(-100%); }
100% { transform: translateX(100%); } 100% { transform: translateX(100%); }

View File

@@ -13,7 +13,6 @@
// Update heading with detailed logging to debug // Update heading with detailed logging to debug
$: if (status?.heading !== undefined && status?.heading !== null) { $: if (status?.heading !== undefined && status?.heading !== null) {
const newHeading = status.heading; const newHeading = status.heading;
const oldHeading = heading;
if (heading === null) { if (heading === null) {
// First time: accept any value // First time: accept any value
@@ -25,7 +24,6 @@
} else { } else {
// Normal update // Normal update
heading = newHeading; heading = newHeading;
console.log(` ✓ Updated to ${heading}°`);
} }
} }
@@ -34,32 +32,38 @@
$: connected = status?.connected || false; $: connected = status?.connected || false;
let targetHeading = 0; // ✅ Target heading from rotator status (when controlled by PST Rotator or other software)
let hasTarget = false; $: statusTargetHeading = status?.target_heading ?? null;
// Clear target when we reach it (within 5 degrees) // Local target (when clicking on map in ShackMaster)
$: if (hasTarget && heading !== null) { let localTargetHeading = null;
const diff = Math.abs(heading - targetHeading);
// ✅ Determine if antenna is moving to a target from status
// (target differs from current heading by more than 2 degrees)
$: isMovingFromStatus = statusTargetHeading !== null &&
heading !== null &&
(() => {
const diff = Math.abs(statusTargetHeading - heading);
const wrappedDiff = Math.min(diff, 360 - diff);
return wrappedDiff > 2;
})();
// ✅ Active target: prefer status target when moving, otherwise use local target
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
// ✅ Has target if there's an active target that differs from current heading
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
const diff = Math.abs(activeTargetHeading - heading);
const wrappedDiff = Math.min(diff, 360 - diff); const wrappedDiff = Math.min(diff, 360 - diff);
if (wrappedDiff < 5) { return wrappedDiff > 2;
hasTarget = false; })();
}
}
async function goToHeading() { // Clear local target when we reach it (within 3 degrees)
if (targetHeading < 0 || targetHeading > 359) { $: if (localTargetHeading !== null && heading !== null) {
// Removed alert popup - check console for errors const diff = Math.abs(heading - localTargetHeading);
return; const wrappedDiff = Math.min(diff, 360 - diff);
} if (wrappedDiff < 3) {
try { localTargetHeading = null;
hasTarget = true; // Mark that we have a target
// Subtract 10 degrees to compensate for rotator momentum
const adjustedHeading = (targetHeading + 360) % 360;
await api.rotator.setHeading(adjustedHeading);
} catch (err) {
console.error('Failed to set heading:', err);
hasTarget = false;
// Removed alert popup - check console for errors
} }
} }
@@ -81,6 +85,7 @@
async function stop() { async function stop() {
try { try {
localTargetHeading = null; // Clear local target on stop
await api.rotator.stop(); await api.rotator.stop();
} catch (err) { } catch (err) {
console.error('Failed to stop:', err); console.error('Failed to stop:', err);
@@ -88,7 +93,28 @@
} }
// Handle click on compass to set heading // Handle click on compass to set heading
function handleCompassClick(event) { 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) {
const svg = event.currentTarget; const svg = event.currentTarget;
const rect = svg.getBoundingClientRect(); const rect = svg.getBoundingClientRect();
const centerX = rect.width / 2; const centerX = rect.width / 2;
@@ -104,17 +130,26 @@
// Round to nearest 5 degrees // Round to nearest 5 degrees
const roundedHeading = Math.round(angle / 5) * 5; const roundedHeading = Math.round(angle / 5) * 5;
const adjustedHeading = (roundedHeading + 360) % 360;
// Set target and go // ✅ CORRIGÉ : Send command first, then set localTargetHeading only on success
targetHeading = roundedHeading; try {
goToHeading(); await api.rotator.setHeading(adjustedHeading);
// Only set local target AFTER successful API call
localTargetHeading = adjustedHeading;
} catch (err) {
console.error('Failed to set heading:', err);
}
} }
</script> </script>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>Rotator Genius</h2> <h2>Rotator Genius</h2>
<span class="status-dot" class:disconnected={!connected}></span> <div class="header-right">
<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">
@@ -124,8 +159,8 @@
<div class="heading-label">CURRENT HEADING</div> <div class="heading-label">CURRENT HEADING</div>
<div class="heading-value"> <div class="heading-value">
{displayHeading}° {displayHeading}°
{#if hasTarget} {#if hasTarget && activeTargetHeading !== null}
<span class="target-indicator">{targetHeading}°</span> <span class="target-indicator">{activeTargetHeading}°</span>
{/if} {/if}
</div> </div>
</div> </div>
@@ -282,19 +317,21 @@
</g> </g>
<!-- Target arrow (if we have a target) --> <!-- Target arrow (yellow) - shown when antenna is moving to target -->
{#if hasTarget} {#if hasTarget && activeTargetHeading !== null}
<g transform="rotate({targetHeading})"> <g transform="rotate({activeTargetHeading})">
<line x1="0" y1="0" x2="0" y2="-120" <!-- Target direction line (dashed yellow) -->
<line x1="0" y1="0" x2="0" y2="-135"
stroke="#ffc107" stroke="#ffc107"
stroke-width="3" stroke-width="3"
stroke-dasharray="8,4" stroke-dasharray="8,4"
opacity="0.9"/> opacity="0.9"/>
<g transform="translate(0, -120)"> <!-- Target arrow head with pulse animation -->
<polygon points="0,-15 -10,10 0,5 10,10" <g transform="translate(0, -135)">
<polygon points="0,-12 -8,6 0,2 8,6"
fill="#ffc107" fill="#ffc107"
stroke="#ff9800" stroke="#ff9800"
stroke-width="2" stroke-width="1.5"
style="filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.8))"> 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"/> <animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
</polygon> </polygon>
@@ -302,7 +339,7 @@
</g> </g>
{/if} {/if}
<!-- Center dot (your QTH - JN36dg) --> <!-- Center dot (your QTH) -->
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2"> <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"/> <animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
</circle> </circle>
@@ -344,8 +381,6 @@
</div> </div>
</div> </div>
{/if} {/if}
<!-- Go To Heading -->
</div> </div>
</div> </div>
@@ -357,6 +392,9 @@
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 {
@@ -371,11 +409,41 @@
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;
@@ -394,10 +462,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex: 1;
} }
/* Heading Display */
.heading-controls-row { .heading-controls-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -486,7 +553,6 @@
50% { opacity: 1; } 50% { opacity: 1; }
} }
/* Map */
.map-container { .map-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -522,7 +588,7 @@
.clickable-compass { .clickable-compass {
cursor: crosshair; cursor: crosshair;
user-select: none; user-select: none;
outline: none; /* Remove focus outline */ outline: none;
} }
.clickable-compass:hover { .clickable-compass:hover {
@@ -541,5 +607,4 @@
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
</style> </style>

View File

@@ -0,0 +1,736 @@
<script>
import { onMount, onDestroy } from 'svelte';
export let flexradio = null;
export let weather = null;
export let latitude = null;
export let longitude = null;
export let windWarningThreshold = 30; // km/h
export let gustWarningThreshold = 50; // km/h
export let graylineWindow = 30; // minutes avant/après sunrise/sunset
// FlexRadio status
$: frequency = flexradio?.frequency || 0;
$: mode = flexradio?.mode || '';
$: txEnabled = flexradio?.tx || 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
let sunrise = null;
let sunset = null;
let isGrayline = false;
let graylineType = ''; // 'sunrise' ou 'sunset'
let timeToNextEvent = '';
let currentTime = new Date();
let clockInterval;
// Update time every minute for grayline check
onMount(() => {
calculateSunTimes();
clockInterval = setInterval(() => {
currentTime = new Date();
checkGrayline();
updateTimeToNextEvent();
}, 10000); // Update every 10 seconds
});
onDestroy(() => {
if (clockInterval) clearInterval(clockInterval);
});
// Recalculate when location changes
$: if (latitude && longitude) {
calculateSunTimes();
}
// SunCalc algorithm (simplified version)
function calculateSunTimes() {
if (!latitude || !longitude) return;
const now = new Date();
const times = getSunTimes(now, latitude, longitude);
sunrise = times.sunrise;
sunset = times.sunset;
checkGrayline();
updateTimeToNextEvent();
}
$: console.log('FlexRadio status:', {
connected,
radioOn,
frequency,
activeSlices,
radioInfo,
callsign,
model
});
// Simplified sun calculation (based on NOAA algorithm)
function getSunTimes(date, lat, lon) {
const rad = Math.PI / 180;
const dayOfYear = getDayOfYear(date);
// Fractional year
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (date.getHours() - 12) / 24);
// Equation of time (minutes)
const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma)
- 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma));
// Solar declination (radians)
const decl = 0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma)
- 0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma)
- 0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma);
// Hour angle for sunrise/sunset
const latRad = lat * rad;
const zenith = 90.833 * rad; // Official zenith for sunrise/sunset
const cosHA = (Math.cos(zenith) / (Math.cos(latRad) * Math.cos(decl)))
- Math.tan(latRad) * Math.tan(decl);
// Check for polar day/night
if (cosHA > 1 || cosHA < -1) {
return { sunrise: null, sunset: null };
}
const ha = Math.acos(cosHA) / rad; // Hour angle in degrees
// Sunrise and sunset times in minutes from midnight UTC
const sunriseMinutes = 720 - 4 * (lon + ha) - eqTime;
const sunsetMinutes = 720 - 4 * (lon - ha) - eqTime;
// Convert to local Date objects
const sunriseDate = new Date(date);
sunriseDate.setUTCHours(0, 0, 0, 0);
sunriseDate.setUTCMinutes(sunriseMinutes);
const sunsetDate = new Date(date);
sunsetDate.setUTCHours(0, 0, 0, 0);
sunsetDate.setUTCMinutes(sunsetMinutes);
return { sunrise: sunriseDate, sunset: sunsetDate };
}
function getDayOfYear(date) {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date - start;
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay);
}
function checkGrayline() {
if (!sunrise || !sunset) {
isGrayline = false;
return;
}
const now = currentTime.getTime();
const windowMs = graylineWindow * 60 * 1000;
const nearSunrise = Math.abs(now - sunrise.getTime()) <= windowMs;
const nearSunset = Math.abs(now - sunset.getTime()) <= windowMs;
isGrayline = nearSunrise || nearSunset;
graylineType = nearSunrise ? 'sunrise' : (nearSunset ? 'sunset' : '');
}
function updateTimeToNextEvent() {
if (!sunrise || !sunset) {
timeToNextEvent = '';
return;
}
const now = currentTime.getTime();
let nextEvent = null;
let eventName = '';
if (now < sunrise.getTime()) {
nextEvent = sunrise;
eventName = 'Sunrise';
} else if (now < sunset.getTime()) {
nextEvent = sunset;
eventName = 'Sunset';
} else {
// After sunset, calculate tomorrow's sunrise
const tomorrow = new Date(currentTime);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowTimes = getSunTimes(tomorrow, latitude, longitude);
nextEvent = tomorrowTimes.sunrise;
eventName = 'Sunrise';
}
if (nextEvent) {
const diffMs = nextEvent.getTime() - now;
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
timeToNextEvent = `${eventName} in ${hours}h${minutes.toString().padStart(2, '0')}m`;
} else {
timeToNextEvent = `${eventName} in ${minutes}m`;
}
}
}
function formatTime(date) {
if (!date) return '--:--';
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
// Format frequency for display (MHz with appropriate decimals)
function formatFrequency(freqMHz) {
if (!freqMHz || freqMHz === 0) return '---';
if (freqMHz < 10) {
return freqMHz.toFixed(4);
} else if (freqMHz < 100) {
return freqMHz.toFixed(3);
} else {
return freqMHz.toFixed(2);
}
}
// Get band from frequency
function getBand(freqMHz) {
if (!freqMHz || freqMHz === 0) return '';
if (freqMHz >= 1.8 && freqMHz <= 2.0) return '160M';
if (freqMHz >= 3.5 && freqMHz <= 4.0) return '80M';
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60M';
if (freqMHz >= 7.0 && freqMHz <= 7.3) return '40M';
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30M';
if (freqMHz >= 14.0 && freqMHz <= 14.35) return '20M';
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17M';
if (freqMHz >= 21.0 && freqMHz <= 21.45) return '15M';
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12M';
if (freqMHz >= 28.0 && freqMHz <= 29.7) return '10M';
if (freqMHz >= 50.0 && freqMHz <= 54.0) return '6M';
if (freqMHz >= 144.0 && freqMHz <= 148.0) return '2M';
if (freqMHz >= 430.0 && freqMHz <= 440.0) return '70CM';
return '';
}
// Weather alerts
$: windSpeed = weather?.wind_speed || 0;
$: windGust = weather?.wind_gust || 0;
$: hasWindWarning = windSpeed >= windWarningThreshold;
$: hasGustWarning = windGust >= gustWarningThreshold;
$: hasAnyWarning = hasWindWarning || hasGustWarning;
// Band colors
function getBandColor(band) {
const colors = {
'160M': '#9c27b0',
'80M': '#673ab7',
'60M': '#3f51b5',
'40M': '#2196f3',
'30M': '#00bcd4',
'20M': '#009688',
'17M': '#4caf50',
'15M': '#8bc34a',
'12M': '#cddc39',
'10M': '#ffeb3b',
'6M': '#ff9800',
'2M': '#ff5722',
'70CM': '#f44336'
};
return colors[band] || '#4fc3f7';
}
$: currentBand = getBand(frequency);
$: 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>
<div class="status-banner" class:has-warning={hasAnyWarning}>
<!-- FlexRadio Section -->
<div class="flex-section">
<div class="flex-icon" class:connected={connected} class:disconnected={!connected}>
📻
</div>
{#if showFrequency}
<!-- Radio is on and has active slice with frequency -->
<div class="frequency-display">
<span class="frequency" style="--band-color: {bandColor}">
{formatFrequency(frequency)}
</span>
<span class="unit">MHz</span>
</div>
{#if currentBand}
<span class="band-badge" style="background-color: {bandColor}">
{currentBand}
</span>
{/if}
{#if mode}
<span class="mode-badge">
{mode}
</span>
{/if}
<!-- AFFICHAGE TX - SEULEMENT SI TX EST VRAI -->
{#if txEnabled}
<span class="tx-indicator">
TX
</span>
{/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}
<!-- Default/unknown state -->
<span class="no-signal">Checking FlexRadio...</span>
{/if}
</div>
<!-- Separator -->
<div class="separator"></div>
<!-- Grayline Section -->
<div class="grayline-section">
{#if latitude && longitude}
<div class="sun-times">
<span class="sun-item" title="Sunrise">
<svg class="sun-icon sunrise-icon" width="18" height="18" viewBox="0 0 24 24">
<!-- Horizon line -->
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
<!-- Sun (half visible) -->
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#fbbf24"/>
<!-- Rays -->
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/>
<!-- Up arrow -->
<path d="M12 14l-3 3M12 14l3 3M12 14v5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" fill="none"/>
</svg>
{formatTime(sunrise)}
</span>
<span class="sun-item" title="Sunset">
<svg class="sun-icon sunset-icon" width="18" height="18" viewBox="0 0 24 24">
<!-- Horizon line -->
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
<!-- Sun (half visible, setting) -->
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#f97316"/>
<!-- Rays (dimmer) -->
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#f97316" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
<!-- Down arrow -->
<path d="M12 22l-3-3M12 22l3-3M12 22v-5" stroke="#ef4444" stroke-width="2" stroke-linecap="round" fill="none"/>
</svg>
{formatTime(sunset)}
</span>
</div>
{#if isGrayline}
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
✨ Grayline
</span>
{:else if timeToNextEvent}
<span class="next-event">
{timeToNextEvent}
</span>
{/if}
{:else}
<span class="no-location">📍 Position not set</span>
{/if}
</div>
<!-- Separator -->
<div class="separator"></div>
<!-- Weather Alerts Section -->
<div class="weather-section">
{#if hasWindWarning}
<div class="alert wind-alert">
<span class="alert-icon">⚠️</span>
<span class="alert-text">
Vent: <strong>{windSpeed.toFixed(0)} km/h</strong>
</span>
</div>
{/if}
{#if hasGustWarning}
<div class="alert gust-alert">
<span class="alert-icon">🌪️</span>
<span class="alert-text">
Rafales: <strong>{windGust.toFixed(0)} km/h</strong>
</span>
</div>
{/if}
{#if !hasAnyWarning}
<div class="status-ok">
<span class="ok-icon"></span>
<span class="ok-text">Weather OK</span>
</div>
{/if}
</div>
</div>
<style>
.status-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border-bottom: 1px solid rgba(79, 195, 247, 0.15);
gap: 20px;
flex-wrap: wrap;
}
.status-banner.has-warning {
background: linear-gradient(135deg, #1f1a12 0%, #29201b 50%, #2d1b0c 100%);
border-bottom-color: #f59e0b;
}
/* FlexRadio Section */
.flex-section {
display: flex;
align-items: center;
gap: 12px;
}
.flex-icon {
font-size: 20px;
opacity: 0.8;
}
.flex-icon.connected {
opacity: 1;
filter: drop-shadow(0 0 4px rgba(79, 195, 247, 0.6));
}
.flex-icon.disconnected {
opacity: 0.4;
filter: grayscale(1);
}
.frequency-display {
display: flex;
align-items: baseline;
gap: 4px;
}
.frequency {
font-size: 28px;
font-weight: 300;
font-family: 'Roboto Mono', 'Consolas', monospace;
color: var(--band-color, #4fc3f7);
text-shadow: 0 0 15px var(--band-color, rgba(79, 195, 247, 0.5));
letter-spacing: 1px;
}
.unit {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
font-weight: 400;
}
.band-badge {
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
color: #000;
text-shadow: none;
}
.mode-badge {
padding: 4px 10px;
background: rgba(156, 39, 176, 0.3);
border: 1px solid rgba(156, 39, 176, 0.5);
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: #ce93d8;
}
.tx-indicator {
padding: 4px 10px;
background: rgba(244, 67, 54, 0.3);
border: 1px solid #f44336;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
color: #f44336;
text-shadow: 0 0 10px rgba(244, 67, 54, 0.8);
animation: txPulse 0.5s ease-in-out infinite;
}
@keyframes txPulse {
0%, 100% { opacity: 1; }
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 {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
font-style: italic;
}
/* Separator */
.separator {
width: 1px;
height: 30px;
background: rgba(255, 255, 255, 0.2);
}
/* Grayline Section */
.grayline-section {
display: flex;
align-items: center;
gap: 12px;
}
.sun-times {
display: flex;
gap: 12px;
}
.sun-item {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
display: flex;
align-items: center;
gap: 6px;
}
.sun-icon {
flex-shrink: 0;
}
.sunrise-icon {
color: rgba(251, 191, 36, 0.6);
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.4));
}
.sunset-icon {
color: rgba(249, 115, 22, 0.6);
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
}
.grayline-badge {
padding: 5px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
animation: graylinePulse 1.5s ease-in-out infinite;
}
.grayline-badge.sunrise {
background: linear-gradient(135deg, rgba(255, 183, 77, 0.3) 0%, rgba(255, 138, 101, 0.3) 100%);
border: 1px solid rgba(255, 183, 77, 0.6);
color: #ffcc80;
text-shadow: 0 0 10px rgba(255, 183, 77, 0.8);
}
.grayline-badge.sunset {
background: linear-gradient(135deg, rgba(255, 138, 101, 0.3) 0%, rgba(239, 83, 80, 0.3) 100%);
border: 1px solid rgba(255, 138, 101, 0.6);
color: #ffab91;
text-shadow: 0 0 10px rgba(255, 138, 101, 0.8);
}
@keyframes graylinePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.02); }
}
.next-event {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.no-location {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
/* Weather Section */
.weather-section {
display: flex;
align-items: center;
gap: 16px;
}
.alert {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 6px;
animation: alertPulse 2s ease-in-out infinite;
}
.wind-alert {
background: rgba(245, 158, 11, 0.2);
border: 1px solid rgba(245, 158, 11, 0.5);
}
.gust-alert {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
}
@keyframes alertPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.alert-icon {
font-size: 16px;
}
.alert-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
}
.alert-text strong {
color: #fbbf24;
font-weight: 700;
}
.gust-alert .alert-text strong {
color: #f87171;
}
.status-ok {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 6px;
}
.ok-icon {
color: #22c55e;
font-weight: 700;
}
.ok-text {
font-size: 13px;
color: rgba(34, 197, 94, 0.9);
}
/* Responsive */
@media (max-width: 768px) {
.status-banner {
padding: 8px 16px;
gap: 12px;
}
.frequency {
font-size: 22px;
}
.separator {
display: none;
}
.flex-section,
.grayline-section,
.weather-section {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -67,7 +67,6 @@
<div class="power-bar-container"> <div class="power-bar-container">
<div class="power-bar-bg"> <div class="power-bar-bg">
<div class="power-bar-fill" style="width: {powerPercent}%"> <div class="power-bar-fill" style="width: {powerPercent}%">
<div class="power-bar-glow"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -141,6 +140,9 @@
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 {
@@ -206,6 +208,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex: 1;
} }
/* Power Display */ /* Power Display */
@@ -265,16 +268,6 @@
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.power-bar-glow {
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
animation: shimmer 2s infinite;
}
@keyframes shimmer { @keyframes shimmer {
0% { transform: translateX(-100%); } 0% { transform: translateX(-100%); }
100% { transform: translateX(100%); } 100% { transform: translateX(100%); }
@@ -388,6 +381,7 @@
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

@@ -19,6 +19,16 @@
$: interlockState = flexradio?.interlock_state || null; $: interlockState = flexradio?.interlock_state || null;
$: interlockColor = getInterlockColor(interlockState); $: interlockColor = getInterlockColor(interlockState);
// Debug log
$: if (flexradio) {
console.log('FlexRadio data:', {
connected: flexradio.connected,
interlock_state: flexradio.interlock_state,
interlockConnected,
interlockState
});
}
function getInterlockColor(state) { function getInterlockColor(state) {
switch(state) { switch(state) {
case 'READY': return '#4caf50'; case 'READY': return '#4caf50';
@@ -152,18 +162,8 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>Ultrabeam VL2.3</h2> <div class="card-title">Ultrabeam VL2.3</div>
<div class="header-right"> <span class="status-dot" class:disconnected={!connected}></span>
{#if interlockConnected && interlockState}
<div class="interlock-badge" style="border-color: {interlockColor}; color: {interlockColor}">
{interlockState === 'READY' ? '🔓 TX OK' :
interlockState === 'NOT_READY' ? '🔒 TX Block' :
interlockState === 'PTT_REQUESTED' ? '⏳ PTT' :
interlockState === 'TRANSMITTING' ? '📡 TX' : '❓'}
</div>
{/if}
<span class="status-dot" class:disconnected={!connected}></span>
</div>
</div> </div>
<div class="metrics"> <div class="metrics">
@@ -321,20 +321,24 @@
<style> <style>
.card { .card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%); background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
border-radius: 16px; border: 1px solid #2d3748;
padding: 16px; border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); padding: 0;
border: 1px solid rgba(79, 195, 247, 0.2); overflow: hidden;
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;
margin-bottom: 24px; padding: 12px 16px;
padding-bottom: 16px; background: rgba(79, 195, 247, 0.05);
border-bottom: 2px solid rgba(79, 195, 247, 0.3); border-bottom: 1px solid #2d3748;
} }
.header-right { .header-right {
@@ -343,26 +347,13 @@
gap: 12px; gap: 12px;
} }
.interlock-badge { .card-title {
padding: 4px 10px;
border-radius: 12px;
border: 2px solid;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
h2 {
margin: 0; margin: 0;
font-size: 20px; font-size: 14px;
font-weight: 600; font-weight: 600;
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%); line-height: 1;
-webkit-background-clip: text; color: #4fc3f7;
-webkit-text-fill-color: transparent; letter-spacing: 0.5px;
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
} }
h3 { h3 {
@@ -373,11 +364,11 @@
} }
.status-dot { .status-dot {
width: 12px; width: 8px;
height: 12px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #4caf50; background: #4caf50;
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8); box-shadow: 0 0 8px #4caf50;
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
@@ -396,6 +387,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: 16px;
flex: 1;
} }
/* Status Grid */ /* Status Grid */

View File

@@ -106,6 +106,9 @@
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 {
@@ -143,6 +146,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
flex: 1;
} }
/* Relays */ /* Relays */
@@ -253,6 +257,7 @@
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,4 +110,6 @@ export const api = {
body: JSON.stringify({ direction }), body: JSON.stringify({ direction }),
}), }),
}, },
// Shutdown
shutdown: () => request('/shutdown', { method: 'POST' }),
}; };

7
web/src/popup.js Normal file
View File

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

View File

@@ -4,7 +4,13 @@ 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,