Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e5423c4db | |||
| 02fec72c43 | |||
| 08bbaab94b | |||
| 238716fdae | |||
| 0f2dc76d55 | |||
| 5ced01c010 | |||
| 30688ad644 | |||
| 3e169fe615 | |||
| 21db2addff | |||
| 130efeee83 | |||
| 4eeec6bdf6 | |||
| de3fda2648 | |||
| c6ceeb103b | |||
| b8884d89e3 | |||
| 5332ab9dc1 | |||
| b8db847343 | |||
| 0cb83157de | |||
| 4f484b0091 | |||
| 6b5508802a | |||
| 51e08d9463 | |||
| 2bec98a080 | |||
| 431c17347d | |||
| 4f9e1e88eb | |||
| 414d802d37 | |||
| cd93f0ea67 | |||
| 3d06dd44d5 | |||
| 9837657dd9 | |||
| 46ee44c6c9 | |||
| bcf58b208b | |||
| 0ce18d87bc | |||
| f172678560 | |||
| 5fd81a641d | |||
| eee3f48569 | |||
| 8de9a0dd87 | |||
| bceac40518 | |||
| ac99f291a7 |
@@ -38,6 +38,7 @@ web/dist/
|
||||
web/build/
|
||||
web/.svelte-kit/
|
||||
web/package-lock.json
|
||||
cmd/server/web
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+25
-5
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -13,6 +15,9 @@ import (
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
log.Println("Starting ShackMaster server...")
|
||||
|
||||
@@ -39,10 +44,20 @@ func main() {
|
||||
log.Fatalf("Failed to start device manager: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
server := api.NewServer(deviceManager, hub, cfg)
|
||||
// Channel de shutdown partagé entre main et le handler API
|
||||
shutdownChan := make(chan struct{})
|
||||
|
||||
// Create HTTP server with embedded files
|
||||
server := api.NewServer(deviceManager, hub, cfg, shutdownChan)
|
||||
mux := server.SetupRoutes()
|
||||
|
||||
// Serve embedded static files
|
||||
distFS, err := fs.Sub(webFS, "web/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to access embedded files: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(distFS)))
|
||||
|
||||
// Setup HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
httpServer := &http.Server{
|
||||
@@ -64,12 +79,17 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
// Wait for interrupt signal or API shutdown request
|
||||
quit := make(chan os.Signal, 1)
|
||||
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()
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
|
||||
Binary file not shown.
+11
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
+11
File diff suppressed because one or more lines are too long
+11
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
+11
File diff suppressed because one or more lines are too long
+11
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
+11
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
+11
File diff suppressed because one or more lines are too long
+11
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
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ShackMaster - F4BPO Shack</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<script type="module" crossorigin src="/assets/main-CEFSEmZ6.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-C_k14kaa.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CuAW62oI.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
+23
-13
@@ -4,29 +4,39 @@ server:
|
||||
|
||||
devices:
|
||||
webswitch:
|
||||
host: "10.10.10.119"
|
||||
host: "10.10.10.100"
|
||||
|
||||
power_genius:
|
||||
host: "10.10.10.128"
|
||||
port: 9008
|
||||
host: "10.10.10.110"
|
||||
port: 4001
|
||||
|
||||
tuner_genius:
|
||||
host: "10.10.10.129"
|
||||
port: 9010
|
||||
host: "10.10.10.111"
|
||||
port: 4001
|
||||
|
||||
antenna_genius:
|
||||
host: "10.10.10.130"
|
||||
port: 9007
|
||||
host: "10.10.10.112"
|
||||
port: 4001
|
||||
|
||||
rotator_genius:
|
||||
host: "10.10.10.121"
|
||||
port: 9006
|
||||
host: "10.10.10.113"
|
||||
port: 4533
|
||||
|
||||
ultrabeam:
|
||||
host: "10.10.10.124"
|
||||
port: 4210
|
||||
|
||||
flexradio:
|
||||
enabled: true
|
||||
host: "10.10.10.120"
|
||||
port: 4992
|
||||
interlock_name: "Ultrabeam"
|
||||
|
||||
weather:
|
||||
openweathermap_api_key: "YOUR_API_KEY_HERE"
|
||||
lightning_enabled: true
|
||||
openweathermap_api_key: ""
|
||||
lightning_enabled: false
|
||||
|
||||
location:
|
||||
latitude: 46.2833
|
||||
longitude: 6.2333
|
||||
latitude: 46.2814
|
||||
longitude: 6.2389
|
||||
callsign: "F4BPO"
|
||||
+318
-43
@@ -7,9 +7,11 @@ import (
|
||||
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/flexradio"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/ultrabeam"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
|
||||
@@ -23,6 +25,8 @@ type DeviceManager struct {
|
||||
tunerGenius *tunergenius.Client
|
||||
antennaGenius *antennagenius.Client
|
||||
rotatorGenius *rotatorgenius.Client
|
||||
ultrabeam *ultrabeam.Client
|
||||
flexRadio *flexradio.Client
|
||||
solarClient *solar.Client
|
||||
weatherClient *weather.Client
|
||||
|
||||
@@ -32,6 +36,15 @@ type DeviceManager struct {
|
||||
|
||||
updateInterval time.Duration
|
||||
stopChan chan struct{}
|
||||
|
||||
// Auto frequency tracking
|
||||
freqThreshold int // Threshold for triggering update (Hz)
|
||||
autoTrackEnabled bool
|
||||
ultrabeamDirection int // User-selected direction (0=normal, 1=180, 2=bi-dir)
|
||||
ultrabeamDirectionSet bool // True if user has explicitly set a direction
|
||||
lastFreqUpdateTime time.Time // Last time we sent frequency update
|
||||
freqUpdateCooldown time.Duration // Minimum time between updates
|
||||
|
||||
}
|
||||
|
||||
type SystemStatus struct {
|
||||
@@ -40,6 +53,8 @@ type SystemStatus struct {
|
||||
TunerGenius *tunergenius.Status `json:"tuner_genius"`
|
||||
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
||||
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
||||
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
|
||||
FlexRadio *flexradio.Status `json:"flexradio,omitempty"`
|
||||
Solar *solar.SolarData `json:"solar"`
|
||||
Weather *weather.WeatherData `json:"weather"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -47,10 +62,14 @@ type SystemStatus struct {
|
||||
|
||||
func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
|
||||
return &DeviceManager{
|
||||
config: cfg,
|
||||
hub: hub,
|
||||
updateInterval: 1 * time.Second, // Update status every second
|
||||
stopChan: make(chan struct{}),
|
||||
config: cfg,
|
||||
hub: hub,
|
||||
updateInterval: 200 * time.Millisecond, // Update status every second
|
||||
stopChan: make(chan struct{}),
|
||||
freqThreshold: 25000, // 25 kHz default
|
||||
autoTrackEnabled: true, // Enabled by default
|
||||
ultrabeamDirection: 0, // Normal direction by default
|
||||
freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,23 +86,70 @@ func (dm *DeviceManager) Initialize() error {
|
||||
)
|
||||
|
||||
// Initialize Tuner Genius
|
||||
log.Printf("Initializing TunerGenius: host=%s port=%d", dm.config.Devices.TunerGenius.Host, dm.config.Devices.TunerGenius.Port)
|
||||
dm.tunerGenius = tunergenius.New(
|
||||
dm.config.Devices.TunerGenius.Host,
|
||||
dm.config.Devices.TunerGenius.Port,
|
||||
)
|
||||
|
||||
// Initialize Antenna Genius
|
||||
log.Printf("Initializing AntennaGenius: host=%s port=%d", dm.config.Devices.AntennaGenius.Host, dm.config.Devices.AntennaGenius.Port)
|
||||
dm.antennaGenius = antennagenius.New(
|
||||
dm.config.Devices.AntennaGenius.Host,
|
||||
dm.config.Devices.AntennaGenius.Port,
|
||||
)
|
||||
|
||||
// Initialize Rotator Genius
|
||||
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
||||
dm.rotatorGenius = rotatorgenius.New(
|
||||
dm.config.Devices.RotatorGenius.Host,
|
||||
dm.config.Devices.RotatorGenius.Port,
|
||||
)
|
||||
|
||||
// Initialize Ultrabeam
|
||||
log.Printf("Initializing Ultrabeam: host=%s port=%d", dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port)
|
||||
dm.ultrabeam = ultrabeam.New(
|
||||
dm.config.Devices.Ultrabeam.Host,
|
||||
dm.config.Devices.Ultrabeam.Port,
|
||||
)
|
||||
|
||||
// Initialize FlexRadio if enabled
|
||||
if dm.config.Devices.FlexRadio.Enabled {
|
||||
log.Printf("Initializing FlexRadio: host=%s port=%d", dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port)
|
||||
dm.flexRadio = flexradio.New(
|
||||
dm.config.Devices.FlexRadio.Host,
|
||||
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)
|
||||
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
|
||||
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
|
||||
dm.solarClient = solar.New()
|
||||
|
||||
@@ -94,9 +160,52 @@ func (dm *DeviceManager) Initialize() error {
|
||||
dm.config.Location.Longitude,
|
||||
)
|
||||
|
||||
// Start PowerGenius continuous polling
|
||||
if err := dm.powerGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
||||
// Start device polling in background (non-blocking)
|
||||
go func() {
|
||||
if err := dm.powerGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := dm.tunerGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start TunerGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := dm.antennaGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start AntennaGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("About to launch RotatorGenius goroutine...")
|
||||
go func() {
|
||||
log.Println("Starting RotatorGenius polling goroutine...")
|
||||
if err := dm.rotatorGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start RotatorGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("RotatorGenius goroutine launched")
|
||||
|
||||
log.Println("About to launch Ultrabeam goroutine...")
|
||||
go func() {
|
||||
log.Println("Starting Ultrabeam polling goroutine...")
|
||||
if err := dm.ultrabeam.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start Ultrabeam polling: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("Ultrabeam goroutine launched")
|
||||
|
||||
// Start FlexRadio if enabled
|
||||
if dm.flexRadio != nil {
|
||||
log.Println("Starting FlexRadio connection...")
|
||||
go func() {
|
||||
if err := dm.flexRadio.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start FlexRadio: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("FlexRadio goroutine launched")
|
||||
}
|
||||
|
||||
log.Println("Device manager initialized")
|
||||
@@ -109,6 +218,74 @@ func (dm *DeviceManager) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFrequencyChange is called immediately when FlexRadio frequency changes
|
||||
// This provides instant auto-track response instead of waiting for updateStatus cycle
|
||||
func (dm *DeviceManager) handleFrequencyChange(freqMHz float64) {
|
||||
// Check if ultrabeam is initialized
|
||||
// Check if auto-track is enabled
|
||||
if !dm.autoTrackEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
if dm.ultrabeam == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check cooldown first
|
||||
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
||||
return
|
||||
}
|
||||
|
||||
// Use cached status instead of calling GetStatus (which can block)
|
||||
dm.statusMu.RLock()
|
||||
hasStatus := dm.lastStatus != nil
|
||||
var ubStatus *ultrabeam.Status
|
||||
if hasStatus {
|
||||
ubStatus = dm.lastStatus.Ultrabeam
|
||||
}
|
||||
dm.statusMu.RUnlock()
|
||||
|
||||
if ubStatus == nil || !ubStatus.Connected {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't update if motors are already moving
|
||||
if ubStatus.MotorsMoving != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
freqKhz := int(freqMHz * 1000)
|
||||
ultrabeamFreqKhz := ubStatus.Frequency
|
||||
|
||||
// Only track if in Ultrabeam range (7-54 MHz)
|
||||
if freqKhz < 7000 || freqKhz > 54000 {
|
||||
return
|
||||
}
|
||||
|
||||
freqDiff := freqKhz - ultrabeamFreqKhz
|
||||
if freqDiff < 0 {
|
||||
freqDiff = -freqDiff
|
||||
}
|
||||
|
||||
freqDiffHz := freqDiff * 1000
|
||||
|
||||
if freqDiffHz >= dm.freqThreshold {
|
||||
directionToUse := dm.ultrabeamDirection
|
||||
if !dm.ultrabeamDirectionSet && ubStatus.Direction != 0 {
|
||||
directionToUse = ubStatus.Direction
|
||||
}
|
||||
|
||||
log.Printf("Auto-track (immediate): Updating to %d kHz (diff=%d kHz)", freqKhz, freqDiff)
|
||||
|
||||
if err := dm.ultrabeam.SetFrequency(freqKhz, directionToUse); err != nil {
|
||||
log.Printf("Auto-track (immediate): Failed: %v", err)
|
||||
} else {
|
||||
dm.lastFreqUpdateTime = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) Stop() {
|
||||
log.Println("Stopping device manager...")
|
||||
close(dm.stopChan)
|
||||
@@ -126,6 +303,9 @@ func (dm *DeviceManager) Stop() {
|
||||
if dm.rotatorGenius != nil {
|
||||
dm.rotatorGenius.Close()
|
||||
}
|
||||
if dm.ultrabeam != nil {
|
||||
dm.ultrabeam.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) monitorDevices() {
|
||||
@@ -162,49 +342,129 @@ func (dm *DeviceManager) updateStatus() {
|
||||
log.Printf("Power Genius error: %v", err)
|
||||
}
|
||||
|
||||
// // Tuner Genius
|
||||
// if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
||||
// status.TunerGenius = tgStatus
|
||||
// } else {
|
||||
// log.Printf("Tuner Genius error: %v", err)
|
||||
// }
|
||||
|
||||
// // Antenna Genius
|
||||
// if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
||||
// status.AntennaGenius = agStatus
|
||||
// } else {
|
||||
// log.Printf("Antenna Genius error: %v", err)
|
||||
// }
|
||||
|
||||
// // Rotator Genius
|
||||
// if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
||||
// status.RotatorGenius = rgStatus
|
||||
// } else {
|
||||
// log.Printf("Rotator Genius error: %v", err)
|
||||
// }
|
||||
|
||||
// Solar Data (fetched every 15 minutes, cached)
|
||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||
status.Solar = solarData
|
||||
// Tuner Genius
|
||||
if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
||||
status.TunerGenius = tgStatus
|
||||
} else {
|
||||
log.Printf("Solar data error: %v", err)
|
||||
log.Printf("Tuner Genius error: %v", err)
|
||||
}
|
||||
|
||||
// Weather Data (fetched every 10 minutes, cached)
|
||||
if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil {
|
||||
status.Weather = weatherData
|
||||
// Antenna Genius
|
||||
if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
||||
status.AntennaGenius = agStatus
|
||||
} else {
|
||||
log.Printf("Weather data error: %v", err)
|
||||
log.Printf("Antenna Genius error: %v", err)
|
||||
}
|
||||
|
||||
// Update cached status
|
||||
dm.statusMu.Lock()
|
||||
dm.lastStatus = status
|
||||
dm.statusMu.Unlock()
|
||||
// Rotator Genius
|
||||
if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
||||
status.RotatorGenius = rgStatus
|
||||
} else {
|
||||
log.Printf("Rotator Genius error: %v", err)
|
||||
}
|
||||
|
||||
// Ultrabeam
|
||||
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||
status.Ultrabeam = ubStatus
|
||||
|
||||
// Sync direction with Ultrabeam if user hasn't explicitly set one
|
||||
// This prevents auto-track from using wrong direction before user changes it
|
||||
if !dm.ultrabeamDirectionSet {
|
||||
dm.ultrabeamDirection = ubStatus.Direction
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Printf("Ultrabeam error: %v", err)
|
||||
}
|
||||
|
||||
// FlexRadio (use direct cache access to avoid mutex contention)
|
||||
if dm.flexRadio != nil {
|
||||
// Access lastStatus directly from FlexRadio's internal cache
|
||||
// The messageLoop updates this in real-time, no need to block on GetStatus
|
||||
frStatus, err := dm.flexRadio.GetStatus()
|
||||
if err == nil && frStatus != nil {
|
||||
status.FlexRadio = frStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Auto frequency tracking: Update Ultrabeam when radio frequency differs from Ultrabeam
|
||||
if dm.autoTrackEnabled {
|
||||
// TunerGenius tracking (FlexRadio uses immediate callback)
|
||||
var radioFreqKhz int
|
||||
var radioSource string
|
||||
|
||||
if status.TunerGenius != nil && status.TunerGenius.Connected {
|
||||
// Fallback to TunerGenius frequency (already in kHz)
|
||||
radioFreqKhz = int(status.TunerGenius.FreqA)
|
||||
radioSource = "TunerGenius"
|
||||
}
|
||||
|
||||
if radioFreqKhz > 0 && status.Ultrabeam != nil && status.Ultrabeam.Connected {
|
||||
ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz
|
||||
|
||||
// Only do auto-track if frequency is in Ultrabeam range (40M-6M: 7000-54000 kHz)
|
||||
// This prevents retraction when slice is closed (FreqA becomes 0) or on out-of-range bands
|
||||
if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 {
|
||||
freqDiff := radioFreqKhz - ultrabeamFreqKhz
|
||||
if freqDiff < 0 {
|
||||
freqDiff = -freqDiff
|
||||
}
|
||||
|
||||
// Convert diff to Hz for comparison with threshold (which is in Hz)
|
||||
freqDiffHz := freqDiff * 1000
|
||||
|
||||
// Don't send command if motors are already moving
|
||||
if status.Ultrabeam.MotorsMoving == 0 {
|
||||
if freqDiffHz >= dm.freqThreshold {
|
||||
// Use user's explicitly set direction, or fallback to current Ultrabeam direction
|
||||
directionToUse := dm.ultrabeamDirection
|
||||
if !dm.ultrabeamDirectionSet && status.Ultrabeam.Direction != 0 {
|
||||
directionToUse = status.Ultrabeam.Direction
|
||||
}
|
||||
|
||||
// Check cooldown to prevent rapid fire commands
|
||||
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||
if timeSinceLastUpdate > dm.freqUpdateCooldown {
|
||||
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
|
||||
if err := dm.ultrabeam.SetFrequency(radioFreqKhz, directionToUse); err != nil {
|
||||
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
|
||||
} else {
|
||||
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
|
||||
dm.lastFreqUpdateTime = time.Now() // Update cooldown timer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If out of range, simply skip auto-track but continue with status broadcast
|
||||
}
|
||||
|
||||
// Solar Data (fetched every 15 minutes, cached)
|
||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||
status.Solar = solarData
|
||||
} else {
|
||||
log.Printf("Solar data error: %v", err)
|
||||
}
|
||||
|
||||
// Weather Data (fetched every 10 minutes, cached)
|
||||
if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil {
|
||||
status.Weather = weatherData
|
||||
} else {
|
||||
log.Printf("Weather data error: %v", err)
|
||||
}
|
||||
|
||||
// Update cached status
|
||||
dm.statusMu.Lock()
|
||||
dm.lastStatus = status
|
||||
dm.statusMu.Unlock()
|
||||
|
||||
// Broadcast to all connected clients
|
||||
if dm.hub != nil {
|
||||
dm.hub.BroadcastStatusUpdate(status)
|
||||
}
|
||||
|
||||
// Broadcast to all connected clients
|
||||
if dm.hub != nil {
|
||||
dm.hub.BroadcastStatusUpdate(status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,3 +501,18 @@ func (dm *DeviceManager) AntennaGenius() *antennagenius.Client {
|
||||
func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
|
||||
return dm.rotatorGenius
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client {
|
||||
return dm.ultrabeam
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) SetAutoTrack(enabled bool, thresholdHz int) {
|
||||
dm.autoTrackEnabled = enabled
|
||||
dm.freqThreshold = thresholdHz
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) SetUltrabeamDirection(direction int) {
|
||||
dm.ultrabeamDirection = direction
|
||||
dm.ultrabeamDirectionSet = true // Mark that user has explicitly set direction
|
||||
log.Printf("Ultrabeam direction set to: %d (user choice)", direction)
|
||||
}
|
||||
|
||||
+197
-33
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -15,13 +16,15 @@ type Server struct {
|
||||
hub *Hub
|
||||
config *config.Config
|
||||
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{
|
||||
deviceManager: dm,
|
||||
hub: hub,
|
||||
config: cfg,
|
||||
shutdownChan: shutdownChan,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
@@ -49,24 +52,35 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
||||
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
||||
|
||||
// Rotator endpoints
|
||||
mux.HandleFunc("/api/rotator/move", s.handleRotatorMove)
|
||||
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
||||
mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW)
|
||||
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
||||
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
||||
|
||||
// Ultrabeam endpoints
|
||||
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
|
||||
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
|
||||
mux.HandleFunc("/api/ultrabeam/autotrack", s.handleUltrabeamAutoTrack)
|
||||
mux.HandleFunc("/api/ultrabeam/direction", s.handleUltrabeamDirection)
|
||||
|
||||
// Tuner endpoints
|
||||
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
||||
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune)
|
||||
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna)
|
||||
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
||||
|
||||
// Antenna Genius endpoints
|
||||
mux.HandleFunc("/api/antenna/set", s.handleAntennaSet)
|
||||
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
||||
mux.HandleFunc("/api/antenna/deselect", s.handleAntennaDeselect)
|
||||
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
||||
|
||||
// Power Genius endpoints
|
||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
||||
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||
|
||||
// Static files (will be frontend)
|
||||
mux.Handle("/", http.FileServer(http.Dir("./web/dist")))
|
||||
// Shutdown endpoint
|
||||
mux.HandleFunc("/api/shutdown", s.handleShutdown)
|
||||
|
||||
// Note: Static files are now served from embedded FS in main.go
|
||||
|
||||
return mux
|
||||
}
|
||||
@@ -179,15 +193,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Rotator handlers
|
||||
func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rotator int `json:"rotator"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
Heading int `json:"heading"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -195,7 +208,7 @@ func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.RotatorGenius().MoveToAzimuth(req.Rotator, req.Azimuth); err != nil {
|
||||
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -209,13 +222,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
||||
if err != nil || rotator < 1 || rotator > 2 {
|
||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.RotatorGenius().RotateCW(rotator); err != nil {
|
||||
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -229,13 +236,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
||||
if err != nil || rotator < 1 || rotator > 2 {
|
||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.RotatorGenius().RotateCCW(rotator); err != nil {
|
||||
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -265,7 +266,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Operate bool `json:"operate"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -273,7 +274,30 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Operate); err != nil {
|
||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTunerBypass(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -295,13 +319,15 @@ func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
// Antenna Genius handlers
|
||||
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Port int `json:"port"`
|
||||
Antenna int `json:"antenna"`
|
||||
}
|
||||
|
||||
@@ -310,7 +336,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.TunerGenius().ActivateAntenna(req.Antenna); err != nil {
|
||||
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -318,15 +344,14 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// Antenna Genius handlers
|
||||
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Radio int `json:"radio"`
|
||||
Port int `json:"port"`
|
||||
Antenna int `json:"antenna"`
|
||||
}
|
||||
|
||||
@@ -335,7 +360,24 @@ func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.AntennaGenius().SetRadioAntenna(req.Radio, req.Antenna); err != nil {
|
||||
log.Printf("Deselecting antenna %d from port %d", req.Antenna, req.Port)
|
||||
if err := s.deviceManager.AntennaGenius().DeselectAntenna(req.Port, req.Antenna); err != nil {
|
||||
log.Printf("Failed to deselect antenna: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("Successfully deselected antenna %d from port %d", req.Antenna, req.Port)
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -367,6 +409,128 @@ func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.PowerGenius().SetOperate(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// Ultrabeam handlers
|
||||
func (s *Server) handleUltrabeamFrequency(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Save direction for auto-track to use
|
||||
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||
|
||||
if err := s.deviceManager.Ultrabeam().SetFrequency(req.Frequency, req.Direction); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.Ultrabeam().Retract(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamAutoTrack(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Threshold int `json:"threshold"` // kHz
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.deviceManager.SetAutoTrack(req.Enabled, req.Threshold*1000) // Convert kHz to Hz
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Just save the direction preference for auto-track to use
|
||||
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||
|
||||
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{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -25,6 +25,8 @@ type DevicesConfig struct {
|
||||
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
|
||||
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
||||
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
||||
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
|
||||
FlexRadio FlexRadioConfig `yaml:"flexradio"`
|
||||
}
|
||||
|
||||
type WebSwitchConfig struct {
|
||||
@@ -51,6 +53,18 @@ type RotatorGeniusConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type UltrabeamConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type FlexRadioConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
InterlockName string `yaml:"interlock_name"`
|
||||
}
|
||||
|
||||
type WeatherConfig struct {
|
||||
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
||||
LightningEnabled bool `yaml:"lightning_enabled"`
|
||||
|
||||
@@ -3,157 +3,476 @@ package antennagenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connMu sync.Mutex
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
antennas []Antenna
|
||||
antennasMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index)
|
||||
Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index)
|
||||
Connected bool `json:"connected"`
|
||||
PortA *PortStatus `json:"port_a"`
|
||||
PortB *PortStatus `json:"port_b"`
|
||||
Antennas []Antenna `json:"antennas"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
type PortStatus struct {
|
||||
Auto bool `json:"auto"`
|
||||
Source string `json:"source"`
|
||||
Band int `json:"band"`
|
||||
Frequency float64 `json:"frequency"`
|
||||
Nickname string `json:"nickname"`
|
||||
RxAnt int `json:"rx_ant"`
|
||||
TxAnt int `json:"tx_ant"`
|
||||
InBand int `json:"in_band"`
|
||||
TX bool `json:"tx"`
|
||||
Inhibit int `json:"inhibit"`
|
||||
}
|
||||
|
||||
type Antenna struct {
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
TX string `json:"tx"`
|
||||
RX string `json:"rx"`
|
||||
InBand string `json:"in_band"`
|
||||
Hotkey int `json:"hotkey"`
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
// Read and discard banner
|
||||
_, _ = c.reader.ReadString('\n')
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.stopChan != nil {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
if err := c.Connect(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
_ = c.Connect()
|
||||
|
||||
// Format command with ID: C<id>|<command>
|
||||
fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd)
|
||||
|
||||
// Send command
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
reader := bufio.NewReader(c.conn)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
resp, err := c.sendCommand("status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.parseStatus(resp)
|
||||
}
|
||||
|
||||
func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
status := &Status{
|
||||
Connected: true,
|
||||
}
|
||||
|
||||
// Parse response format from 4O3A API
|
||||
// Expected format will vary - this is a basic parser
|
||||
pairs := strings.Fields(resp)
|
||||
|
||||
for _, pair := range pairs {
|
||||
parts := strings.SplitN(pair, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
|
||||
switch key {
|
||||
case "radio1", "r1":
|
||||
status.Radio1Antenna, _ = strconv.Atoi(value)
|
||||
case "radio2", "r2":
|
||||
status.Radio2Antenna, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SetRadioAntenna sets which antenna a radio should use
|
||||
// radio: 1 or 2
|
||||
// antenna: 0-7 (antenna index)
|
||||
func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
||||
if radio < 1 || radio > 2 {
|
||||
return fmt.Errorf("radio must be 1 or 2")
|
||||
}
|
||||
if antenna < 0 || antenna > 7 {
|
||||
return fmt.Errorf("antenna must be between 0 and 7")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("set radio%d=%d", radio, antenna)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check response for success
|
||||
if !strings.Contains(strings.ToLower(resp), "ok") && resp != "" {
|
||||
// If response doesn't contain "ok" but isn't empty, assume success
|
||||
// (some devices may return the new state instead of "ok")
|
||||
}
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRadioAntenna gets which antenna a radio is currently using
|
||||
func (c *Client) GetRadioAntenna(radio int) (int, error) {
|
||||
if radio < 1 || radio > 2 {
|
||||
return -1, fmt.Errorf("radio must be 1 or 2")
|
||||
}
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
status, err := c.GetStatus()
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
initialized := false
|
||||
|
||||
if radio == 1 {
|
||||
return status.Radio1Antenna, nil
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
continue
|
||||
}
|
||||
initialized = false
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Initialize: get antenna list and subscribe
|
||||
if !initialized {
|
||||
if err := c.initialize(); err != nil {
|
||||
log.Printf("AntennaGenius init error: %v", err)
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
continue
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
// Read spontaneous messages from subscription
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil && c.reader != nil {
|
||||
c.conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond))
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "S") {
|
||||
c.parsePortStatus(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
return status.Radio2Antenna, nil
|
||||
}
|
||||
|
||||
func (c *Client) initialize() error {
|
||||
// Get antenna list
|
||||
log.Println("AntennaGenius: Getting antenna list...")
|
||||
antennas, err := c.getAntennaList()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get antenna list: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("AntennaGenius: Found %d antennas", len(antennas))
|
||||
for i, ant := range antennas {
|
||||
log.Printf("AntennaGenius: Antenna %d: number=%d, name=%s", i, ant.Number, ant.Name)
|
||||
}
|
||||
|
||||
c.antennasMu.Lock()
|
||||
c.antennas = antennas
|
||||
c.antennasMu.Unlock()
|
||||
|
||||
// Initialize status BEFORE subscribing so parsePortStatus can update it
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
PortA: &PortStatus{},
|
||||
PortB: &PortStatus{},
|
||||
Antennas: antennas,
|
||||
Connected: true,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
log.Println("AntennaGenius: Status initialized, now subscribing to port updates...")
|
||||
|
||||
// Subscribe to port updates (this will parse and update port status)
|
||||
if err := c.subscribeToPortUpdates(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe: %w", err)
|
||||
}
|
||||
|
||||
// Request initial status for both ports
|
||||
log.Println("AntennaGenius: Requesting additional port status...")
|
||||
_, _ = c.sendCommand("port get 1") // Port A
|
||||
_, _ = c.sendCommand("port get 2") // Port B
|
||||
|
||||
c.statusMu.RLock()
|
||||
log.Printf("AntennaGenius: Initialization complete - PortA.RxAnt=%d, PortB.RxAnt=%d",
|
||||
c.lastStatus.PortA.RxAnt, c.lastStatus.PortB.RxAnt)
|
||||
c.statusMu.RUnlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// AntennaGenius only accepts C1| for all commands
|
||||
fullCmd := fmt.Sprintf("C1|%s\n", cmd)
|
||||
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read all response lines using shared reader
|
||||
var response strings.Builder
|
||||
|
||||
// Set a read timeout to avoid blocking forever
|
||||
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
defer c.conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if response.Len() > 0 {
|
||||
// We got some data, return it
|
||||
break
|
||||
}
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
response.WriteString(line)
|
||||
|
||||
// Parse spontaneous status updates
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmedLine, "S0|") {
|
||||
c.connMu.Unlock()
|
||||
c.parsePortStatus(trimmedLine)
|
||||
c.connMu.Lock()
|
||||
}
|
||||
|
||||
// Check if this is the last line (empty line or timeout)
|
||||
if trimmedLine == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return response.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) getAntennaList() ([]Antenna, error) {
|
||||
resp, err := c.sendCommand("antenna list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var antennas []Antenna
|
||||
|
||||
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
||||
lines := strings.Split(resp, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "antenna ") {
|
||||
continue
|
||||
}
|
||||
|
||||
antenna := c.parseAntennaLine(line)
|
||||
|
||||
// Skip unconfigured antennas (name = Antenna X with space)
|
||||
if strings.HasPrefix(antenna.Name, "Antenna ") {
|
||||
continue
|
||||
}
|
||||
|
||||
antennas = append(antennas, antenna)
|
||||
}
|
||||
|
||||
return antennas, nil
|
||||
}
|
||||
|
||||
func (c *Client) parseAntennaLine(line string) Antenna {
|
||||
antenna := Antenna{}
|
||||
|
||||
// Extract antenna number
|
||||
if idx := strings.Index(line, "antenna "); idx != -1 {
|
||||
rest := line[idx+8:]
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) > 0 {
|
||||
antenna.Number, _ = strconv.Atoi(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
pairs := strings.Fields(line)
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
|
||||
switch key {
|
||||
case "name":
|
||||
// Replace underscores with spaces
|
||||
antenna.Name = strings.ReplaceAll(value, "_", " ")
|
||||
case "tx":
|
||||
antenna.TX = value
|
||||
case "rx":
|
||||
antenna.RX = value
|
||||
case "inband":
|
||||
antenna.InBand = value
|
||||
case "hotkey":
|
||||
antenna.Hotkey, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
return antenna
|
||||
}
|
||||
|
||||
func (c *Client) subscribeToPortUpdates() error {
|
||||
resp, err := c.sendCommand("sub port all")
|
||||
if err != nil {
|
||||
log.Printf("AntennaGenius: Failed to subscribe: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse initial port status from subscription response
|
||||
// The response may contain S0|port messages with current status
|
||||
lines := strings.Split(resp, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "S0|port") {
|
||||
c.parsePortStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("AntennaGenius: Subscription complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) parsePortStatus(line string) {
|
||||
// Format: S0|port <id> auto=<0|1> source=<src> band=<n> freq=<f> nickname=<name> rxant=<n> txant=<n> inband=<n> tx=<0|1> inhibit=<n>
|
||||
|
||||
var portID int
|
||||
portStatus := &PortStatus{}
|
||||
|
||||
// Extract port ID
|
||||
if idx := strings.Index(line, "port "); idx != -1 {
|
||||
rest := line[idx+5:]
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) > 0 {
|
||||
portID, _ = strconv.Atoi(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
pairs := strings.Fields(line)
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
|
||||
switch key {
|
||||
case "auto":
|
||||
portStatus.Auto = value == "1"
|
||||
case "source":
|
||||
portStatus.Source = value
|
||||
case "band":
|
||||
portStatus.Band, _ = strconv.Atoi(value)
|
||||
case "freq":
|
||||
portStatus.Frequency, _ = strconv.ParseFloat(value, 64)
|
||||
case "nickname":
|
||||
portStatus.Nickname = value
|
||||
case "rxant":
|
||||
portStatus.RxAnt, _ = strconv.Atoi(value)
|
||||
case "txant":
|
||||
portStatus.TxAnt, _ = strconv.Atoi(value)
|
||||
case "inband":
|
||||
portStatus.InBand, _ = strconv.Atoi(value)
|
||||
case "tx":
|
||||
portStatus.TX = value == "1"
|
||||
case "inhibit":
|
||||
portStatus.Inhibit, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Update status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
if portID == 1 {
|
||||
c.lastStatus.PortA = portStatus
|
||||
} else if portID == 2 {
|
||||
c.lastStatus.PortB = portStatus
|
||||
}
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
// Check if device is actually alive
|
||||
// If no antennas and all values are default, device is probably off
|
||||
status := *c.lastStatus
|
||||
if len(status.Antennas) == 0 || (status.PortA != nil && status.PortA.Source == "" && status.PortB != nil && status.PortB.Source == "") {
|
||||
status.Connected = false
|
||||
}
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// SetAntenna sets the antenna for a specific port
|
||||
func (c *Client) SetAntenna(port, antenna int) error {
|
||||
cmd := fmt.Sprintf("port set %d rxant=%d", port, antenna)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeselectAntenna deselects an antenna from a port (sets rxant=00)
|
||||
// Command format: "C1|port set <port> rxant=00"
|
||||
func (c *Client) DeselectAntenna(port, antenna int) error {
|
||||
cmd := fmt.Sprintf("port set %d rxant=00", port)
|
||||
log.Printf("AntennaGenius: Sending deselect command: %s", cmd)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
log.Printf("AntennaGenius: Deselect failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("AntennaGenius: Deselect response: %s", resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reboot reboots the device
|
||||
func (c *Client) Reboot() error {
|
||||
_, err := c.sendCommand("reboot")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
package flexradio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
|
||||
cmdSeq int
|
||||
cmdSeqMu sync.Mutex
|
||||
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
|
||||
reconnectInterval time.Duration
|
||||
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) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
reconnectInterval: 5 * time.Second,
|
||||
maxReconnectDelay: 60 * time.Second,
|
||||
radioInfo: make(map[string]string),
|
||||
activeSlices: []int{},
|
||||
lastStatus: &Status{
|
||||
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
|
||||
func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) {
|
||||
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 {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||
log.Printf("FlexRadio: Connecting to %s...", addr)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
c.reconnectAttempts = 0
|
||||
|
||||
log.Println("FlexRadio: TCP connection established")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try initial connection
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("FlexRadio: Initial connection failed: %v", err)
|
||||
}
|
||||
|
||||
// Update connected status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Connected = (c.conn != nil)
|
||||
c.lastStatus.RadioOn = false
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
c.running = true
|
||||
|
||||
// Start message listener
|
||||
go c.messageLoop()
|
||||
|
||||
// Start reconnection monitor
|
||||
go c.reconnectionMonitor()
|
||||
|
||||
// Start radio status checker
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
|
||||
c.running = false
|
||||
close(c.stopChan)
|
||||
|
||||
// Stop timers
|
||||
if c.infoCheckTimer != nil {
|
||||
c.infoCheckTimer.Stop()
|
||||
}
|
||||
if c.sliceListTimer != nil {
|
||||
c.sliceListTimer.Stop()
|
||||
}
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Update status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
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()
|
||||
}
|
||||
|
||||
// Helper functions for common commands
|
||||
func (c *Client) SendInfo() error {
|
||||
return c.sendCommand("info")
|
||||
}
|
||||
|
||||
func (c *Client) SendSliceList() error {
|
||||
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()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
c.connMu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
seq := c.getNextSeq()
|
||||
fullCmd := fmt.Sprintf("C%d|%s\n", seq, cmd)
|
||||
|
||||
log.Printf("FlexRadio TX: %s", strings.TrimSpace(fullCmd))
|
||||
|
||||
_, err := conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
// Mark connection as broken
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (c *Client) getNextSeq() int {
|
||||
c.cmdSeqMu.Lock()
|
||||
defer c.cmdSeqMu.Unlock()
|
||||
c.cmdSeq++
|
||||
return c.cmdSeq
|
||||
}
|
||||
|
||||
func (c *Client) messageLoop() {
|
||||
log.Println("FlexRadio: Message loop started")
|
||||
|
||||
for c.running {
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil || c.reader == nil {
|
||||
c.connMu.Unlock()
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
line, err := c.reader.ReadString('\n')
|
||||
c.connMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("FlexRadio: Read error: %v", err)
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Connected = false
|
||||
c.lastStatus.RadioOn = false
|
||||
c.lastStatus.RadioInfo = "Connection lost"
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleMessage(line)
|
||||
}
|
||||
|
||||
log.Println("FlexRadio: Message loop stopped")
|
||||
}
|
||||
|
||||
// Message handling - SIMPLIFIED VERSION
|
||||
func (c *Client) handleMessage(msg string) {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// DEBUG: Log tous les messages reçus
|
||||
log.Printf("FlexRadio RAW: %s", msg)
|
||||
|
||||
// Vérifier le type de message
|
||||
if len(msg) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Messages commençant par R (réponses)
|
||||
if msg[0] == 'R' {
|
||||
c.handleCommandResponse(msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Messages commençant par S (statut)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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 {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
c.radioInfoMu.Unlock()
|
||||
|
||||
// Mettre à jour le statut
|
||||
c.updateRadioStatus(true, "Radio is on")
|
||||
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
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) {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{
|
||||
Connected: false,
|
||||
RadioOn: false,
|
||||
Tx: false,
|
||||
RadioInfo: "Not initialized",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Créer une copie
|
||||
status := *c.lastStatus
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package flexradio
|
||||
|
||||
// Status represents the FlexRadio status
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
RadioOn bool `json:"radio_on"`
|
||||
RadioInfo string `json:"radio_info"`
|
||||
Frequency float64 `json:"frequency"` // Primary frequency in MHz
|
||||
Mode string `json:"mode"`
|
||||
Tx bool `json:"tx"` // Actually transmitting
|
||||
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
|
||||
const (
|
||||
InterlockStateReady = "READY"
|
||||
InterlockStateNotReady = "NOT_READY"
|
||||
InterlockStatePTTRequested = "PTT_REQUESTED"
|
||||
InterlockStateTransmitting = "TRANSMITTING"
|
||||
InterlockStateUnkeyRequested = "UNKEY_REQUESTED"
|
||||
)
|
||||
@@ -23,6 +23,14 @@ type Client struct {
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
|
||||
// Connection health tracking
|
||||
lastAliveTime time.Time
|
||||
|
||||
// Auto fan management
|
||||
autoFanEnabled bool
|
||||
lastFanMode string // Remember last manual mode
|
||||
autoFanActive bool // Track if auto-fan is currently active (in Broadcast mode)
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -42,14 +50,19 @@ type Status struct {
|
||||
BandB string `json:"band_b"`
|
||||
FaultPresent bool `json:"fault_present"`
|
||||
Connected bool `json:"connected"`
|
||||
Meffa string `json:"meffa"`
|
||||
|
||||
// Peak hold for display (internal)
|
||||
displayPower float64
|
||||
peakTime time.Time
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
autoFanEnabled: false, // Auto fan DISABLED - manual control only
|
||||
lastFanMode: "Contest",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +103,17 @@ func (c *Client) Close() error {
|
||||
|
||||
// Start begins continuous polling of the device
|
||||
func (c *Client) Start() error {
|
||||
if err := c.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize connection tracking
|
||||
c.lastAliveTime = time.Now()
|
||||
|
||||
// Try to connect, but don't fail if it doesn't work
|
||||
// The poll loop will keep trying
|
||||
_ = c.Connect()
|
||||
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
@@ -112,10 +128,29 @@ func (c *Client) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Try to reconnect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
// Silent fail, will retry on next tick
|
||||
continue
|
||||
}
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
log.Printf("PowerGenius query error: %v", err)
|
||||
// Try to reconnect
|
||||
// Connection lost, close and retry next tick
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
@@ -123,19 +158,104 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("PowerGenius reconnect failed: %v", err)
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Check if device is actually alive (not just TCP connected)
|
||||
// If voltage is 0 and temperature is 0, device might be temporarily idle
|
||||
// Use a 3-second timeout before marking as disconnected (helps with morse code pauses)
|
||||
if status.Voltage == 0 && status.Temperature == 0 {
|
||||
// Check if we've seen valid data recently (within 3 seconds)
|
||||
if time.Since(c.lastAliveTime) > 3*time.Second {
|
||||
status.Connected = false
|
||||
}
|
||||
// else: Keep Connected = true (device is probably just idle between morse letters)
|
||||
} else {
|
||||
// Valid data received, update lastAliveTime
|
||||
c.lastAliveTime = time.Now()
|
||||
}
|
||||
|
||||
// Peak hold logic - keep highest power for 1 second
|
||||
now := time.Now()
|
||||
if c.lastStatus != nil {
|
||||
// If new power is higher, update peak
|
||||
if status.PowerForward > c.lastStatus.displayPower {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
} else {
|
||||
// Check if peak has expired (1 second)
|
||||
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||
// Keep old peak
|
||||
status.displayPower = c.lastStatus.displayPower
|
||||
status.peakTime = c.lastStatus.peakTime
|
||||
} else {
|
||||
// Peak expired, use current value
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
|
||||
// Override PowerForward with display power for frontend
|
||||
status.PowerForward = status.displayPower
|
||||
|
||||
// Auto fan management based on temperature
|
||||
// Do this BEFORE merging to use the fresh temperature value
|
||||
if c.autoFanEnabled {
|
||||
// Use the temperature from the current status message
|
||||
// If it's 0, use the last known temperature
|
||||
temp := status.Temperature
|
||||
if temp == 0 && c.lastStatus != nil {
|
||||
temp = c.lastStatus.Temperature
|
||||
}
|
||||
|
||||
currentMode := strings.ToUpper(status.FanMode)
|
||||
if currentMode == "" && c.lastStatus != nil {
|
||||
currentMode = strings.ToUpper(c.lastStatus.FanMode)
|
||||
}
|
||||
|
||||
// Only act on valid temperature readings
|
||||
if temp > 5.0 { // Ignore invalid/startup readings below 5°C
|
||||
// If temp >= 60°C, switch to Broadcast
|
||||
if temp >= 60.0 && currentMode != "BROADCAST" {
|
||||
if !c.autoFanActive {
|
||||
log.Printf("PowerGenius: Temperature %.1f°C >= 60°C, switching fan to Broadcast mode", temp)
|
||||
c.autoFanActive = true
|
||||
}
|
||||
if err := c.setFanModeInternal("BROADCAST"); err != nil {
|
||||
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If temp <= 55°C, switch back to Contest
|
||||
if temp <= 55.0 && currentMode == "BROADCAST" {
|
||||
if c.autoFanActive {
|
||||
log.Printf("PowerGenius: Temperature %.1f°C <= 55°C, switching fan back to Contest mode", temp)
|
||||
c.autoFanActive = false
|
||||
}
|
||||
if err := c.setFanModeInternal("CONTEST"); err != nil {
|
||||
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing status (spontaneous messages may only update some fields)
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
// Keep existing values for fields not in the new status
|
||||
if status.PowerForward == 0 && c.lastStatus.PowerForward != 0 {
|
||||
status.PowerForward = c.lastStatus.PowerForward
|
||||
}
|
||||
if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
|
||||
status.Temperature = c.lastStatus.Temperature
|
||||
}
|
||||
@@ -304,8 +424,6 @@ func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
}
|
||||
case "vac":
|
||||
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
||||
case "meffa":
|
||||
status.Meffa = value
|
||||
case "vdd":
|
||||
status.VDD, _ = strconv.ParseFloat(value, 64)
|
||||
case "id":
|
||||
@@ -370,11 +488,34 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
"BROADCAST": true,
|
||||
}
|
||||
|
||||
if !validModes[mode] {
|
||||
// Normalize mode to title case for comparison
|
||||
modeUpper := strings.ToUpper(mode)
|
||||
if !validModes[modeUpper] {
|
||||
return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode)
|
||||
}
|
||||
|
||||
// Remember last manual mode (if not triggered by auto-fan)
|
||||
// We store it in title case: "Standard", "Contest", "Broadcast"
|
||||
c.lastFanMode = strings.Title(strings.ToLower(mode))
|
||||
|
||||
return c.setFanModeInternal(modeUpper)
|
||||
}
|
||||
|
||||
// setFanModeInternal sets fan mode without updating lastFanMode (for auto-fan)
|
||||
func (c *Client) setFanModeInternal(mode string) error {
|
||||
cmd := fmt.Sprintf("setup fanmode=%s", mode)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetOperate sets the operate mode
|
||||
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
||||
func (c *Client) SetOperate(value int) error {
|
||||
if value != 0 && value != 1 {
|
||||
return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("operate=%d", value)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,235 +6,250 @@ import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connMu sync.Mutex
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Rotator1 RotatorData `json:"rotator1"`
|
||||
Rotator2 RotatorData `json:"rotator2"`
|
||||
Panic bool `json:"panic"`
|
||||
}
|
||||
|
||||
type RotatorData struct {
|
||||
CurrentAzimuth int `json:"current_azimuth"`
|
||||
LimitCW int `json:"limit_cw"`
|
||||
LimitCCW int `json:"limit_ccw"`
|
||||
Configuration string `json:"configuration"` // "A" for azimuth, "E" for elevation
|
||||
Moving int `json:"moving"` // 0=stopped, 1=CW, 2=CCW
|
||||
Offset int `json:"offset"`
|
||||
TargetAzimuth int `json:"target_azimuth"`
|
||||
StartAzimuth int `json:"start_azimuth"`
|
||||
OutsideLimit bool `json:"outside_limit"`
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
Heading int `json:"heading"`
|
||||
TargetHeading int `json:"target_heading"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("RotatorGenius: Attempting to connect to %s:%d\n", c.host, c.port)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
fmt.Printf("RotatorGenius: Connection failed: %v\n", err)
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
fmt.Println("RotatorGenius: Connected successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.stopChan != nil {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
if err := c.Connect(); err != nil {
|
||||
return "", err
|
||||
func (c *Client) Start() error {
|
||||
fmt.Println("RotatorGenius Start() called")
|
||||
|
||||
if c.running {
|
||||
fmt.Println("RotatorGenius already running, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("RotatorGenius attempting initial connection...")
|
||||
_ = c.Connect()
|
||||
|
||||
c.running = true
|
||||
fmt.Println("RotatorGenius launching pollLoop...")
|
||||
go c.pollLoop()
|
||||
|
||||
fmt.Println("RotatorGenius Start() completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
continue
|
||||
}
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
status.Connected = true
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
_, err := c.conn.Write([]byte(cmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Send |h command
|
||||
_, err := c.conn.Write([]byte("|h"))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return nil, fmt.Errorf("failed to send query: %w", err)
|
||||
}
|
||||
|
||||
// Read response - RotatorGenius doesn't send newline, read fixed amount
|
||||
c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
defer c.conn.SetReadDeadline(time.Time{})
|
||||
|
||||
buf := make([]byte, 100)
|
||||
n, err := c.reader.Read(buf)
|
||||
if err != nil || n == 0 {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
response := string(buf[:n])
|
||||
|
||||
return c.parseStatus(response), nil
|
||||
}
|
||||
|
||||
func (c *Client) parseStatus(response string) *Status {
|
||||
status := &Status{}
|
||||
|
||||
// Response format: |h2<null><heading>...
|
||||
// Example: |h2\x00183 8 10A0...
|
||||
// After |h2 there's a null byte, then 3 digits for heading
|
||||
|
||||
if !strings.HasPrefix(response, "|h2") {
|
||||
return status
|
||||
}
|
||||
|
||||
// Skip |h2 (3 chars) and null byte (1 char), then read 3 digits
|
||||
if len(response) >= 7 {
|
||||
// Position 3 is the null byte, position 4-6 are the heading
|
||||
headingStr := response[4:7]
|
||||
heading, err := strconv.Atoi(strings.TrimSpace(headingStr))
|
||||
if err == nil {
|
||||
status.Heading = heading
|
||||
}
|
||||
targetStr := response[19:22]
|
||||
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||
if err == nil {
|
||||
status.TargetHeading = targetHeading
|
||||
}
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
|
||||
// Format command with ID: C<id>|<command>
|
||||
fullCmd := fmt.Sprintf("C%d%s", cmdID, cmd)
|
||||
|
||||
// Send command
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
reader := bufio.NewReader(c.conn)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response), nil
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
resp, err := c.sendCommand("|h")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
return parseStatusResponse(resp)
|
||||
return c.lastStatus, nil
|
||||
}
|
||||
|
||||
func parseStatusResponse(resp string) (*Status, error) {
|
||||
if len(resp) < 80 {
|
||||
return nil, fmt.Errorf("response too short: %d bytes", len(resp))
|
||||
}
|
||||
|
||||
status := &Status{}
|
||||
|
||||
// Parse panic flag
|
||||
status.Panic = resp[3] != 0x00
|
||||
|
||||
// Parse Rotator 1 (positions 4-38)
|
||||
status.Rotator1 = parseRotatorData(resp[4:38])
|
||||
|
||||
// Parse Rotator 2 (positions 38-72)
|
||||
if len(resp) >= 72 {
|
||||
status.Rotator2 = parseRotatorData(resp[38:72])
|
||||
}
|
||||
|
||||
return status, nil
|
||||
// SetHeading rotates to a specific azimuth
|
||||
func (c *Client) SetHeading(azimuth int) error {
|
||||
cmd := fmt.Sprintf("|A1%d", azimuth)
|
||||
return c.sendCommand(cmd)
|
||||
}
|
||||
|
||||
func parseRotatorData(data string) RotatorData {
|
||||
rd := RotatorData{}
|
||||
|
||||
// Current azimuth (3 bytes)
|
||||
if azStr := strings.TrimSpace(data[0:3]); azStr != "999" {
|
||||
rd.CurrentAzimuth, _ = strconv.Atoi(azStr)
|
||||
rd.Connected = true
|
||||
} else {
|
||||
rd.CurrentAzimuth = 999
|
||||
rd.Connected = false
|
||||
}
|
||||
|
||||
// Limits
|
||||
rd.LimitCW, _ = strconv.Atoi(strings.TrimSpace(data[3:6]))
|
||||
rd.LimitCCW, _ = strconv.Atoi(strings.TrimSpace(data[6:9]))
|
||||
|
||||
// Configuration
|
||||
rd.Configuration = string(data[9])
|
||||
|
||||
// Moving state
|
||||
rd.Moving, _ = strconv.Atoi(string(data[10]))
|
||||
|
||||
// Offset
|
||||
rd.Offset, _ = strconv.Atoi(strings.TrimSpace(data[11:15]))
|
||||
|
||||
// Target azimuth
|
||||
if targetStr := strings.TrimSpace(data[15:18]); targetStr != "999" {
|
||||
rd.TargetAzimuth, _ = strconv.Atoi(targetStr)
|
||||
} else {
|
||||
rd.TargetAzimuth = 999
|
||||
}
|
||||
|
||||
// Start azimuth
|
||||
if startStr := strings.TrimSpace(data[18:21]); startStr != "999" {
|
||||
rd.StartAzimuth, _ = strconv.Atoi(startStr)
|
||||
} else {
|
||||
rd.StartAzimuth = 999
|
||||
}
|
||||
|
||||
// Limit flag
|
||||
rd.OutsideLimit = data[21] == '1'
|
||||
|
||||
// Name
|
||||
rd.Name = strings.TrimSpace(data[22:34])
|
||||
|
||||
return rd
|
||||
// RotateCW rotates clockwise
|
||||
func (c *Client) RotateCW() error {
|
||||
return c.sendCommand("|P1")
|
||||
}
|
||||
|
||||
func (c *Client) MoveToAzimuth(rotator int, azimuth int) error {
|
||||
if rotator < 1 || rotator > 2 {
|
||||
return fmt.Errorf("rotator must be 1 or 2")
|
||||
}
|
||||
if azimuth < 0 || azimuth > 360 {
|
||||
return fmt.Errorf("azimuth must be between 0 and 360")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("|A%d%03d", rotator, azimuth)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RotateCW(rotator int) error {
|
||||
if rotator < 1 || rotator > 2 {
|
||||
return fmt.Errorf("rotator must be 1 or 2")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("|P%d", rotator)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RotateCCW(rotator int) error {
|
||||
if rotator < 1 || rotator > 2 {
|
||||
return fmt.Errorf("rotator must be 1 or 2")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("|M%d", rotator)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
// RotateCCW rotates counter-clockwise
|
||||
func (c *Client) RotateCCW() error {
|
||||
return c.sendCommand("|M1")
|
||||
}
|
||||
|
||||
// Stop stops rotation
|
||||
func (c *Client) Stop() error {
|
||||
resp, err := c.sendCommand("|S")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
return c.sendCommand("|S")
|
||||
}
|
||||
|
||||
@@ -3,64 +3,248 @@ package tunergenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
connMu sync.Mutex
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||
Bypass bool `json:"bypass"` // Bypass mode
|
||||
ActiveAntenna int `json:"active_antenna"` // 0=ANT1, 1=ANT2, 2=ANT3
|
||||
TuningStatus string `json:"tuning_status"`
|
||||
FrequencyA float64 `json:"frequency_a"`
|
||||
FrequencyB float64 `json:"frequency_b"`
|
||||
C1 int `json:"c1"`
|
||||
L int `json:"l"`
|
||||
C2 int `json:"c2"`
|
||||
SWR float64 `json:"swr"`
|
||||
Power float64 `json:"power"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Connected bool `json:"connected"`
|
||||
PowerForward float64 `json:"power_forward"`
|
||||
PowerPeak float64 `json:"power_peak"`
|
||||
PowerMax float64 `json:"power_max"`
|
||||
SWR float64 `json:"swr"`
|
||||
PTTA int `json:"ptt_a"`
|
||||
BandA int `json:"band_a"`
|
||||
FreqA float64 `json:"frequency_a"`
|
||||
BypassA bool `json:"bypass_a"`
|
||||
AntA int `json:"antenna_a"`
|
||||
PTTB int `json:"ptt_b"`
|
||||
BandB int `json:"band_b"`
|
||||
FreqB float64 `json:"frequency_b"`
|
||||
BypassB bool `json:"bypass_b"`
|
||||
AntB int `json:"antenna_b"`
|
||||
State int `json:"state"`
|
||||
Active int `json:"active"`
|
||||
Tuning int `json:"tuning"`
|
||||
Bypass bool `json:"bypass"`
|
||||
RelayC1 int `json:"c1"`
|
||||
RelayL int `json:"l"`
|
||||
RelayC2 int `json:"c2"`
|
||||
TuningStatus string `json:"tuning_status"`
|
||||
Connected bool `json:"connected"`
|
||||
|
||||
// Peak hold for display (internal)
|
||||
displayPower float64
|
||||
peakTime time.Time
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil // Already connected
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
|
||||
// Read and discard version banner
|
||||
reader := bufio.NewReader(c.conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.stopChan != nil {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
if err := c.Connect(); err != nil {
|
||||
return "", err
|
||||
// Start begins continuous polling of the device
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to connect, but don't fail if it doesn't work
|
||||
// The poll loop will keep trying
|
||||
_ = c.Connect()
|
||||
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollLoop continuously polls the device for status
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Try to reconnect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
// Silent fail, will retry on next tick
|
||||
continue
|
||||
}
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
// Connection lost, close and retry next tick
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Check if device is actually alive
|
||||
// If all frequencies are 0, device is probably off
|
||||
if status.FreqA == 0 && status.FreqB == 0 && status.PowerForward == 0 {
|
||||
status.Connected = false
|
||||
}
|
||||
|
||||
// Peak hold logic - keep highest power for 1 second
|
||||
now := time.Now()
|
||||
if c.lastStatus != nil {
|
||||
// If new power is higher, update peak
|
||||
if status.PowerForward > c.lastStatus.displayPower {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
} else {
|
||||
// Check if peak has expired (1 second)
|
||||
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||
// Keep old peak
|
||||
status.displayPower = c.lastStatus.displayPower
|
||||
status.peakTime = c.lastStatus.peakTime
|
||||
} else {
|
||||
// Peak expired, use current value
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
|
||||
// Override PowerForward with display power for frontend
|
||||
status.PowerForward = status.displayPower
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
|
||||
// Format command with ID: C<id>|status get
|
||||
fullCmd := fmt.Sprintf("C%d|status get\n", cmdID)
|
||||
|
||||
// Send command
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return nil, fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
reader := bufio.NewReader(c.conn)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return c.parseStatus(strings.TrimSpace(response))
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
@@ -87,119 +271,156 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
resp, err := c.sendCommand("status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
// Parse the response - format will depend on actual device response
|
||||
// This is a placeholder that should be updated based on real response format
|
||||
return c.lastStatus, nil
|
||||
}
|
||||
|
||||
func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
status := &Status{
|
||||
Connected: true,
|
||||
}
|
||||
|
||||
// TODO: Parse actual status response from device
|
||||
// The response format needs to be determined from real device testing
|
||||
// For now, we just check if we got a response
|
||||
_ = resp // Temporary: will be used when we parse the actual response format
|
||||
// Response format: S<id>|status fwd=21.19 peak=21.55 ...
|
||||
// Extract the data part after "S<id>|status "
|
||||
idx := strings.Index(resp, "|status ")
|
||||
if idx == -1 {
|
||||
return nil, fmt.Errorf("invalid response format: %s", resp)
|
||||
}
|
||||
|
||||
data := resp[idx+8:] // Skip "|status "
|
||||
|
||||
// Parse key=value pairs separated by spaces
|
||||
pairs := strings.Fields(data)
|
||||
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
|
||||
switch key {
|
||||
case "fwd":
|
||||
// fwd is in dBm (e.g., 42.62 dBm)
|
||||
// Formula: watts = 10^(dBm/10) / 1000
|
||||
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
milliwatts := math.Pow(10, dBm/10.0)
|
||||
status.PowerForward = milliwatts / 1000.0
|
||||
}
|
||||
case "peak":
|
||||
// peak power in dBm
|
||||
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
milliwatts := math.Pow(10, dBm/10.0)
|
||||
status.PowerPeak = milliwatts / 1000.0
|
||||
}
|
||||
case "max":
|
||||
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
milliwatts := math.Pow(10, dBm/10.0)
|
||||
status.PowerMax = milliwatts / 1000.0
|
||||
}
|
||||
case "swr":
|
||||
// SWR from return loss
|
||||
// Formula: returnLoss = abs(swr) / 20
|
||||
// swr = (10^returnLoss + 1) / (10^returnLoss - 1)
|
||||
if swrRaw, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
returnLoss := math.Abs(swrRaw) / 20.0
|
||||
tenPowRL := math.Pow(10, returnLoss)
|
||||
calculatedSWR := (tenPowRL + 1) / (tenPowRL - 1)
|
||||
status.SWR = calculatedSWR
|
||||
}
|
||||
case "pttA":
|
||||
status.PTTA, _ = strconv.Atoi(value)
|
||||
case "bandA":
|
||||
status.BandA, _ = strconv.Atoi(value)
|
||||
case "freqA":
|
||||
status.FreqA, _ = strconv.ParseFloat(value, 64)
|
||||
case "bypassA":
|
||||
status.BypassA = value == "1"
|
||||
case "antA":
|
||||
status.AntA, _ = strconv.Atoi(value)
|
||||
case "pttB":
|
||||
status.PTTB, _ = strconv.Atoi(value)
|
||||
case "bandB":
|
||||
status.BandB, _ = strconv.Atoi(value)
|
||||
case "freqB":
|
||||
status.FreqB, _ = strconv.ParseFloat(value, 64)
|
||||
case "bypassB":
|
||||
status.BypassB = value == "1"
|
||||
case "antB":
|
||||
status.AntB, _ = strconv.Atoi(value)
|
||||
case "state":
|
||||
status.State, _ = strconv.Atoi(value)
|
||||
case "active":
|
||||
status.Active, _ = strconv.Atoi(value)
|
||||
case "tuning":
|
||||
status.Tuning, _ = strconv.Atoi(value)
|
||||
if status.Tuning == 1 {
|
||||
status.TuningStatus = "TUNING"
|
||||
} else {
|
||||
status.TuningStatus = "READY"
|
||||
}
|
||||
case "bypass":
|
||||
status.Bypass = value == "1"
|
||||
case "relayC1":
|
||||
status.RelayC1, _ = strconv.Atoi(value)
|
||||
case "relayL":
|
||||
status.RelayL, _ = strconv.Atoi(value)
|
||||
case "relayC2":
|
||||
status.RelayC2, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetOperate(operate bool) error {
|
||||
var state int
|
||||
if operate {
|
||||
state = 1
|
||||
// SetOperate switches between STANDBY (0) and OPERATE (1)
|
||||
func (c *Client) SetOperate(value int) error {
|
||||
if value != 0 && value != 1 {
|
||||
return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("operate set=%d", state)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
cmd := fmt.Sprintf("operate set=%d", value)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SetBypass(bypass bool) error {
|
||||
var state int
|
||||
if bypass {
|
||||
state = 1
|
||||
// SetBypass sets BYPASS mode
|
||||
func (c *Client) SetBypass(value int) error {
|
||||
if value != 0 && value != 1 {
|
||||
return fmt.Errorf("invalid bypass value: %d (must be 0 or 1)", value)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("bypass set=%d", state)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ActivateAntenna(antenna int) error {
|
||||
if antenna < 0 || antenna > 2 {
|
||||
return fmt.Errorf("antenna must be 0 (ANT1), 1 (ANT2), or 2 (ANT3)")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("activate ant=%d", antenna)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
cmd := fmt.Sprintf("bypass set=%d", value)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// AutoTune starts a tuning cycle
|
||||
func (c *Client) AutoTune() error {
|
||||
resp, err := c.sendCommand("autotune")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
_, err := c.sendCommand("autotune")
|
||||
return err
|
||||
}
|
||||
|
||||
// TuneRelay adjusts tuning parameters manually
|
||||
// TuneRelay adjusts one tuning parameter by one step
|
||||
// relay: 0=C1, 1=L, 2=C2
|
||||
// move: -1 to decrease, 1 to increase
|
||||
func (c *Client) TuneRelay(relay int, move int) error {
|
||||
// move: -1 (decrease) or 1 (increase)
|
||||
func (c *Client) TuneRelay(relay, move int) error {
|
||||
if relay < 0 || relay > 2 {
|
||||
return fmt.Errorf("relay must be 0 (C1), 1 (L), or 2 (C2)")
|
||||
return fmt.Errorf("invalid relay: %d (must be 0, 1, or 2)", relay)
|
||||
}
|
||||
if move != -1 && move != 1 {
|
||||
return fmt.Errorf("move must be -1 or 1")
|
||||
return fmt.Errorf("invalid move: %d (must be -1 or 1)", move)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package ultrabeam
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Protocol constants
|
||||
const (
|
||||
STX byte = 0xF5 // 245 decimal
|
||||
ETX byte = 0xFA // 250 decimal
|
||||
DLE byte = 0xF6 // 246 decimal
|
||||
)
|
||||
|
||||
// Command codes
|
||||
const (
|
||||
CMD_STATUS byte = 1 // General status query
|
||||
CMD_RETRACT byte = 2 // Retract elements
|
||||
CMD_FREQ byte = 3 // Change frequency
|
||||
CMD_READ_BANDS byte = 9 // Read current band adjustments
|
||||
CMD_PROGRESS byte = 10 // Read progress bar
|
||||
CMD_MODIFY_ELEM byte = 12 // Modify element length
|
||||
)
|
||||
|
||||
// Reply codes
|
||||
const (
|
||||
UB_OK byte = 0 // Normal execution
|
||||
UB_BAD byte = 1 // Invalid command
|
||||
UB_PAR byte = 2 // Bad parameters
|
||||
UB_ERR byte = 3 // Error executing command
|
||||
)
|
||||
|
||||
// Direction modes
|
||||
const (
|
||||
DIR_NORMAL byte = 0
|
||||
DIR_180 byte = 1
|
||||
DIR_BIDIR byte = 2
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
connMu sync.Mutex
|
||||
reader *bufio.Reader
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
seqNum byte
|
||||
seqMu sync.Mutex
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
FirmwareMinor int `json:"firmware_minor"`
|
||||
FirmwareMajor int `json:"firmware_major"`
|
||||
CurrentOperation int `json:"current_operation"`
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Band int `json:"band"`
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
OffState bool `json:"off_state"`
|
||||
MotorsMoving int `json:"motors_moving"` // Bitmask
|
||||
FreqMin int `json:"freq_min"` // MHz
|
||||
FreqMax int `json:"freq_max"` // MHz
|
||||
ElementLengths []int `json:"element_lengths"` // mm
|
||||
ProgressTotal int `json:"progress_total"` // mm
|
||||
ProgressCurrent int `json:"progress_current"` // 0-60
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
seqNum: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
c.running = false
|
||||
close(c.stopChan)
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
||||
defer ticker.Stop()
|
||||
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
|
||||
// Try to connect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("Ultrabeam: Connection failed: %v", err)
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Query status
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
||||
// Close connection and retry
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Query progress if motors moving
|
||||
if status.MotorsMoving != 0 {
|
||||
progress, err := c.queryProgress()
|
||||
if err == nil {
|
||||
status.ProgressTotal = progress[0]
|
||||
status.ProgressCurrent = progress[1]
|
||||
}
|
||||
} else {
|
||||
// Motors stopped - reset progress
|
||||
status.ProgressTotal = 0
|
||||
status.ProgressCurrent = 0
|
||||
}
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
return c.lastStatus, nil
|
||||
}
|
||||
|
||||
// getNextSeq returns the next sequence number
|
||||
func (c *Client) getNextSeq() byte {
|
||||
c.seqMu.Lock()
|
||||
defer c.seqMu.Unlock()
|
||||
|
||||
seq := c.seqNum
|
||||
c.seqNum = (c.seqNum + 1) % 128
|
||||
return seq
|
||||
}
|
||||
|
||||
// calculateChecksum calculates the checksum for a packet
|
||||
func calculateChecksum(data []byte) byte {
|
||||
chk := byte(0x55)
|
||||
for _, b := range data {
|
||||
chk ^= b
|
||||
chk++
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
// quoteByte handles DLE escaping
|
||||
func quoteByte(b byte) []byte {
|
||||
if b == STX || b == ETX || b == DLE {
|
||||
return []byte{DLE, b & 0x7F} // Clear MSB
|
||||
}
|
||||
return []byte{b}
|
||||
}
|
||||
|
||||
// buildPacket creates a complete packet with checksum and escaping
|
||||
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
||||
seq := c.getNextSeq()
|
||||
|
||||
// Calculate checksum on unquoted data
|
||||
payload := append([]byte{seq, cmd}, data...)
|
||||
chk := calculateChecksum(payload)
|
||||
|
||||
// Build packet with quoting
|
||||
packet := []byte{STX}
|
||||
|
||||
// Add quoted SEQ
|
||||
packet = append(packet, quoteByte(seq)...)
|
||||
|
||||
// Add quoted CMD
|
||||
packet = append(packet, quoteByte(cmd)...)
|
||||
|
||||
// Add quoted data
|
||||
for _, b := range data {
|
||||
packet = append(packet, quoteByte(b)...)
|
||||
}
|
||||
|
||||
// Add quoted checksum
|
||||
packet = append(packet, quoteByte(chk)...)
|
||||
|
||||
// Add ETX
|
||||
packet = append(packet, ETX)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
// parsePacket parses a received packet, handling DLE unescaping
|
||||
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
|
||||
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
|
||||
return 0, 0, nil, fmt.Errorf("packet too short")
|
||||
}
|
||||
|
||||
if data[0] != STX {
|
||||
return 0, 0, nil, fmt.Errorf("missing STX")
|
||||
}
|
||||
|
||||
if data[len(data)-1] != ETX {
|
||||
return 0, 0, nil, fmt.Errorf("missing ETX")
|
||||
}
|
||||
|
||||
// Unquote the data
|
||||
var unquoted []byte
|
||||
dle := false
|
||||
for i := 1; i < len(data)-1; i++ {
|
||||
b := data[i]
|
||||
if b == DLE {
|
||||
dle = true
|
||||
continue
|
||||
}
|
||||
if dle {
|
||||
b |= 0x80 // Set MSB
|
||||
dle = false
|
||||
}
|
||||
unquoted = append(unquoted, b)
|
||||
}
|
||||
|
||||
if len(unquoted) < 3 {
|
||||
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
|
||||
}
|
||||
|
||||
seq = unquoted[0]
|
||||
cmd = unquoted[1]
|
||||
chk := unquoted[len(unquoted)-1]
|
||||
payload = unquoted[2 : len(unquoted)-1]
|
||||
|
||||
// Verify checksum
|
||||
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
|
||||
if calcChk != chk {
|
||||
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
|
||||
}
|
||||
|
||||
return seq, cmd, payload, nil
|
||||
}
|
||||
|
||||
// sendCommand sends a command and waits for reply
|
||||
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Build and send packet
|
||||
packet := c.buildPacket(cmd, data)
|
||||
|
||||
_, err := c.conn.Write(packet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write: %w", err)
|
||||
}
|
||||
|
||||
// Read reply with timeout
|
||||
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
|
||||
|
||||
// Read until we get a complete packet
|
||||
var buffer []byte
|
||||
for {
|
||||
b, err := c.reader.ReadByte()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read: %w", err)
|
||||
}
|
||||
|
||||
buffer = append(buffer, b)
|
||||
|
||||
// Check if we have a complete packet
|
||||
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
||||
break
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if len(buffer) > 256 {
|
||||
return nil, fmt.Errorf("packet too long")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse reply
|
||||
_, replyCmd, payload, err := parsePacket(buffer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
||||
}
|
||||
|
||||
// Log for debugging unknown codes
|
||||
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
|
||||
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
switch replyCmd {
|
||||
case UB_BAD:
|
||||
return nil, fmt.Errorf("invalid command")
|
||||
case UB_PAR:
|
||||
return nil, fmt.Errorf("bad parameters")
|
||||
case UB_ERR:
|
||||
return nil, fmt.Errorf("execution error")
|
||||
case UB_OK:
|
||||
return payload, nil
|
||||
default:
|
||||
// Unknown codes might indicate "busy" or "in progress"
|
||||
// Treat as non-fatal, return empty payload
|
||||
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
|
||||
return []byte{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// queryStatus queries general status (command 1)
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(reply) < 12 {
|
||||
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
|
||||
}
|
||||
|
||||
status := &Status{
|
||||
FirmwareMinor: int(reply[0]),
|
||||
FirmwareMajor: int(reply[1]),
|
||||
CurrentOperation: int(reply[2]),
|
||||
Frequency: int(reply[3]) | (int(reply[4]) << 8),
|
||||
Band: int(reply[5]),
|
||||
Direction: int(reply[6] & 0x0F),
|
||||
OffState: (reply[7] & 0x02) != 0,
|
||||
MotorsMoving: int(reply[9]),
|
||||
FreqMin: int(reply[10]),
|
||||
FreqMax: int(reply[11]),
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// queryProgress queries motor progress (command 10)
|
||||
func (c *Client) queryProgress() ([]int, error) {
|
||||
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(reply) < 4 {
|
||||
return nil, fmt.Errorf("progress reply too short")
|
||||
}
|
||||
|
||||
total := int(reply[0]) | (int(reply[1]) << 8)
|
||||
current := int(reply[2]) | (int(reply[3]) << 8)
|
||||
|
||||
return []int{total, current}, nil
|
||||
}
|
||||
|
||||
// SetFrequency changes frequency and optional direction (command 3)
|
||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
data := []byte{
|
||||
byte(freqKhz & 0xFF),
|
||||
byte((freqKhz >> 8) & 0xFF),
|
||||
byte(direction),
|
||||
}
|
||||
|
||||
_, err := c.sendCommand(CMD_FREQ, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retract retracts all elements (command 2)
|
||||
func (c *Client) Retract() error {
|
||||
_, err := c.sendCommand(CMD_RETRACT, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ModifyElement modifies element length (command 12)
|
||||
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||
if elementNum < 0 || elementNum > 5 {
|
||||
return fmt.Errorf("invalid element number: %d", elementNum)
|
||||
}
|
||||
|
||||
data := []byte{
|
||||
byte(elementNum),
|
||||
0, // Reserved
|
||||
byte(lengthMm & 0xFF),
|
||||
byte((lengthMm >> 8) & 0xFF),
|
||||
}
|
||||
|
||||
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
||||
return err
|
||||
}
|
||||
@@ -15,7 +15,8 @@ type Client struct {
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Relays []RelayState `json:"relays"`
|
||||
Relays []RelayState `json:"relays"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
type RelayState struct {
|
||||
@@ -67,20 +68,56 @@ func (c *Client) TurnOff(relay int) error {
|
||||
}
|
||||
|
||||
func (c *Client) AllOn() error {
|
||||
for i := 1; i <= 5; i++ {
|
||||
if err := c.TurnOn(i); err != nil {
|
||||
return fmt.Errorf("failed to turn on relay %d: %w", i, err)
|
||||
// Sequence for ALL ON:
|
||||
// 1. Turn on relays 1, 2, 3, 5 immediately
|
||||
// 2. Wait 5 seconds
|
||||
// 3. Turn on relay 4 (Flex Radio Start)
|
||||
|
||||
// Turn on relays 1, 2, 3, 5
|
||||
for _, relay := range []int{1, 2, 3, 5} {
|
||||
if err := c.TurnOn(relay); err != nil {
|
||||
return fmt.Errorf("failed to turn on relay %d: %w", relay, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 5 seconds for power supply to stabilize
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Turn on relay 4 (Flex Radio)
|
||||
if err := c.TurnOn(4); err != nil {
|
||||
return fmt.Errorf("failed to turn on relay 4: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) AllOff() error {
|
||||
for i := 1; i <= 5; i++ {
|
||||
if err := c.TurnOff(i); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay %d: %w", i, err)
|
||||
// Sequence for ALL OFF:
|
||||
// 1. Turn off relay 4 (Flex Radio) immediately
|
||||
// 2. Turn off relays 2, 3, 5 immediately
|
||||
// 3. Wait 35 seconds for Flex Radio to shut down
|
||||
// 4. Turn off relay 1 (Power Supply)
|
||||
|
||||
// Turn off relay 4 (Flex Radio)
|
||||
if err := c.TurnOff(4); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay 4: %w", err)
|
||||
}
|
||||
|
||||
// Turn off relays 2, 3, 5
|
||||
for _, relay := range []int{2, 3, 5} {
|
||||
if err := c.TurnOff(relay); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay %d: %w", relay, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 35 seconds for Flex Radio to shut down properly
|
||||
time.Sleep(35 * time.Second)
|
||||
|
||||
// Turn off relay 1 (Power Supply)
|
||||
if err := c.TurnOff(1); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay 1: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -105,7 +142,8 @@ func (c *Client) GetStatus() (*Status, error) {
|
||||
|
||||
// Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
|
||||
status := &Status{
|
||||
Relays: make([]RelayState, 0, 5),
|
||||
Relays: make([]RelayState, 0, 5),
|
||||
Connected: true,
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
|
||||
@@ -101,13 +101,14 @@ func (c *Client) GetWeatherData() (*WeatherData, error) {
|
||||
}
|
||||
|
||||
// Convert to our structure
|
||||
// OWM retourne wind_speed et gust en m/s — conversion en km/h
|
||||
weatherData := &WeatherData{
|
||||
Temperature: owmData.Main.Temp,
|
||||
FeelsLike: owmData.Main.FeelsLike,
|
||||
Humidity: owmData.Main.Humidity,
|
||||
Pressure: owmData.Main.Pressure,
|
||||
WindSpeed: owmData.Wind.Speed,
|
||||
WindGust: owmData.Wind.Gust,
|
||||
WindSpeed: owmData.Wind.Speed * 3.6,
|
||||
WindGust: owmData.Wind.Gust * 3.6,
|
||||
WindDeg: owmData.Wind.Deg,
|
||||
Clouds: owmData.Clouds.All,
|
||||
UpdatedAt: time.Unix(owmData.Dt, 0).Format(time.RFC3339),
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ShackMaster - XV9Q Shack</title>
|
||||
<title>ShackMaster - F4BPO Shack</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
+93
-13
@@ -2,16 +2,20 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { wsService, connected, systemStatus } from './lib/websocket.js';
|
||||
import { api } from './lib/api.js';
|
||||
import StatusBanner from './components/StatusBanner.svelte';
|
||||
import WebSwitch from './components/WebSwitch.svelte';
|
||||
import PowerGenius from './components/PowerGenius.svelte';
|
||||
import TunerGenius from './components/TunerGenius.svelte';
|
||||
import AntennaGenius from './components/AntennaGenius.svelte';
|
||||
import RotatorGenius from './components/RotatorGenius.svelte';
|
||||
import Ultrabeam from './components/Ultrabeam.svelte';
|
||||
|
||||
let status = null;
|
||||
let isConnected = false;
|
||||
let currentTime = new Date();
|
||||
let callsign = 'F4BPO'; // Default
|
||||
let latitude = null;
|
||||
let longitude = null;
|
||||
|
||||
const unsubscribeStatus = systemStatus.subscribe(value => {
|
||||
status = value;
|
||||
@@ -39,6 +43,10 @@
|
||||
if (config.callsign) {
|
||||
callsign = config.callsign;
|
||||
}
|
||||
if (config.location) {
|
||||
latitude = config.location.latitude;
|
||||
longitude = config.location.longitude;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
}
|
||||
@@ -63,6 +71,14 @@
|
||||
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
|
||||
$: weatherData = status?.weather || {
|
||||
wind_speed: 0,
|
||||
@@ -75,11 +91,14 @@
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1>{callsign} Shack</h1>
|
||||
<h1>{callsign}'s Shack</h1>
|
||||
<div class="connection-status">
|
||||
<span class="status-indicator" class:status-online={isConnected} class:status-offline={!isConnected}></span>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
<button class="shutdown-btn" on:click={shutdown} title="Fermer ShackMaster">
|
||||
⏻ Shutdown
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
@@ -94,10 +113,10 @@
|
||||
|
||||
<div class="header-right">
|
||||
<div class="weather-info">
|
||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)}m/s</span>
|
||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)}m/s</span>
|
||||
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)}°C</span>
|
||||
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)}°C</span>
|
||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)} km/h</span>
|
||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)} km/h</span>
|
||||
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
|
||||
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)} °C</span>
|
||||
</div>
|
||||
<div class="clock">
|
||||
<span class="time">{formatTime(currentTime)}</span>
|
||||
@@ -106,17 +125,28 @@
|
||||
</div>
|
||||
</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>
|
||||
<div class="dashboard-grid">
|
||||
<div class="row">
|
||||
<WebSwitch status={status?.webswitch} />
|
||||
<PowerGenius status={status?.power_genius} />
|
||||
<TunerGenius status={status?.tuner_genius} />
|
||||
<TunerGenius status={status?.tuner_genius} flexradio={status?.flexradio} />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<AntennaGenius status={status?.antenna_genius} />
|
||||
<RotatorGenius status={status?.rotator_genius} />
|
||||
<Ultrabeam status={status?.ultrabeam} flexradio={status?.flexradio} />
|
||||
<RotatorGenius status={status?.rotator_genius} ultrabeam={status?.ultrabeam} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -130,12 +160,13 @@
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -163,6 +194,25 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -176,13 +226,41 @@
|
||||
}
|
||||
|
||||
.solar-item {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.solar-item .value {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.solar-item:nth-child(1) .value { /* SFI */
|
||||
color: #ffa726;
|
||||
text-shadow: 0 0 8px rgba(255, 167, 38, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(2) .value { /* Spots */
|
||||
color: #66bb6a;
|
||||
text-shadow: 0 0 8px rgba(102, 187, 106, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(3) .value { /* A */
|
||||
color: #42a5f5;
|
||||
text-shadow: 0 0 8px rgba(66, 165, 245, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(4) .value { /* K */
|
||||
color: #ef5350;
|
||||
text-shadow: 0 0 8px rgba(239, 83, 80, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(5) .value { /* G */
|
||||
color: #ab47bc;
|
||||
text-shadow: 0 0 8px rgba(171, 71, 188, 0.5);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -213,6 +291,7 @@
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -233,6 +312,7 @@
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.row > :global(*) {
|
||||
|
||||
@@ -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>
|
||||
+393
-83
@@ -1,128 +1,438 @@
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2a2a2a;
|
||||
--bg-card: #333333;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--accent-teal: #00bcd4;
|
||||
--accent-green: #4caf50;
|
||||
--accent-red: #f44336;
|
||||
/* Modern dark theme inspired by FlexDXCluster */
|
||||
--bg-primary: #0a1628;
|
||||
--bg-secondary: #1a2332;
|
||||
--bg-tertiary: #243447;
|
||||
--bg-hover: #2a3f5f;
|
||||
|
||||
--text-primary: #e0e6ed;
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #718096;
|
||||
|
||||
--accent-cyan: #4fc3f7;
|
||||
--accent-blue: #2196f3;
|
||||
--border-color: #444444;
|
||||
--accent-green: #4caf50;
|
||||
--accent-orange: #ff9800;
|
||||
--accent-red: #f44336;
|
||||
--accent-purple: #9c27b0;
|
||||
--accent-yellow: #ffc107;
|
||||
|
||||
--border-color: #2d3748;
|
||||
--border-light: #374151;
|
||||
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--card-radius: 6px;
|
||||
|
||||
--header-height: 56px;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
/* ==================== HEADER ==================== */
|
||||
header {
|
||||
height: var(--header-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
.header-left h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background: var(--accent-red);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background-color: var(--accent-green);
|
||||
.status-indicator.status-online {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background-color: var(--text-secondary);
|
||||
.header-center {
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.2s ease;
|
||||
.solar-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
.solar-item {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #45a049;
|
||||
.solar-item .value {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.value-display {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.label {
|
||||
.weather-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.clock .time {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clock .date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ==================== MAIN CONTENT ==================== */
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ==================== CARDS ==================== */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--card-radius);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card h2::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ==================== LABELS & VALUES ==================== */
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ==================== BUTTONS ==================== */
|
||||
button, .button {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
button:hover, .button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
button:active, .button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #29b6f6;
|
||||
border-color: #29b6f6;
|
||||
}
|
||||
|
||||
button.success {
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==================== SELECT ==================== */
|
||||
select {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ==================== BADGES ==================== */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge.green {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.badge.red {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.badge.orange {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.badge.cyan {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.badge.purple {
|
||||
background: rgba(156, 39, 176, 0.2);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
/* ==================== PROGRESS BARS ==================== */
|
||||
.bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-orange), var(--accent-red));
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* ==================== METRICS ==================== */
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.metric.small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ==================== SCROLLBAR ==================== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ==================== RESPONSIVE ==================== */
|
||||
@media (max-width: 1400px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: var(--spacing-sm);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,344 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
|
||||
export let status;
|
||||
|
||||
$: radio1Antenna = status?.radio1_antenna || 0;
|
||||
$: radio2Antenna = status?.radio2_antenna || 0;
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
async function setRadioAntenna(radio, antenna) {
|
||||
$: portA = status?.port_a || {};
|
||||
$: portB = status?.port_b || {};
|
||||
$: antennas = status?.antennas || [];
|
||||
|
||||
// Band names
|
||||
const bandNames = {
|
||||
0: '160M', 1: '80M', 2: '60M', 3: '40M', 4: '30M',
|
||||
5: '20M', 6: '17M', 7: '15M', 8: '12M', 9: '10M',
|
||||
10: '6M', 11: '4M', 12: '2M', 13: '1.25M', 14: '70CM', 15: 'GEN'
|
||||
};
|
||||
|
||||
$: bandAName = bandNames[portA.band] || 'None';
|
||||
$: bandBName = bandNames[portB.band] || 'None';
|
||||
|
||||
async function selectAntenna(port, antennaNum) {
|
||||
try {
|
||||
await api.antenna.set(radio, antenna);
|
||||
// Check if antenna is already selected on this port
|
||||
const isAlreadySelected = (port === 1 && portA.rx_ant === antennaNum) ||
|
||||
(port === 2 && portB.rx_ant === antennaNum);
|
||||
|
||||
if (isAlreadySelected) {
|
||||
// Deselect: set rxant to 00
|
||||
console.log(`Deselecting antenna ${antennaNum} from port ${port}`);
|
||||
await api.antenna.deselectAntenna(port, antennaNum);
|
||||
} else {
|
||||
// Select normally
|
||||
console.log(`Selecting antenna ${antennaNum} on port ${port}`);
|
||||
await api.antenna.selectAntenna(port, antennaNum);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to set antenna:', err);
|
||||
console.error('Failed to select/deselect antenna:', err);
|
||||
// No popup, just log the error
|
||||
}
|
||||
}
|
||||
|
||||
// Debug TX state - only log when tx state changes, not on every update
|
||||
let lastTxStateA = false;
|
||||
let lastTxStateB = false;
|
||||
$: if (status && (portA.tx !== lastTxStateA || portB.tx !== lastTxStateB)) {
|
||||
console.log('AntennaGenius TX state changed:', {
|
||||
portA_tx: portA.tx,
|
||||
portB_tx: portB.tx,
|
||||
portA_tx_ant: portA.tx_ant,
|
||||
portB_tx_ant: portB.tx_ant
|
||||
});
|
||||
lastTxStateA = portA.tx;
|
||||
lastTxStateB = portB.tx;
|
||||
}
|
||||
|
||||
async function reboot() {
|
||||
if (!confirm('Are you sure you want to reboot the Antenna Genius?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.antenna.reboot();
|
||||
console.log('Antenna Genius reboot command sent');
|
||||
} catch (err) {
|
||||
console.error('Failed to reboot:', err);
|
||||
// No popup, just log
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="antenna-card card">
|
||||
<h2>
|
||||
AG 8X2
|
||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
||||
</h2>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Antenna Genius</h2>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
|
||||
<div class="radio-section">
|
||||
<div class="radio-label">Radio 1 / Radio 2</div>
|
||||
|
||||
<div class="radio-grid">
|
||||
<div class="radio-column">
|
||||
<div class="radio-title">Radio 1</div>
|
||||
<div class="antenna-slots">
|
||||
{#each Array(4) as _, i}
|
||||
<button
|
||||
class="slot"
|
||||
class:active={radio1Antenna === i}
|
||||
on:click={() => setRadioAntenna(1, i)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<!-- Radio Sources -->
|
||||
<div class="sources">
|
||||
<div class="source-item">
|
||||
<div class="source-label">{portA.source || 'FLEX'}</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-column">
|
||||
<div class="radio-title">Radio 2</div>
|
||||
<div class="antenna-slots">
|
||||
{#each Array(4) as _, i}
|
||||
<button
|
||||
class="slot"
|
||||
class:active={radio2Antenna === i}
|
||||
on:click={() => setRadioAntenna(2, i)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="source-item">
|
||||
<div class="source-label">{portB.source || 'FLEX'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bands -->
|
||||
<div class="bands">
|
||||
<div class="band-item">
|
||||
<div class="band-value">{bandAName}</div>
|
||||
</div>
|
||||
<div class="band-item">
|
||||
<div class="band-value">{bandBName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antennas -->
|
||||
<div class="antennas">
|
||||
{#each antennas as antenna}
|
||||
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
||||
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
||||
{@const isPortARx = !portA.tx && portA.rx_ant === antenna.number}
|
||||
{@const isPortBRx = !portB.tx && portB.rx_ant === antenna.number}
|
||||
{@const isTx = isPortATx || isPortBTx}
|
||||
{@const isActiveA = isPortARx || isPortATx}
|
||||
{@const isActiveB = isPortBRx || isPortBTx}
|
||||
|
||||
<div
|
||||
class="antenna-card"
|
||||
class:tx={isTx}
|
||||
class:active-a={isActiveA}
|
||||
class:active-b={isActiveB}
|
||||
>
|
||||
<div class="antenna-name">{antenna.name}</div>
|
||||
<div class="antenna-ports">
|
||||
<button
|
||||
class="port-btn"
|
||||
class:active={isActiveA}
|
||||
on:click={() => selectAntenna(1, antenna.number)}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<button
|
||||
class="port-btn"
|
||||
class:active={isActiveB}
|
||||
on:click={() => selectAntenna(2, antenna.number)}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Reboot Button -->
|
||||
<button class="reboot-btn" on:click={reboot}>
|
||||
<span class="reboot-icon">🔄</span>
|
||||
REBOOT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.antenna-card {
|
||||
min-width: 300px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.radio-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.radio-grid {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.radio-column {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.radio-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.antenna-slots {
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Sources */
|
||||
.sources {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
.source-item {
|
||||
padding: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.source-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Bands */
|
||||
.bands {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.band-item {
|
||||
padding: 10px;
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.band-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Antennas */
|
||||
.antennas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.antenna-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.antenna-card.active-a {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 20px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.antenna-card.active-b {
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
/* TX must come AFTER active-a/active-b to override */
|
||||
.antenna-card.tx {
|
||||
background: rgba(244, 67, 54, 0.2) !important;
|
||||
border-color: #f44336 !important;
|
||||
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4) !important;
|
||||
}
|
||||
|
||||
.antenna-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.slot:hover {
|
||||
border-color: var(--accent-blue);
|
||||
.antenna-ports {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slot.active {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
.port-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.port-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.port-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
/* Reboot Button */
|
||||
.reboot-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #ff9800, #f57c00);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.reboot-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(255, 152, 0, 0.5);
|
||||
}
|
||||
|
||||
.reboot-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.reboot-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,209 +1,451 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
export let status;
|
||||
|
||||
$: powerForward = status?.power_forward || 0;
|
||||
$: powerReflected = status?.power_reflected || 0;
|
||||
$: swr = status?.swr || 1.0;
|
||||
$: voltage = status?.voltage || 0;
|
||||
$: vdd = status?.vdd || 0;
|
||||
$: current = status?.current || 0;
|
||||
$: peakCurrent = status?.peak_current || 0;
|
||||
$: temperature = status?.temperature || 0;
|
||||
$: harmonicLoadTemp = status?.harmonic_load_temp || 0;
|
||||
$: fanMode = status?.fan_mode || 'CONTEST';
|
||||
$: state = status?.state || 'IDLE';
|
||||
$: bandA = status?.band_a || '0';
|
||||
$: bandB = status?.band_b || '0';
|
||||
$: connected = status?.connected || false;
|
||||
$: displayState = state.replace('TRANSMIT_A', 'TRANSMIT').replace('TRANSMIT_B', 'TRANSMIT');
|
||||
$: meffa = status?.meffa || 'STANDBY';
|
||||
$: powerForward = status?.power_forward || 0;
|
||||
$: powerReflected = status?.power_reflected || 0;
|
||||
$: swr = status?.swr || 1.0;
|
||||
|
||||
$: voltage = status?.voltage || 0;
|
||||
$: vdd = status?.vdd || 0;
|
||||
$: current = status?.current || 0;
|
||||
$: peakCurrent = status?.peak_current || 0;
|
||||
$: temperature = status?.temperature || 0;
|
||||
$: harmonicLoadTemp = status?.harmonic_load_temp || 0;
|
||||
$: fanMode = status?.fan_mode || 'CONTEST';
|
||||
$: state = status?.state || 'IDLE';
|
||||
$: bandA = status?.band_a || '0';
|
||||
$: bandB = status?.band_b || '0';
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
$: displayState = state.replace('TRANSMIT_A', 'TRANSMIT').replace('TRANSMIT_B', 'TRANSMIT');
|
||||
|
||||
// Color functions
|
||||
$: tempColor = temperature < 40 ? '#4caf50' : temperature < 60 ? '#ffc107' : temperature < 75 ? '#ff9800' : '#f44336';
|
||||
$: swrColor = swr < 1.5 ? '#4caf50' : swr < 2.0 ? '#ffc107' : swr < 3.0 ? '#ff9800' : '#f44336';
|
||||
$: powerPercent = Math.min((powerForward / 2000) * 100, 100);
|
||||
|
||||
async function setFanMode(mode) {
|
||||
try {
|
||||
await api.power.setFanMode(mode);
|
||||
} catch (err) {
|
||||
console.error('Failed to set fan mode:', err);
|
||||
alert('Failed to set fan mode');
|
||||
async function setFanMode(mode) {
|
||||
try {
|
||||
await api.power.setFanMode(mode);
|
||||
} catch (err) {
|
||||
console.error('Failed to set fan mode:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOperate() {
|
||||
try {
|
||||
const operateValue = state === 'IDLE' ? 0 : 1;
|
||||
await api.power.setOperate(operateValue);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle operate:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="powergenius-card card">
|
||||
<h2>
|
||||
PGXL
|
||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
||||
</h2>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="status-label" class:normal={state === 'IDLE'} class:warning={state.includes('TRANSMIT')}>
|
||||
{displayState}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Power Genius XL</h2>
|
||||
<div class="header-right">
|
||||
<button
|
||||
class="state-badge"
|
||||
class:idle={state === 'IDLE'}
|
||||
class:transmit={state.includes('TRANSMIT')}
|
||||
on:click={toggleOperate}
|
||||
>
|
||||
{displayState}
|
||||
</button>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="label">FWD PWR (W)</div>
|
||||
<div class="value">{powerForward.toFixed(1)}</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" style="width: {Math.min(100, (powerForward / 1500) * 100)}%"></div>
|
||||
<!-- Power Display + SWR Side by Side -->
|
||||
<div class="power-swr-row">
|
||||
<div class="power-section">
|
||||
<div class="power-header">
|
||||
<span class="power-label-inline">Power</span>
|
||||
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||
</div>
|
||||
<div class="power-bar-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scale">
|
||||
<span>0</span>
|
||||
<span>1000</span>
|
||||
<span>2000</span>
|
||||
|
||||
<!-- SWR Circle Compact -->
|
||||
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||
<div class="swr-label-compact">SWR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<div class="label">PG XL SWR 1:1.00 use</div>
|
||||
<div class="value">{swr.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
<div class="metric">
|
||||
<div class="label">Temp / HL Temp</div>
|
||||
<div class="value">{temperature.toFixed(0)}°C / {harmonicLoadTemp.toFixed(1)}°C</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" style="width: {(temperature / 80) * 100}%"></div>
|
||||
<!-- Temperature Gauges -->
|
||||
<div class="temp-group">
|
||||
<div class="temp-item">
|
||||
<div class="temp-value" style="color: {tempColor}">{temperature.toFixed(1)}°</div>
|
||||
<div class="temp-label">PA Temp</div>
|
||||
<div class="temp-mini-bar">
|
||||
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scale">
|
||||
<span>25</span>
|
||||
<span>55</span>
|
||||
<span>80</span>
|
||||
<div class="temp-item">
|
||||
<div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(1)}°</div>
|
||||
<div class="temp-label">HL Temp</div>
|
||||
<div class="temp-mini-bar">
|
||||
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-row">
|
||||
<div class="metric small">
|
||||
<div class="label">VAC</div>
|
||||
<div class="value">{voltage.toFixed(0)}</div>
|
||||
<!-- Electrical Parameters -->
|
||||
<div class="params-grid">
|
||||
<div class="param-box">
|
||||
<div class="param-label">VAC</div>
|
||||
<div class="param-value">{voltage.toFixed(0)}</div>
|
||||
</div>
|
||||
<div class="metric small">
|
||||
<div class="label">VDD</div>
|
||||
<div class="value">{vdd.toFixed(1)}</div>
|
||||
<div class="param-box">
|
||||
<div class="param-label">VDD</div>
|
||||
<div class="param-value">{vdd.toFixed(1)}</div>
|
||||
</div>
|
||||
<div class="metric small">
|
||||
<div class="label">ID peak</div>
|
||||
<div class="value">{peakCurrent.toFixed(1)}</div>
|
||||
<div class="param-box">
|
||||
<div class="param-label">ID Peak</div>
|
||||
<div class="param-value">{peakCurrent.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fan-speed">
|
||||
<div class="label">Fan Speed</div>
|
||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||
<option value="STANDARD">STANDARD</option>
|
||||
<option value="CONTEST">CONTEST</option>
|
||||
<option value="BROADCAST">BROADCAST</option>
|
||||
<!-- Band Display -->
|
||||
<div class="band-display">
|
||||
<div class="band-item">
|
||||
<span class="band-label">Band A</span>
|
||||
<span class="band-value">{bandA}</span>
|
||||
</div>
|
||||
<div class="band-item">
|
||||
<span class="band-label">Band B</span>
|
||||
<span class="band-value">{bandB}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fan Control -->
|
||||
<div class="fan-control">
|
||||
<label for="fan-mode-select" class="control-label">Fan Mode</label>
|
||||
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||
<option value="STANDARD">Standard</option>
|
||||
<option value="CONTEST">Contest</option>
|
||||
<option value="BROADCAST">Broadcast</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="band-info">
|
||||
<div class="label">Band A</div>
|
||||
<div class="value">{bandA}</div>
|
||||
</div>
|
||||
<div class="band-info">
|
||||
<div class="label">Band B</div>
|
||||
<div class="value">{bandB}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
.powergenius-card {
|
||||
min-width: 350px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
.state-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-label.normal {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
.state-badge.idle {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-label.warning {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
.state-badge.transmit {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
/* Power Display */
|
||||
/* Power + SWR Row */
|
||||
.power-swr-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric.small {
|
||||
.power-section {
|
||||
flex: 1;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-teal);
|
||||
.power-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
.power-label-inline {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.power-value-inline {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.power-bar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-bg {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
height: 28px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
.power-bar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-red));
|
||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||
border-radius: 14px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.scale {
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.swr-circle-compact {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||
border: 4px solid var(--swr-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 25px var(--swr-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swr-value-compact {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--swr-color);
|
||||
}
|
||||
|
||||
.swr-label-compact {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Temperature */
|
||||
.temp-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.temp-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.temp-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fan-speed select {
|
||||
width: 100%;
|
||||
.temp-mini-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.temp-mini-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Parameters Grid */
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.param-box {
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.band-info {
|
||||
.param-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Band Display */
|
||||
.band-display {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.band-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.band-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.band-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Fan Control */
|
||||
.fan-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,334 +1,610 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
|
||||
export let status;
|
||||
export let ultrabeam = null;
|
||||
|
||||
$: rotator1 = status?.rotator1 || {};
|
||||
$: rotator2 = status?.rotator2 || {};
|
||||
$: currentHeading = rotator1.current_azimuth || 0;
|
||||
$: targetHeading = rotator1.target_azimuth || 0;
|
||||
$: moving = rotator1.moving || 0;
|
||||
$: connected = rotator1.connected || false;
|
||||
|
||||
let targetInput = currentHeading;
|
||||
let canvas;
|
||||
let ctx;
|
||||
|
||||
onMount(() => {
|
||||
if (canvas) {
|
||||
ctx = canvas.getContext('2d');
|
||||
drawGlobe();
|
||||
}
|
||||
});
|
||||
|
||||
$: if (ctx && currentHeading !== undefined) {
|
||||
drawGlobe();
|
||||
}
|
||||
|
||||
function drawGlobe() {
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 2 - 20;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw globe circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw grid lines (latitude/longitude)
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Latitude lines
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = centerY - radius + (radius * 2 * i / 4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - radius, y);
|
||||
ctx.lineTo(centerX + radius, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Longitude lines
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const x = centerX - radius + (radius * 2 * i / 4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, centerY - radius);
|
||||
ctx.lineTo(x, centerY + radius);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw cardinal directions
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '14px Roboto';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillText('N', centerX, centerY - radius - 10);
|
||||
ctx.fillText('S', centerX, centerY + radius + 10);
|
||||
ctx.fillText('E', centerX + radius + 10, centerY);
|
||||
ctx.fillText('W', centerX - radius - 10, centerY);
|
||||
|
||||
// Draw heading indicator
|
||||
const angle = (currentHeading - 90) * Math.PI / 180;
|
||||
const lineLength = radius - 10;
|
||||
const endX = centerX + Math.cos(angle) * lineLength;
|
||||
const endY = centerY + Math.sin(angle) * lineLength;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.lineTo(endX, endY);
|
||||
ctx.strokeStyle = '#00bcd4';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw arrow head
|
||||
const arrowSize = 15;
|
||||
const arrowAngle1 = angle + Math.PI * 0.85;
|
||||
const arrowAngle2 = angle - Math.PI * 0.85;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(endX, endY);
|
||||
ctx.lineTo(endX + Math.cos(arrowAngle1) * arrowSize, endY + Math.sin(arrowAngle1) * arrowSize);
|
||||
ctx.moveTo(endX, endY);
|
||||
ctx.lineTo(endX + Math.cos(arrowAngle2) * arrowSize, endY + Math.sin(arrowAngle2) * arrowSize);
|
||||
ctx.strokeStyle = '#00bcd4';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw center dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 5, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#f44336';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
async function moveToHeading() {
|
||||
const heading = parseInt(targetInput);
|
||||
if (isNaN(heading) || heading < 0 || heading > 360) {
|
||||
alert('Please enter a valid heading (0-360)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.rotator.move(1, heading);
|
||||
} catch (err) {
|
||||
console.error('Failed to move rotator:', err);
|
||||
alert('Failed to move rotator');
|
||||
let heading = null; // Start with null instead of 0
|
||||
let connected = false;
|
||||
|
||||
// Get Ultrabeam direction mode: 0=Normal, 1=180°, 2=Bi-Dir
|
||||
$: ultrabeamDirection = ultrabeam?.direction ?? 0;
|
||||
|
||||
// Update heading with detailed logging to debug
|
||||
$: if (status?.heading !== undefined && status?.heading !== null) {
|
||||
const newHeading = status.heading;
|
||||
|
||||
if (heading === null) {
|
||||
// First time: accept any value
|
||||
heading = newHeading;
|
||||
console.log(` ✓ First load, set to ${heading}°`);
|
||||
} else if (newHeading === 0 && heading > 10 && heading < 350) {
|
||||
// Ignore sudden jump to 0 from middle range (glitch)
|
||||
console.log(` ✗ IGNORED glitch jump from ${heading}° to 0°`);
|
||||
} else {
|
||||
// Normal update
|
||||
heading = newHeading;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Display heading: use cached value or 0 if never set
|
||||
$: displayHeading = heading !== null ? heading : 0;
|
||||
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
// ✅ Target heading from rotator status (when controlled by PST Rotator or other software)
|
||||
$: statusTargetHeading = status?.target_heading ?? null;
|
||||
|
||||
// Local target (when clicking on map in ShackMaster)
|
||||
let localTargetHeading = null;
|
||||
|
||||
// ✅ 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);
|
||||
return wrappedDiff > 2;
|
||||
})();
|
||||
|
||||
// Clear local target when we reach it (within 3 degrees)
|
||||
$: if (localTargetHeading !== null && heading !== null) {
|
||||
const diff = Math.abs(heading - localTargetHeading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
if (wrappedDiff < 3) {
|
||||
localTargetHeading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateCW() {
|
||||
try {
|
||||
await api.rotator.cw(1);
|
||||
await api.rotator.rotateCW();
|
||||
} catch (err) {
|
||||
console.error('Failed to rotate CW:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function rotateCCW() {
|
||||
try {
|
||||
await api.rotator.ccw(1);
|
||||
await api.rotator.rotateCCW();
|
||||
} catch (err) {
|
||||
console.error('Failed to rotate CCW:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function stop() {
|
||||
try {
|
||||
localTargetHeading = null; // Clear local target on stop
|
||||
await api.rotator.stop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on compass to set heading
|
||||
let popupWindow = null;
|
||||
|
||||
// Preset directions
|
||||
const presets = [
|
||||
{ name: 'EU-0', heading: 0 },
|
||||
{ name: 'JA-35', heading: 35 },
|
||||
{ name: 'AS-75', heading: 75 },
|
||||
{ name: 'VK-120', heading: 120 },
|
||||
{ name: 'AF-180', heading: 180 },
|
||||
{ name: 'SA-230', heading: 230 },
|
||||
{ name: 'WI-270', heading: 270 },
|
||||
{ name: 'NA-300', heading: 300 }
|
||||
];
|
||||
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 gotoPreset(heading) {
|
||||
async function handleCompassClick(event) {
|
||||
const svg = event.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
// Get click position relative to center
|
||||
const x = event.clientX - rect.left - centerX;
|
||||
const y = event.clientY - rect.top - centerY;
|
||||
|
||||
// Calculate angle (0° = North/top, clockwise)
|
||||
let angle = Math.atan2(x, -y) * (180 / Math.PI);
|
||||
if (angle < 0) angle += 360;
|
||||
|
||||
// Round to nearest 5 degrees
|
||||
const roundedHeading = Math.round(angle / 5) * 5;
|
||||
const adjustedHeading = (roundedHeading + 360) % 360;
|
||||
|
||||
// ✅ CORRIGÉ : Send command first, then set localTargetHeading only on success
|
||||
try {
|
||||
await api.rotator.move(1, heading);
|
||||
await api.rotator.setHeading(adjustedHeading);
|
||||
// Only set local target AFTER successful API call
|
||||
localTargetHeading = adjustedHeading;
|
||||
} catch (err) {
|
||||
console.error('Failed to move to preset:', err);
|
||||
console.error('Failed to set heading:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rotator-card card">
|
||||
<h2>
|
||||
ROTATOR GENIUS
|
||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
||||
</h2>
|
||||
|
||||
<div class="heading-display">
|
||||
CURRENT HEADING: <span class="heading-value">{currentHeading}°</span>
|
||||
</div>
|
||||
|
||||
{#if moving > 0}
|
||||
<div class="moving-indicator">
|
||||
{moving === 1 ? '↻ ROTATING CW' : '↺ ROTATING CCW'}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<canvas bind:this={canvas} width="300" height="300"></canvas>
|
||||
|
||||
<div class="controls">
|
||||
<div class="heading-input">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="360"
|
||||
bind:value={targetInput}
|
||||
placeholder="Enter heading"
|
||||
/>
|
||||
<button class="btn btn-primary" on:click={moveToHeading}>GO</button>
|
||||
</div>
|
||||
|
||||
<div class="rotation-controls">
|
||||
<button class="btn btn-secondary" on:click={rotateCCW}>↺ CCW</button>
|
||||
<button class="btn btn-danger" on:click={stop}>STOP</button>
|
||||
<button class="btn btn-secondary" on:click={rotateCW}>CW ↻</button>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Rotator Genius</h2>
|
||||
<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 class="presets">
|
||||
{#each presets as preset}
|
||||
<button
|
||||
class="preset-btn"
|
||||
class:active={Math.abs(currentHeading - preset.heading) < 5}
|
||||
on:click={() => gotoPreset(preset.heading)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="metrics">
|
||||
<!-- Current Heading Display with Compact Controls -->
|
||||
<div class="heading-controls-row">
|
||||
<div class="heading-display-compact">
|
||||
<div class="heading-label">CURRENT HEADING</div>
|
||||
<div class="heading-value">
|
||||
{displayHeading}°
|
||||
{#if hasTarget && activeTargetHeading !== null}
|
||||
<span class="target-indicator">→ {activeTargetHeading}°</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-compact">
|
||||
<button class="btn-mini ccw" on:click={rotateCCW} title="Rotate Counter-Clockwise">
|
||||
↺
|
||||
</button>
|
||||
<button class="btn-mini stop" on:click={stop} title="Stop Rotation">
|
||||
■
|
||||
</button>
|
||||
<button class="btn-mini cw" on:click={rotateCW} title="Rotate Clockwise">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map with Beam -->
|
||||
<div class="map-container">
|
||||
<svg viewBox="0 0 300 300" class="map-svg clickable-compass"
|
||||
on:click={handleCompassClick}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleCompassClick(e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Click to rotate antenna to direction">
|
||||
<defs>
|
||||
<!-- Gradient for beam -->
|
||||
<radialGradient id="beamGradient">
|
||||
<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>
|
||||
|
||||
<!-- Ocean background -->
|
||||
<circle cx="150" cy="150" r="140" fill="rgba(30, 64, 175, 0.15)" stroke="rgba(79, 195, 247, 0.4)" stroke-width="2"/>
|
||||
|
||||
<!-- Distance circles -->
|
||||
<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"/>
|
||||
|
||||
<!-- Rotated group for beam -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Physical antenna direction indicator (only in 180° or Bi-Dir mode) -->
|
||||
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||
<g transform="rotate({displayHeading})">
|
||||
<!-- Gray dashed line showing physical antenna direction -->
|
||||
<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"/>
|
||||
<!-- Small triangle at end to show physical direction -->
|
||||
<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}
|
||||
|
||||
<!-- Beam (rotates with heading) -->
|
||||
<g transform="rotate({displayHeading})">
|
||||
|
||||
<!-- NORMAL MODE (0): Forward beam only -->
|
||||
{#if ultrabeamDirection === 0}
|
||||
<!-- Beam sector (±15° = 30° total beamwidth) -->
|
||||
<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(#beamGradient)"
|
||||
opacity="0.85"/>
|
||||
|
||||
<!-- Beam outline -->
|
||||
<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"/>
|
||||
|
||||
<!-- Direction arrow -->
|
||||
<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}
|
||||
|
||||
<!-- 180° MODE (1): Backward beam only -->
|
||||
{#if ultrabeamDirection === 1}
|
||||
<!-- Beam sector pointing BACKWARD (180° opposite) -->
|
||||
<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(#beamGradient)"
|
||||
opacity="0.85"/>
|
||||
|
||||
<!-- Beam outline -->
|
||||
<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"/>
|
||||
|
||||
<!-- Direction arrow pointing BACKWARD -->
|
||||
<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}
|
||||
|
||||
<!-- BI-DIRECTIONAL MODE (2): Both forward AND backward beams -->
|
||||
{#if ultrabeamDirection === 2}
|
||||
<!-- Forward beam -->
|
||||
<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(#beamGradient)"
|
||||
opacity="0.7"/>
|
||||
|
||||
<!-- Backward beam -->
|
||||
<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(#beamGradient)"
|
||||
opacity="0.7"/>
|
||||
|
||||
<!-- Beam outlines -->
|
||||
<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"/>
|
||||
|
||||
<!-- Direction arrows (both directions) -->
|
||||
<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 (yellow) - shown when antenna is moving to target -->
|
||||
{#if hasTarget && activeTargetHeading !== null}
|
||||
<g transform="rotate({activeTargetHeading})">
|
||||
<!-- Target direction line (dashed yellow) -->
|
||||
<line x1="0" y1="0" x2="0" y2="-135"
|
||||
stroke="#ffc107"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="8,4"
|
||||
opacity="0.9"/>
|
||||
<!-- Target arrow head with pulse animation -->
|
||||
<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}
|
||||
|
||||
<!-- Center dot (your QTH) -->
|
||||
<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>
|
||||
|
||||
<!-- Cardinal points -->
|
||||
<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>
|
||||
|
||||
<!-- Degree markers every 45° -->
|
||||
{#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={x} y={y} text-anchor="middle" dominant-baseline="middle" class="degree-label">{angle}°</text>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Legend (only show in 180° or Bi-Dir mode) -->
|
||||
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||
<div class="map-legend">
|
||||
<div class="legend-item">
|
||||
<svg width="30" height="20" viewBox="0 0 30 20">
|
||||
<line x1="5" y1="10" x2="25" y2="10" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="3,3"/>
|
||||
</svg>
|
||||
<span>Physical antenna</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<svg width="30" height="20" viewBox="0 0 30 20">
|
||||
<line x1="5" y1="10" x2="25" y2="10" stroke="#4fc3f7" stroke-width="2"/>
|
||||
</svg>
|
||||
<span>Radiation pattern</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rotator-card {
|
||||
min-width: 350px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.heading-display {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
.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 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heading-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
.heading-display-compact {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.controls-compact {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-mini {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(79, 195, 247, 0.08);
|
||||
}
|
||||
|
||||
.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);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
.btn-mini.ccw:hover {
|
||||
transform: translateY(-1px) rotate(-5deg);
|
||||
}
|
||||
|
||||
.btn-mini.cw:hover {
|
||||
transform: translateY(-1px) rotate(5deg);
|
||||
}
|
||||
|
||||
.btn-mini.stop:hover {
|
||||
border-color: #f44336;
|
||||
color: #f44336;
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
|
||||
.heading-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heading-value {
|
||||
color: var(--accent-blue);
|
||||
font-size: 42px;
|
||||
font-weight: 200;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.target-indicator {
|
||||
font-size: 24px;
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
color: #ffc107;
|
||||
margin-left: 20px;
|
||||
text-shadow: 0 0 15px rgba(255, 193, 7, 0.6);
|
||||
animation: targetPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.moving-indicator {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
@keyframes targetPulse {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
background: var(--bg-secondary);
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
background: rgba(10, 22, 40, 0.6);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
.map-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.heading-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heading-input input {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rotation-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rotation-controls button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 12px 8px;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
background: rgba(10, 22, 40, 0.4);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #1976d2;
|
||||
transform: translateY(-2px);
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: var(--accent-green);
|
||||
.map-svg {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.clickable-compass {
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clickable-compass:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.cardinal {
|
||||
fill: var(--accent-cyan);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 10px rgba(79, 195, 247, 0.8);
|
||||
}
|
||||
|
||||
.degree-label {
|
||||
fill: rgba(79, 195, 247, 0.7);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,257 +1,445 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
|
||||
export let status;
|
||||
|
||||
$: operate = status?.operate || false;
|
||||
$: activeAntenna = status?.active_antenna || 0;
|
||||
$: powerForward = status?.power_forward || 0;
|
||||
$: swr = status?.swr || 1.0;
|
||||
$: tuningStatus = status?.tuning_status || 'READY';
|
||||
$: frequencyA = status?.frequency_a || 0;
|
||||
$: frequencyB = status?.frequency_b || 0;
|
||||
$: c1 = status?.c1 || 0;
|
||||
$: l = status?.l || 0;
|
||||
$: c2 = status?.c2 || 0;
|
||||
$: bypass = status?.bypass || false;
|
||||
$: state = status?.state || 0;
|
||||
$: relayC1 = status?.c1 || 0;
|
||||
$: relayL = status?.l || 0;
|
||||
$: relayC2 = status?.c2 || 0;
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
let tuning = false;
|
||||
// Color functions
|
||||
$: swrColor = swr < 1.5 ? '#4caf50' : swr < 2.0 ? '#ffc107' : swr < 3.0 ? '#ff9800' : '#f44336';
|
||||
$: powerPercent = Math.min((powerForward / 2000) * 100, 100);
|
||||
|
||||
async function toggleOperate() {
|
||||
async function autoTune() {
|
||||
try {
|
||||
await api.tuner.operate(!operate);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle operate:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTune() {
|
||||
tuning = true;
|
||||
try {
|
||||
await api.tuner.tune();
|
||||
await api.tuner.autoTune();
|
||||
} catch (err) {
|
||||
console.error('Failed to tune:', err);
|
||||
alert('Tuning failed');
|
||||
} finally {
|
||||
tuning = false;
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function setAntenna(ant) {
|
||||
async function setBypass(value) {
|
||||
try {
|
||||
await api.tuner.antenna(ant);
|
||||
await api.tuner.setBypass(value);
|
||||
} catch (err) {
|
||||
console.error('Failed to set antenna:', err);
|
||||
console.error('Failed to set bypass:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function setOperate(value) {
|
||||
try {
|
||||
await api.tuner.setOperate(value);
|
||||
} catch (err) {
|
||||
console.error('Failed to set operate:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tuner-card card">
|
||||
<h2>
|
||||
TGXL
|
||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
||||
</h2>
|
||||
|
||||
<div class="power-status">
|
||||
<div class="label">Power 0.0w</div>
|
||||
<div class="status-badge">1500</div>
|
||||
<div class="status-badge">1650</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Tuner Genius XL</h2>
|
||||
<div class="header-right">
|
||||
<span class="tuning-badge" class:tuning={tuningStatus === 'TUNING'}>{tuningStatus}</span>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tuning-controls">
|
||||
<div class="tuning-row">
|
||||
<div class="tuning-label">TG XL SWR 1.00 use</div>
|
||||
<div class="metrics">
|
||||
<!-- Power Display + SWR Side by Side -->
|
||||
<div class="power-swr-row">
|
||||
<div class="power-section">
|
||||
<div class="power-header">
|
||||
<span class="power-label-inline">Power</span>
|
||||
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||
</div>
|
||||
<div class="power-bar-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SWR Circle Compact -->
|
||||
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||
<div class="swr-label-compact">SWR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="antenna-buttons">
|
||||
|
||||
<!-- Tuning Capacitors -->
|
||||
<div class="capacitors">
|
||||
<div class="cap-item">
|
||||
<div class="cap-value">{relayC1}</div>
|
||||
<div class="cap-label">C1</div>
|
||||
</div>
|
||||
<div class="cap-item">
|
||||
<div class="cap-value">{relayL}</div>
|
||||
<div class="cap-label">L</div>
|
||||
</div>
|
||||
<div class="cap-item">
|
||||
<div class="cap-value">{relayC2}</div>
|
||||
<div class="cap-label">C2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequencies -->
|
||||
<div class="freq-display">
|
||||
<div class="freq-item">
|
||||
<div class="freq-label">Freq A</div>
|
||||
<div class="freq-value">{(frequencyA / 1000).toFixed(3)}<span class="freq-unit">MHz</span></div>
|
||||
</div>
|
||||
<div class="freq-item">
|
||||
<div class="freq-label">Freq B</div>
|
||||
<div class="freq-value">{(frequencyB / 1000).toFixed(3)}<span class="freq-unit">MHz</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="controls">
|
||||
<button
|
||||
class="antenna-btn"
|
||||
class:active={activeAntenna === 0}
|
||||
on:click={() => setAntenna(0)}
|
||||
class="control-btn operate"
|
||||
class:active={state === 1}
|
||||
on:click={() => setOperate(state === 1 ? 0 : 1)}
|
||||
>
|
||||
C1
|
||||
{state === 1 ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<button
|
||||
class="antenna-btn"
|
||||
class:active={activeAntenna === 1}
|
||||
on:click={() => setAntenna(1)}
|
||||
class="control-btn bypass"
|
||||
class:active={bypass}
|
||||
on:click={() => setBypass(bypass ? 0 : 1)}
|
||||
>
|
||||
L
|
||||
</button>
|
||||
<button
|
||||
class="antenna-btn"
|
||||
class:active={activeAntenna === 2}
|
||||
on:click={() => setAntenna(2)}
|
||||
>
|
||||
C2
|
||||
BYPASS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tuning-values">
|
||||
<div class="value-box">
|
||||
<div class="value">{c1}</div>
|
||||
<div class="label">C1</div>
|
||||
</div>
|
||||
<div class="value-box">
|
||||
<div class="value">{l}</div>
|
||||
<div class="label">L</div>
|
||||
</div>
|
||||
<div class="value-box">
|
||||
<div class="value">{c2}</div>
|
||||
<div class="label">C2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="metric">
|
||||
<div class="label">Tuning Status</div>
|
||||
<div class="status-badge" class:tuning={tuningStatus === 'TUNING'}>
|
||||
{tuningStatus}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="frequency-row">
|
||||
<div class="metric">
|
||||
<div class="label">Frequency A</div>
|
||||
<div class="value-display">{(frequencyA / 1000).toFixed(3)}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Frequency B</div>
|
||||
<div class="value-display">{(frequencyB / 1000).toFixed(3)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
class="btn"
|
||||
class:btn-primary={!operate}
|
||||
class:btn-danger={operate}
|
||||
on:click={toggleOperate}
|
||||
>
|
||||
{operate ? 'STANDBY' : 'OPERATE'}
|
||||
<button class="tune-btn" on:click={autoTune}>
|
||||
<span class="tune-icon">⚡</span>
|
||||
AUTO TUNE
|
||||
</button>
|
||||
<button class="btn btn-secondary" disabled>BYPASS</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-danger tune-btn"
|
||||
disabled={tuning || !operate}
|
||||
on:click={startTune}
|
||||
>
|
||||
{tuning ? 'TUNING...' : 'TUNE'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tuner-card {
|
||||
min-width: 350px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.power-status {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
.tuning-badge {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-badge.tuning {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
.tuning-badge.tuning {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.tuning-controls {
|
||||
margin-bottom: 16px;
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.tuning-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.antenna-buttons {
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.antenna-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.antenna-btn.active {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.tuning-values {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.value-box {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.value-box .value {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.frequency-row {
|
||||
/* Power Display */
|
||||
/* Power + SWR Row */
|
||||
.power-swr-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric {
|
||||
.power-section {
|
||||
flex: 1;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.power-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
flex: 1;
|
||||
.power-label-inline {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.power-value-inline {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.power-bar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-bg {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||
border-radius: 14px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.swr-circle-compact {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||
border: 4px solid var(--swr-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 25px var(--swr-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swr-value-compact {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--swr-color);
|
||||
}
|
||||
|
||||
.swr-label-compact {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* SWR Circle */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Capacitors */
|
||||
.capacitors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.cap-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cap-value {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.cap-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Frequencies */
|
||||
.freq-display {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.freq-item {
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.freq-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.freq-value {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.freq-unit {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.tune-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.tune-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
|
||||
.tune-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tune-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,565 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
export let status;
|
||||
export let flexradio = null;
|
||||
|
||||
$: connected = status?.connected || false;
|
||||
$: frequency = status?.frequency || 0;
|
||||
$: band = status?.band || 0;
|
||||
$: direction = status?.direction || 0;
|
||||
$: motorsMoving = status?.motors_moving || 0;
|
||||
$: progressTotal = status?.progress_total || 0;
|
||||
$: progressCurrent = status?.progress_current || 0;
|
||||
$: elementLengths = status?.element_lengths || [];
|
||||
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
||||
|
||||
// FlexRadio interlock
|
||||
$: interlockConnected = flexradio?.connected || false;
|
||||
$: interlockState = flexradio?.interlock_state || null;
|
||||
$: interlockColor = getInterlockColor(interlockState);
|
||||
|
||||
// Debug log
|
||||
$: if (flexradio) {
|
||||
console.log('FlexRadio data:', {
|
||||
connected: flexradio.connected,
|
||||
interlock_state: flexradio.interlock_state,
|
||||
interlockConnected,
|
||||
interlockState
|
||||
});
|
||||
}
|
||||
|
||||
function getInterlockColor(state) {
|
||||
switch(state) {
|
||||
case 'READY': return '#4caf50';
|
||||
case 'NOT_READY': return '#f44336';
|
||||
case 'PTT_REQUESTED': return '#ffc107';
|
||||
case 'TRANSMITTING': return '#ff9800';
|
||||
default: return 'rgba(255, 255, 255, 0.3)';
|
||||
}
|
||||
}
|
||||
|
||||
// Band names mapping - VL2.3 covers 6M to 40M only
|
||||
// Band 0=6M, 1=10M, 2=12M, 3=15M, 4=17M, 5=20M, 6=30M, 7=40M
|
||||
const bandNames = [
|
||||
'6M', '10M', '12M', '15M', '17M', '20M', '30M', '40M'
|
||||
];
|
||||
|
||||
// Detect band from frequency
|
||||
$: detectedBand = detectBandFromFrequency(frequency, band);
|
||||
|
||||
function detectBandFromFrequency(freq, bandIndex) {
|
||||
// If band index is valid (0-7), use it directly
|
||||
if (bandIndex >= 0 && bandIndex <= 7) {
|
||||
return bandNames[bandIndex];
|
||||
}
|
||||
|
||||
// Otherwise detect from frequency (in kHz)
|
||||
if (freq >= 7000 && freq <= 7300) return '40M';
|
||||
if (freq >= 10100 && freq <= 10150) return '30M';
|
||||
if (freq >= 14000 && freq <= 14350) return '20M';
|
||||
if (freq >= 18068 && freq <= 18168) return '17M';
|
||||
if (freq >= 21000 && freq <= 21450) return '15M';
|
||||
if (freq >= 24890 && freq <= 24990) return '12M';
|
||||
if (freq >= 28000 && freq <= 29700) return '10M';
|
||||
if (freq >= 50000 && freq <= 54000) return '6M';
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// Direction names
|
||||
const directionNames = ['Normal', '180°', 'Bi-Dir'];
|
||||
|
||||
// Auto-track threshold options
|
||||
const thresholdOptions = [
|
||||
{ value: 25, label: '25 kHz' },
|
||||
{ value: 50, label: '50 kHz' },
|
||||
{ value: 100, label: '100 kHz' }
|
||||
];
|
||||
|
||||
// Auto-track state
|
||||
let autoTrackEnabled = true; // Default enabled
|
||||
let autoTrackThreshold = 25; // Default 25 kHz
|
||||
|
||||
// Form state
|
||||
let targetDirection = 0;
|
||||
|
||||
// Auto-update targetDirection when status changes
|
||||
$: targetDirection = direction;
|
||||
|
||||
// Element names based on band (corrected order: 0=6M ... 10=160M)
|
||||
$: elementNames = getElementNames(band);
|
||||
|
||||
function getElementNames(band) {
|
||||
// 30M (band 6) and 40M (band 7): Reflector (inverted), Radiator (inverted)
|
||||
if (band === 6 || band === 7) {
|
||||
return ['Radiator (30/40M)', 'Reflector (30/40M)', null];
|
||||
}
|
||||
// 6M to 20M (bands 0-5): Reflector, Radiator, Director 1
|
||||
if (band >= 0 && band <= 5) {
|
||||
return ['Reflector', 'Radiator', 'Director 1'];
|
||||
}
|
||||
// Default
|
||||
return ['Element 1', 'Element 2', 'Element 3'];
|
||||
}
|
||||
|
||||
// Element calibration state
|
||||
let calibrationMode = false;
|
||||
let selectedElement = 0;
|
||||
let elementAdjustment = 0;
|
||||
|
||||
async function setDirection() {
|
||||
if (frequency === 0) {
|
||||
return; // Silently skip if no frequency
|
||||
}
|
||||
try {
|
||||
// Send command to antenna with current frequency and new direction
|
||||
await api.ultrabeam.setFrequency(frequency, targetDirection);
|
||||
// Also save direction preference for auto-track
|
||||
await api.ultrabeam.setDirection(targetDirection);
|
||||
} catch (err) {
|
||||
// Log error but don't alert - code 30 (busy) is normal
|
||||
console.log('Direction change sent (may show code 30 if busy):', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAutoTrack() {
|
||||
try {
|
||||
await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold);
|
||||
} catch (err) {
|
||||
console.error('Failed to update auto-track:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function retract() {
|
||||
if (!confirm('Retract all antenna elements?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.ultrabeam.retract();
|
||||
} catch (err) {
|
||||
console.error('Failed to retract:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function adjustElement() {
|
||||
try {
|
||||
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
||||
// TODO: Add API call when backend supports it
|
||||
// Removed alert popup - check console for errors
|
||||
elementAdjustment = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to adjust element:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
$: progressPercent = progressTotal > 0 ? (progressCurrent / 60) * 100 : 0;
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Ultrabeam VL2.3</div>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<!-- Current Status -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Frequency</div>
|
||||
<div class="status-value freq">{(frequency / 1000).toFixed(3)} MHz</div>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<div class="status-label">Band</div>
|
||||
<div class="status-value band">{detectedBand}</div>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<div class="status-label">Direction</div>
|
||||
<div class="status-value direction">{directionNames[direction]}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Track Control -->
|
||||
<div class="control-section compact">
|
||||
<h3>Auto Tracking</h3>
|
||||
<div class="auto-track-controls">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
|
||||
<span>Enable Auto-Track from Tuner</span>
|
||||
</label>
|
||||
|
||||
<div class="threshold-group">
|
||||
<label for="threshold-select">Threshold:</label>
|
||||
<select id="threshold-select" bind:value={autoTrackThreshold} on:change={updateAutoTrack}>
|
||||
{#each thresholdOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direction buttons on separate line -->
|
||||
<div class="direction-buttons">
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={targetDirection === 0}
|
||||
on:click={() => { targetDirection = 0; setDirection(); }}
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={targetDirection === 1}
|
||||
on:click={() => { targetDirection = 1; setDirection(); }}
|
||||
>
|
||||
180°
|
||||
</button>
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={targetDirection === 2}
|
||||
on:click={() => { targetDirection = 2; setDirection(); }}
|
||||
>
|
||||
Bi-Dir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motor Progress -->
|
||||
{#if motorsMoving > 0}
|
||||
<div class="progress-section">
|
||||
<h3>Motors Moving...</h3>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">{progressCurrent} / 60 ({progressPercent.toFixed(0)}%)</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Element Lengths Display - HIDDEN: Command 9 returns status instead -->
|
||||
<!--
|
||||
<div class="elements-section">
|
||||
<h3>Element Lengths (mm)</h3>
|
||||
<div class="elements-grid">
|
||||
{#each elementLengths.slice(0, 3) as length, i}
|
||||
{#if length > 0 && elementNames[i]}
|
||||
<div class="element-item">
|
||||
<div class="element-label">{elementNames[i]}</div>
|
||||
<div class="element-value">{length} mm</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Calibration Mode - HIDDEN: Command 9 doesn't work -->
|
||||
<!--
|
||||
<div class="calibration-section">
|
||||
<div class="section-header">
|
||||
<h3>Calibration</h3>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class:active={calibrationMode}
|
||||
on:click={() => calibrationMode = !calibrationMode}
|
||||
>
|
||||
{calibrationMode ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if calibrationMode}
|
||||
<div class="calibration-controls">
|
||||
<div class="input-group">
|
||||
<label for="element-select">Element</label>
|
||||
<select id="element-select" bind:value={selectedElement}>
|
||||
{#each elementLengths.slice(0, 3) as length, i}
|
||||
{#if length > 0 && elementNames[i]}
|
||||
<option value={i}>{elementNames[i]} ({length}mm)</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="adjustment">Adjustment (mm)</label>
|
||||
<input
|
||||
id="adjustment"
|
||||
type="number"
|
||||
bind:value={elementAdjustment}
|
||||
step="1"
|
||||
placeholder="±10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn-caution" on:click={adjustElement}>
|
||||
<span class="icon">⚙️</span>
|
||||
Apply Adjustment
|
||||
</button>
|
||||
|
||||
<p class="warning-text">
|
||||
⚠️ Calibration changes are saved after 12 seconds. Do not turn off during this time.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="btn-danger" on:click={retract}>
|
||||
<span class="icon">↓</span>
|
||||
Retract Elements
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: #4fc3f7;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #666;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Status Grid */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #4fc3f7;
|
||||
text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.status-value.freq {
|
||||
color: #66bb6a;
|
||||
font-size: 22px;
|
||||
text-shadow: 0 0 10px rgba(102, 187, 106, 0.5);
|
||||
}
|
||||
|
||||
.status-value.band {
|
||||
color: #ffa726;
|
||||
text-shadow: 0 0 10px rgba(255, 167, 38, 0.5);
|
||||
}
|
||||
|
||||
.status-value.direction {
|
||||
color: #ab47bc;
|
||||
text-shadow: 0 0 10px rgba(171, 71, 188, 0.5);
|
||||
}
|
||||
|
||||
/* Control Section */
|
||||
.control-section {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.control-section.compact {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auto-track-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.threshold-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.threshold-group label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.direction-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dir-btn {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(79, 195, 247, 0.08);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.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);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dir-btn.active {
|
||||
border-color: #4fc3f7;
|
||||
color: #4fc3f7;
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
box-shadow: 0 0 20px rgba(79, 195, 247, 0.4);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Progress Section */
|
||||
.progress-section {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.progress-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #4fc3f7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 20px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4fc3f7, #03a9f4);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
export let status;
|
||||
|
||||
$: relays = status?.relays || [];
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
const relayNames = {
|
||||
1: 'Power Supply',
|
||||
@@ -51,102 +52,259 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="webswitch-card card">
|
||||
<h2>
|
||||
1216RH
|
||||
<span class="status-indicator" class:status-online={relays.length > 0} class:status-offline={relays.length === 0}></span>
|
||||
</h2>
|
||||
|
||||
<div class="relays">
|
||||
{#each [1, 2, 3, 4, 5] as relayNum}
|
||||
{@const relay = relays.find(r => r.number === relayNum)}
|
||||
{@const isOn = relay?.state || false}
|
||||
<div class="relay-row">
|
||||
<span class="relay-name">{relayNames[relayNum]}</span>
|
||||
<button
|
||||
class="relay-toggle"
|
||||
class:active={isOn}
|
||||
disabled={loading[relayNum]}
|
||||
on:click={() => toggleRelay(relayNum)}
|
||||
>
|
||||
<div class="toggle-icon"></div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>WebSwitch</h2>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn btn-primary" on:click={allOn}>ALL ON</button>
|
||||
<button class="btn btn-danger" on:click={allOff}>ALL OFF</button>
|
||||
<div class="metrics">
|
||||
<div class="relays">
|
||||
{#each [1, 2, 3, 4, 5] as relayNum}
|
||||
{@const relay = relays.find(r => r.number === relayNum)}
|
||||
{@const isOn = relay?.state || false}
|
||||
<div class="relay-card" class:relay-on={isOn}>
|
||||
<div class="relay-info">
|
||||
<div class="relay-details">
|
||||
<div class="relay-name">{relayNames[relayNum]}</div>
|
||||
<div class="relay-status">{isOn ? 'ON' : 'OFF'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="relay-toggle"
|
||||
class:active={isOn}
|
||||
class:loading={loading[relayNum]}
|
||||
disabled={loading[relayNum]}
|
||||
on:click={() => toggleRelay(relayNum)}
|
||||
>
|
||||
<div class="toggle-track">
|
||||
<div class="toggle-thumb"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="control-btn all-on" on:click={allOn}>
|
||||
<span class="btn-icon">⚡</span>
|
||||
ALL ON
|
||||
</button>
|
||||
<button class="control-btn all-off" on:click={allOff}>
|
||||
<span class="btn-icon">⏻</span>
|
||||
ALL OFF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.webswitch-card {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.relays {
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.relay-row {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Relays */
|
||||
.relays {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relay-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.relay-card.relay-on {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border-color: rgba(76, 175, 80, 0.3);
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.relay-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.relay-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.relay-name {
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.relay-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.relay-card.relay-on .relay-status {
|
||||
color: #4caf50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.relay-toggle {
|
||||
width: 60px;
|
||||
height: 32px;
|
||||
background: #555;
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
transition: background 0.3s ease;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.relay-toggle.active {
|
||||
background: var(--accent-green);
|
||||
.relay-toggle:hover .toggle-track {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.relay-toggle.active .toggle-track {
|
||||
background: linear-gradient(135deg, #4caf50, #66bb6a);
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
transition: transform 0.3s ease;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.relay-toggle.active .toggle-icon {
|
||||
transform: translateX(28px);
|
||||
.relay-toggle.active .toggle-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.relay-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
flex: 1;
|
||||
.control-btn {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.all-on {
|
||||
background: linear-gradient(135deg, #4caf50, #66bb6a);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.all-on:hover {
|
||||
box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.all-off {
|
||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.all-off:hover {
|
||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
</style>
|
||||
+46
-7
@@ -47,23 +47,28 @@ export const api = {
|
||||
|
||||
// Tuner
|
||||
tuner: {
|
||||
operate: (operate) => request('/tuner/operate', {
|
||||
setOperate: (value) => request('/tuner/operate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ operate }),
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
tune: () => request('/tuner/tune', { method: 'POST' }),
|
||||
antenna: (antenna) => request('/tuner/antenna', {
|
||||
setBypass: (value) => request('/tuner/bypass', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ antenna }),
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Antenna Genius
|
||||
antenna: {
|
||||
set: (radio, antenna) => request('/antenna/set', {
|
||||
selectAntenna: (port, antenna) => request('/antenna/select', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ radio, antenna }),
|
||||
body: JSON.stringify({ port, antenna }),
|
||||
}),
|
||||
deselectAntenna: (port, antenna) => request('/antenna/deselect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ port, antenna }),
|
||||
}),
|
||||
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Power Genius
|
||||
@@ -72,5 +77,39 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
setOperate: (value) => request('/power/operate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
},
|
||||
|
||||
// Rotator Genius
|
||||
rotator: {
|
||||
setHeading: (heading) => request('/rotator/heading', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ heading }),
|
||||
}),
|
||||
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
||||
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Ultrabeam
|
||||
ultrabeam: {
|
||||
setFrequency: (frequency, direction) => request('/ultrabeam/frequency', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ frequency, direction }),
|
||||
}),
|
||||
retract: () => request('/ultrabeam/retract', { method: 'POST' }),
|
||||
setAutoTrack: (enabled, threshold) => request('/ultrabeam/autotrack', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled, threshold }),
|
||||
}),
|
||||
setDirection: (direction) => request('/ultrabeam/direction', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ direction }),
|
||||
}),
|
||||
},
|
||||
// Shutdown
|
||||
shutdown: () => request('/shutdown', { method: 'POST' }),
|
||||
};
|
||||
@@ -28,6 +28,7 @@ class WebSocketService {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'update') {
|
||||
console.log('System status updated:', message.data);
|
||||
systemStatus.set(message.data);
|
||||
lastUpdate.set(new Date(message.timestamp));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import PopupApp from './PopupApp.svelte';
|
||||
|
||||
const app = new PopupApp({
|
||||
target: document.getElementById('popup-app'),
|
||||
});
|
||||
|
||||
export default app;
|
||||
+7
-1
@@ -4,7 +4,13 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'index.html',
|
||||
popup: 'popup.html',
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user