Compare commits

23 Commits

Author SHA1 Message Date
b8884d89e3 updated frontend 2026-01-14 17:35:07 +01:00
5332ab9dc1 update km/h 2026-01-14 14:29:47 +01:00
b8db847343 u 2026-01-13 23:11:58 +01:00
0cb83157de up 2026-01-13 23:10:43 +01:00
4f484b0091 up 2026-01-12 22:34:14 +01:00
6b5508802a up 2026-01-12 22:34:04 +01:00
51e08d9463 working tx inhibit 2026-01-12 22:07:54 +01:00
2bec98a080 Merge branch 'main' of https://git.rouggy.com/rouggy/ShackMaster 2026-01-12 21:40:30 +01:00
431c17347d corrected autotrack still working when deactivated
change track to radio
2026-01-12 21:40:14 +01:00
4f9e1e88eb corrected autotrack still working when deactivated 2026-01-12 21:36:01 +01:00
414d802d37 last 2026-01-11 17:41:40 +01:00
cd93f0ea67 bug idle status PGXL 2026-01-11 16:50:38 +01:00
3d06dd44d5 up 2026-01-11 15:57:32 +01:00
9837657dd9 corrected all bugs 2026-01-11 15:33:44 +01:00
46ee44c6c9 correct bugs AG 2026-01-10 23:33:47 +01:00
bcf58b208b bug ub 2026-01-10 17:03:50 +01:00
0ce18d87bc up 2026-01-10 16:04:38 +01:00
f172678560 ultrabeam 2026-01-10 11:01:40 +01:00
5fd81a641d up 2026-01-10 09:31:46 +01:00
eee3f48569 up 2026-01-10 04:39:21 +01:00
8de9a0dd87 up 2026-01-10 03:26:17 +01:00
bceac40518 Merge branch 'main' of https://git.rouggy.com/rouggy/ShackMaster 2026-01-09 23:55:14 +01:00
ac99f291a7 rot finished 2026-01-09 23:54:50 +01:00
40 changed files with 6078 additions and 1363 deletions

View File

@@ -1,7 +1,9 @@
package main package main
import ( import (
"embed"
"fmt" "fmt"
"io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -13,6 +15,9 @@ import (
"git.rouggy.com/rouggy/ShackMaster/internal/config" "git.rouggy.com/rouggy/ShackMaster/internal/config"
) )
//go:embed web/dist
var webFS embed.FS
func main() { func main() {
log.Println("Starting ShackMaster server...") log.Println("Starting ShackMaster server...")
@@ -39,10 +44,17 @@ func main() {
log.Fatalf("Failed to start device manager: %v", err) log.Fatalf("Failed to start device manager: %v", err)
} }
// Create HTTP server // Create HTTP server with embedded files
server := api.NewServer(deviceManager, hub, cfg) server := api.NewServer(deviceManager, hub, cfg)
mux := server.SetupRoutes() 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 // Setup HTTP server
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
httpServer := &http.Server{ httpServer := &http.Server{

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

17
cmd/server/web/dist/index.html vendored Normal file
View File

@@ -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/index-BqlArLJ0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -4,29 +4,39 @@ server:
devices: devices:
webswitch: webswitch:
host: "10.10.10.119" host: "10.10.10.100"
power_genius: power_genius:
host: "10.10.10.128" host: "10.10.10.110"
port: 9008 port: 4001
tuner_genius: tuner_genius:
host: "10.10.10.129" host: "10.10.10.111"
port: 9010 port: 4001
antenna_genius: antenna_genius:
host: "10.10.10.130" host: "10.10.10.112"
port: 9007 port: 4001
rotator_genius: rotator_genius:
host: "10.10.10.121" host: "10.10.10.113"
port: 9006 port: 4533
ultrabeam:
host: "10.10.10.124"
port: 4210
flexradio:
enabled: true
host: "10.10.10.120"
port: 4992
interlock_name: "Ultrabeam"
weather: weather:
openweathermap_api_key: "YOUR_API_KEY_HERE" openweathermap_api_key: ""
lightning_enabled: true lightning_enabled: false
location: location:
latitude: 46.2833 latitude: 46.2814
longitude: 6.2333 longitude: 6.2389
callsign: "F4BPO" callsign: "F4BPO"

View File

@@ -7,9 +7,11 @@ import (
"git.rouggy.com/rouggy/ShackMaster/internal/config" "git.rouggy.com/rouggy/ShackMaster/internal/config"
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius" "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/powergenius"
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius" "git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius" "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/devices/webswitch"
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar" "git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather" "git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
@@ -23,6 +25,8 @@ type DeviceManager struct {
tunerGenius *tunergenius.Client tunerGenius *tunergenius.Client
antennaGenius *antennagenius.Client antennaGenius *antennagenius.Client
rotatorGenius *rotatorgenius.Client rotatorGenius *rotatorgenius.Client
ultrabeam *ultrabeam.Client
flexRadio *flexradio.Client
solarClient *solar.Client solarClient *solar.Client
weatherClient *weather.Client weatherClient *weather.Client
@@ -32,6 +36,15 @@ type DeviceManager struct {
updateInterval time.Duration updateInterval time.Duration
stopChan chan struct{} 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 { type SystemStatus struct {
@@ -40,6 +53,8 @@ type SystemStatus struct {
TunerGenius *tunergenius.Status `json:"tuner_genius"` TunerGenius *tunergenius.Status `json:"tuner_genius"`
AntennaGenius *antennagenius.Status `json:"antenna_genius"` AntennaGenius *antennagenius.Status `json:"antenna_genius"`
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"` RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
FlexRadio *flexradio.Status `json:"flexradio,omitempty"`
Solar *solar.SolarData `json:"solar"` Solar *solar.SolarData `json:"solar"`
Weather *weather.WeatherData `json:"weather"` Weather *weather.WeatherData `json:"weather"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@@ -47,10 +62,14 @@ type SystemStatus struct {
func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager { func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
return &DeviceManager{ return &DeviceManager{
config: cfg, config: cfg,
hub: hub, hub: hub,
updateInterval: 1 * time.Second, // Update status every second updateInterval: 200 * time.Millisecond, // Update status every second
stopChan: make(chan struct{}), 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,67 @@ func (dm *DeviceManager) Initialize() error {
) )
// Initialize Tuner Genius // 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.tunerGenius = tunergenius.New(
dm.config.Devices.TunerGenius.Host, dm.config.Devices.TunerGenius.Host,
dm.config.Devices.TunerGenius.Port, dm.config.Devices.TunerGenius.Port,
) )
// Initialize Antenna Genius // 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.antennaGenius = antennagenius.New(
dm.config.Devices.AntennaGenius.Host, dm.config.Devices.AntennaGenius.Host,
dm.config.Devices.AntennaGenius.Port, dm.config.Devices.AntennaGenius.Port,
) )
// Initialize Rotator Genius // 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.rotatorGenius = rotatorgenius.New(
dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Host,
dm.config.Devices.RotatorGenius.Port, 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,
)
// 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 // Initialize Solar data client
dm.solarClient = solar.New() dm.solarClient = solar.New()
@@ -94,9 +157,52 @@ func (dm *DeviceManager) Initialize() error {
dm.config.Location.Longitude, dm.config.Location.Longitude,
) )
// Start PowerGenius continuous polling // Start device polling in background (non-blocking)
if err := dm.powerGenius.Start(); err != nil { go func() {
log.Printf("Warning: Failed to start PowerGenius polling: %v", err) 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") log.Println("Device manager initialized")
@@ -109,6 +215,74 @@ func (dm *DeviceManager) Start() error {
return nil 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() { func (dm *DeviceManager) Stop() {
log.Println("Stopping device manager...") log.Println("Stopping device manager...")
close(dm.stopChan) close(dm.stopChan)
@@ -126,6 +300,9 @@ func (dm *DeviceManager) Stop() {
if dm.rotatorGenius != nil { if dm.rotatorGenius != nil {
dm.rotatorGenius.Close() dm.rotatorGenius.Close()
} }
if dm.ultrabeam != nil {
dm.ultrabeam.Stop()
}
} }
func (dm *DeviceManager) monitorDevices() { func (dm *DeviceManager) monitorDevices() {
@@ -162,49 +339,131 @@ func (dm *DeviceManager) updateStatus() {
log.Printf("Power Genius error: %v", err) log.Printf("Power Genius error: %v", err)
} }
// // Tuner Genius // Tuner Genius
// if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil { if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
// status.TunerGenius = tgStatus 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
} else { } else {
log.Printf("Solar data error: %v", err) log.Printf("Tuner Genius error: %v", err)
} }
// Weather Data (fetched every 10 minutes, cached) // Antenna Genius
if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil { if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
status.Weather = weatherData status.AntennaGenius = agStatus
} else { } else {
log.Printf("Weather data error: %v", err) log.Printf("Antenna Genius error: %v", err)
} }
// Update cached status // Rotator Genius
dm.statusMu.Lock() if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
dm.lastStatus = status status.RotatorGenius = rgStatus
dm.statusMu.Unlock() } 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: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate)
} else {
log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse)
// 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 +500,18 @@ func (dm *DeviceManager) AntennaGenius() *antennagenius.Client {
func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client { func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
return dm.rotatorGenius 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)
}

View File

@@ -49,24 +49,32 @@ func (s *Server) SetupRoutes() *http.ServeMux {
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff) mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
// Rotator endpoints // 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/cw", s.handleRotatorCW)
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW) mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop) 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 // Tuner endpoints
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate) mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune) mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna) mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
// Antenna Genius endpoints // 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 // Power Genius endpoints
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode) mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
// Static files (will be frontend) // Note: Static files are now served from embedded FS in main.go
mux.Handle("/", http.FileServer(http.Dir("./web/dist")))
return mux return mux
} }
@@ -179,15 +187,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
} }
// Rotator handlers // 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 { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
var req struct { var req struct {
Rotator int `json:"rotator"` Heading int `json:"heading"`
Azimuth int `json:"azimuth"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -195,7 +202,7 @@ func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
return 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -209,13 +216,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
return return
} }
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator")) if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
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 {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -229,13 +230,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
return return
} }
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator")) if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
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 {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -265,7 +260,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
} }
var req struct { var req struct {
Operate bool `json:"operate"` Value int `json:"value"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -273,7 +268,30 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
return 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -295,13 +313,15 @@ func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) {
s.sendJSON(w, map[string]string{"status": "ok"}) s.sendJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) 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 { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
var req struct { var req struct {
Port int `json:"port"`
Antenna int `json:"antenna"` Antenna int `json:"antenna"`
} }
@@ -310,7 +330,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
return 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -318,15 +338,14 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
s.sendJSON(w, map[string]string{"status": "ok"}) s.sendJSON(w, map[string]string{"status": "ok"})
} }
// Antenna Genius handlers func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
var req struct { var req struct {
Radio int `json:"radio"` Port int `json:"port"`
Antenna int `json:"antenna"` Antenna int `json:"antenna"`
} }
@@ -335,7 +354,24 @@ func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
return 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -367,6 +403,113 @@ func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) {
s.sendJSON(w, map[string]string{"status": "ok"}) s.sendJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) 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) sendJSON(w http.ResponseWriter, data interface{}) { func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)

View File

@@ -25,6 +25,8 @@ type DevicesConfig struct {
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"` TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"` AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"` RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
FlexRadio FlexRadioConfig `yaml:"flexradio"`
} }
type WebSwitchConfig struct { type WebSwitchConfig struct {
@@ -51,6 +53,18 @@ type RotatorGeniusConfig struct {
Port int `yaml:"port"` 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 { type WeatherConfig struct {
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"` OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
LightningEnabled bool `yaml:"lightning_enabled"` LightningEnabled bool `yaml:"lightning_enabled"`

View File

@@ -3,157 +3,476 @@ package antennagenius
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"log"
"net" "net"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
) )
type Client struct { type Client struct {
host string host string
port int port int
conn net.Conn 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 { type Status struct {
Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index) PortA *PortStatus `json:"port_a"`
Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index) PortB *PortStatus `json:"port_b"`
Connected bool `json:"connected"` 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 { func New(host string, port int) *Client {
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
stopChan: make(chan struct{}),
} }
} }
func (c *Client) Connect() error { 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) conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect: %w", err) return fmt.Errorf("failed to connect: %w", err)
} }
c.conn = conn c.conn = conn
c.reader = bufio.NewReader(c.conn)
// Read and discard banner
_, _ = c.reader.ReadString('\n')
return nil return nil
} }
func (c *Client) Close() error { func (c *Client) Close() error {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.stopChan != nil {
close(c.stopChan)
}
if c.conn != nil { if c.conn != nil {
return c.conn.Close() return c.conn.Close()
} }
return nil return nil
} }
func (c *Client) sendCommand(cmd string) (string, error) { func (c *Client) Start() error {
if c.conn == nil { if c.running {
if err := c.Connect(); err != nil { return nil
return "", err
}
} }
// Get next command ID from global counter _ = c.Connect()
cmdID := GetGlobalCommandID().GetNextID()
// Format command with ID: C<id>|<command> c.running = true
fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd) go c.pollLoop()
// 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")
}
return nil return nil
} }
// GetRadioAntenna gets which antenna a radio is currently using func (c *Client) pollLoop() {
func (c *Client) GetRadioAntenna(radio int) (int, error) { ticker := time.NewTicker(100 * time.Millisecond)
if radio < 1 || radio > 2 { defer ticker.Stop()
return -1, fmt.Errorf("radio must be 1 or 2")
}
status, err := c.GetStatus() initialized := false
if err != nil {
return -1, err
}
if radio == 1 { for {
return status.Radio1Antenna, nil 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
} }

View File

@@ -0,0 +1,379 @@
package flexradio
import (
"bufio"
"fmt"
"log"
"net"
"strconv"
"strings"
"sync"
"time"
)
type Client struct {
host string
port int
conn net.Conn
reader *bufio.Reader
connMu sync.Mutex // For connection management
writeMu sync.Mutex // For writing to connection (separate from reads)
lastStatus *Status
statusMu sync.RWMutex
cmdSeq int
cmdSeqMu sync.Mutex
running bool
stopChan chan struct{}
// Callbacks
onFrequencyChange func(freqMHz float64)
checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
// Reconnection settings
reconnectInterval time.Duration
}
func New(host string, port int) *Client {
return &Client{
host: host,
port: port,
stopChan: make(chan struct{}),
lastStatus: &Status{
Connected: false,
},
reconnectInterval: 5 * time.Second, // Reconnect every 5 seconds if disconnected
}
}
// 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 {
log.Printf("FlexRadio: Connection failed: %v", err)
return fmt.Errorf("failed to connect: %w", err)
}
c.conn = conn
c.reader = bufio.NewReader(conn)
log.Println("FlexRadio: Connected successfully")
return nil
}
func (c *Client) Start() error {
if c.running {
return nil
}
c.running = true
// Try initial connection but don't fail if it doesn't work
// The messageLoop will handle reconnection
err := c.Connect()
if err != nil {
log.Printf("FlexRadio: Initial connection failed, will retry: %v", err)
} else {
// Update connected status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = true
}
c.statusMu.Unlock()
// Subscribe to slice updates for frequency tracking
c.subscribeToSlices()
}
// Start message listener (handles reconnection)
go c.messageLoop()
return nil
}
func (c *Client) subscribeToSlices() {
log.Println("FlexRadio: Subscribing to slice updates...")
_, err := c.sendCommand("sub slice all")
if err != nil {
log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err)
}
}
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.reader = nil
}
c.connMu.Unlock()
// Update connected status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
}
c.statusMu.Unlock()
}
func (c *Client) getNextSeq() int {
c.cmdSeqMu.Lock()
defer c.cmdSeqMu.Unlock()
c.cmdSeq++
return c.cmdSeq
}
func (c *Client) sendCommand(cmd string) (string, error) {
// Use writeMu instead of connMu to avoid blocking on messageLoop reads
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 {
c.connMu.Lock()
c.conn = nil
c.reader = nil
c.connMu.Unlock()
return "", fmt.Errorf("failed to send command: %w", err)
}
return "", nil
}
func (c *Client) messageLoop() {
log.Println("FlexRadio: Message loop started")
reconnectTicker := time.NewTicker(c.reconnectInterval)
defer reconnectTicker.Stop()
for c.running {
c.connMu.Lock()
isConnected := c.conn != nil && c.reader != nil
c.connMu.Unlock()
if !isConnected {
// Update status to disconnected
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
}
c.statusMu.Unlock()
// Wait for reconnect interval
select {
case <-reconnectTicker.C:
log.Println("FlexRadio: Attempting to reconnect...")
if err := c.Connect(); err != nil {
log.Printf("FlexRadio: Reconnect failed: %v", err)
continue
}
// Successfully reconnected
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = true
}
c.statusMu.Unlock()
// Re-subscribe to slices after reconnection
c.subscribeToSlices()
case <-c.stopChan:
log.Println("FlexRadio: Message loop stopping (stop signal received)")
return
}
continue
}
// Read from connection
c.connMu.Lock()
if c.conn == nil || c.reader == nil {
c.connMu.Unlock()
continue
}
// Set read deadline to allow periodic checks
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() {
// Timeout is expected, continue
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()
// Update connected status
c.statusMu.Lock()
if c.lastStatus != nil {
c.lastStatus.Connected = false
}
c.statusMu.Unlock()
log.Println("FlexRadio: Connection lost, will attempt reconnection...")
continue
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
c.handleMessage(line)
}
log.Println("FlexRadio: Message loop stopped")
}
func (c *Client) handleMessage(msg string) {
// Response format: R<seq>|<status>|<data>
if strings.HasPrefix(msg, "R") {
c.handleResponse(msg)
return
}
// Status format: S<handle>|<key>=<value> ...
if strings.HasPrefix(msg, "S") {
c.handleStatus(msg)
return
}
// Version/handle format: V<version>|H<handle>
if strings.HasPrefix(msg, "V") {
log.Printf("FlexRadio: Version/Handle received: %s", msg)
return
}
// Message format: M<handle>|<message>
if strings.HasPrefix(msg, "M") {
log.Printf("FlexRadio: Message: %s", msg)
return
}
}
func (c *Client) handleResponse(msg string) {
// Format: R<seq>|<status>|<data>
// Example: R21|0|000000F4
parts := strings.SplitN(msg, "|", 3)
if len(parts) < 2 {
return
}
status := parts[1]
if status != "0" {
log.Printf("FlexRadio: Command error: status=%s, message=%s", status, msg)
return
}
}
func (c *Client) handleStatus(msg string) {
// Format: S<handle>|<key>=<value> ...
parts := strings.SplitN(msg, "|", 2)
if len(parts) < 2 {
return
}
data := parts[1]
// Parse key=value pairs
pairs := strings.Fields(data)
statusMap := make(map[string]string)
for _, pair := range pairs {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
statusMap[kv[0]] = kv[1]
}
}
// Check for slice updates (frequency changes)
if strings.Contains(msg, "slice") {
if rfFreq, ok := statusMap["RF_frequency"]; ok {
freq, err := strconv.ParseFloat(rfFreq, 64)
if err == nil {
c.statusMu.Lock()
oldFreq := c.lastStatus.Frequency
c.lastStatus.Frequency = freq
c.statusMu.Unlock()
// Only log significant frequency changes (> 1 kHz)
if oldFreq == 0 || (freq-oldFreq)*(freq-oldFreq) > 0.001 {
log.Printf("FlexRadio: Frequency updated to %.6f MHz", freq)
// Trigger callback for immediate auto-track
if c.onFrequencyChange != nil {
go c.onFrequencyChange(freq)
}
}
}
}
}
}
func (c *Client) GetStatus() (*Status, error) {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{Connected: false}, nil
}
// Create a copy
status := *c.lastStatus
// DON'T lock connMu here - it causes 4-second blocking!
// The messageLoop updates Connected status, and we trust the cached value
return &status, nil
}

View File

@@ -0,0 +1,21 @@
package flexradio
// Status represents the FlexRadio status
type Status struct {
Connected bool `json:"connected"`
InterlockID string `json:"interlock_id"`
InterlockState string `json:"interlock_state"`
Frequency float64 `json:"frequency"` // MHz
Model string `json:"model"`
Serial string `json:"serial"`
Version string `json:"version"`
}
// InterlockState represents possible interlock states
const (
InterlockStateReady = "READY"
InterlockStateNotReady = "NOT_READY"
InterlockStatePTTRequested = "PTT_REQUESTED"
InterlockStateTransmitting = "TRANSMITTING"
InterlockStateUnkeyRequested = "UNKEY_REQUESTED"
)

View File

@@ -23,6 +23,14 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
stopChan chan struct{} stopChan chan struct{}
running bool 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 { type Status struct {
@@ -42,14 +50,19 @@ type Status struct {
BandB string `json:"band_b"` BandB string `json:"band_b"`
FaultPresent bool `json:"fault_present"` FaultPresent bool `json:"fault_present"`
Connected bool `json:"connected"` 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 { func New(host string, port int) *Client {
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
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 // Start begins continuous polling of the device
func (c *Client) Start() error { func (c *Client) Start() error {
if err := c.Connect(); err != nil {
return err
}
if c.running { if c.running {
return nil 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 c.running = true
go c.pollLoop() go c.pollLoop()
@@ -112,10 +128,29 @@ func (c *Client) pollLoop() {
for { for {
select { select {
case <-ticker.C: 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() status, err := c.queryStatus()
if err != nil { if err != nil {
log.Printf("PowerGenius query error: %v", err) // Connection lost, close and retry next tick
// Try to reconnect
c.connMu.Lock() c.connMu.Lock()
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -123,19 +158,104 @@ func (c *Client) pollLoop() {
} }
c.connMu.Unlock() c.connMu.Unlock()
if err := c.Connect(); err != nil { // Mark as disconnected and reset all values
log.Printf("PowerGenius reconnect failed: %v", err) c.statusMu.Lock()
c.lastStatus = &Status{
Connected: false,
} }
c.statusMu.Unlock()
continue 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) // Merge with existing status (spontaneous messages may only update some fields)
c.statusMu.Lock() c.statusMu.Lock()
if c.lastStatus != nil { if c.lastStatus != nil {
// Keep existing values for fields not in the new status // 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 { if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
status.Temperature = c.lastStatus.Temperature status.Temperature = c.lastStatus.Temperature
} }
@@ -304,8 +424,6 @@ func (c *Client) parseStatus(resp string) (*Status, error) {
} }
case "vac": case "vac":
status.Voltage, _ = strconv.ParseFloat(value, 64) status.Voltage, _ = strconv.ParseFloat(value, 64)
case "meffa":
status.Meffa = value
case "vdd": case "vdd":
status.VDD, _ = strconv.ParseFloat(value, 64) status.VDD, _ = strconv.ParseFloat(value, 64)
case "id": case "id":
@@ -370,11 +488,34 @@ func (c *Client) SetFanMode(mode string) error {
"BROADCAST": true, "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) 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) cmd := fmt.Sprintf("setup fanmode=%s", mode)
_, err := c.sendCommand(cmd) _, err := c.sendCommand(cmd)
return err 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
}

View File

@@ -6,235 +6,250 @@ import (
"net" "net"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
) )
type Client struct { type Client struct {
host string host string
port int port int
conn net.Conn conn net.Conn
reader *bufio.Reader
connMu sync.Mutex
lastStatus *Status
statusMu sync.RWMutex
stopChan chan struct{}
running bool
} }
type Status struct { type Status struct {
Rotator1 RotatorData `json:"rotator1"` Heading int `json:"heading"`
Rotator2 RotatorData `json:"rotator2"` TargetHeading int `json:"target_heading"`
Panic bool `json:"panic"` Connected bool `json:"connected"`
}
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"`
} }
func New(host string, port int) *Client { func New(host string, port int) *Client {
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
stopChan: make(chan struct{}),
} }
} }
func (c *Client) Connect() error { 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) conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
if err != nil { if err != nil {
fmt.Printf("RotatorGenius: Connection failed: %v\n", err)
return fmt.Errorf("failed to connect: %w", err) return fmt.Errorf("failed to connect: %w", err)
} }
c.conn = conn c.conn = conn
c.reader = bufio.NewReader(c.conn)
fmt.Println("RotatorGenius: Connected successfully")
return nil return nil
} }
func (c *Client) Close() error { func (c *Client) Close() error {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.stopChan != nil {
close(c.stopChan)
}
if c.conn != nil { if c.conn != nil {
return c.conn.Close() return c.conn.Close()
} }
return nil return nil
} }
func (c *Client) sendCommand(cmd string) (string, error) { func (c *Client) Start() error {
if c.conn == nil { fmt.Println("RotatorGenius Start() called")
if err := c.Connect(); err != nil {
return "", err 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 return status
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
} }
func (c *Client) GetStatus() (*Status, error) { func (c *Client) GetStatus() (*Status, error) {
resp, err := c.sendCommand("|h") c.statusMu.RLock()
if err != nil { defer c.statusMu.RUnlock()
return nil, err
if c.lastStatus == nil {
return &Status{Connected: false}, nil
} }
return parseStatusResponse(resp) return c.lastStatus, nil
} }
func parseStatusResponse(resp string) (*Status, error) { // SetHeading rotates to a specific azimuth
if len(resp) < 80 { func (c *Client) SetHeading(azimuth int) error {
return nil, fmt.Errorf("response too short: %d bytes", len(resp)) cmd := fmt.Sprintf("|A1%d", azimuth)
} return c.sendCommand(cmd)
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
} }
func parseRotatorData(data string) RotatorData { // RotateCW rotates clockwise
rd := RotatorData{} func (c *Client) RotateCW() error {
return c.sendCommand("|P1")
// 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
} }
func (c *Client) MoveToAzimuth(rotator int, azimuth int) error { // RotateCCW rotates counter-clockwise
if rotator < 1 || rotator > 2 { func (c *Client) RotateCCW() error {
return fmt.Errorf("rotator must be 1 or 2") return c.sendCommand("|M1")
}
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
} }
// Stop stops rotation
func (c *Client) Stop() error { func (c *Client) Stop() error {
resp, err := c.sendCommand("|S") return c.sendCommand("|S")
if err != nil {
return err
}
if !strings.HasSuffix(resp, "K") {
return fmt.Errorf("command failed: %s", resp)
}
return nil
} }

View File

@@ -3,64 +3,248 @@ package tunergenius
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"math"
"net" "net"
"strconv"
"strings" "strings"
"sync"
"time" "time"
. "git.rouggy.com/rouggy/ShackMaster/internal/devices" . "git.rouggy.com/rouggy/ShackMaster/internal/devices"
) )
type Client struct { type Client struct {
host string host string
port int port int
conn net.Conn conn net.Conn
connMu sync.Mutex
lastStatus *Status
statusMu sync.RWMutex
stopChan chan struct{}
running bool
} }
type Status struct { type Status struct {
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY PowerForward float64 `json:"power_forward"`
Bypass bool `json:"bypass"` // Bypass mode PowerPeak float64 `json:"power_peak"`
ActiveAntenna int `json:"active_antenna"` // 0=ANT1, 1=ANT2, 2=ANT3 PowerMax float64 `json:"power_max"`
TuningStatus string `json:"tuning_status"` SWR float64 `json:"swr"`
FrequencyA float64 `json:"frequency_a"` PTTA int `json:"ptt_a"`
FrequencyB float64 `json:"frequency_b"` BandA int `json:"band_a"`
C1 int `json:"c1"` FreqA float64 `json:"frequency_a"`
L int `json:"l"` BypassA bool `json:"bypass_a"`
C2 int `json:"c2"` AntA int `json:"antenna_a"`
SWR float64 `json:"swr"` PTTB int `json:"ptt_b"`
Power float64 `json:"power"` BandB int `json:"band_b"`
Temperature float64 `json:"temperature"` FreqB float64 `json:"frequency_b"`
Connected bool `json:"connected"` 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 { func New(host string, port int) *Client {
return &Client{ return &Client{
host: host, host: host,
port: port, port: port,
stopChan: make(chan struct{}),
} }
} }
func (c *Client) Connect() error { 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) conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect: %w", err) return fmt.Errorf("failed to connect: %w", err)
} }
c.conn = conn c.conn = conn
// Read and discard version banner
reader := bufio.NewReader(c.conn)
_, _ = reader.ReadString('\n')
return nil return nil
} }
func (c *Client) Close() error { func (c *Client) Close() error {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.stopChan != nil {
close(c.stopChan)
}
if c.conn != nil { if c.conn != nil {
return c.conn.Close() return c.conn.Close()
} }
return nil return nil
} }
func (c *Client) sendCommand(cmd string) (string, error) { // Start begins continuous polling of the device
if c.conn == nil { func (c *Client) Start() error {
if err := c.Connect(); err != nil { if c.running {
return "", err 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 // Get next command ID from global counter
cmdID := GetGlobalCommandID().GetNextID() cmdID := GetGlobalCommandID().GetNextID()
@@ -87,119 +271,156 @@ func (c *Client) sendCommand(cmd string) (string, error) {
} }
func (c *Client) GetStatus() (*Status, error) { func (c *Client) GetStatus() (*Status, error) {
resp, err := c.sendCommand("status") c.statusMu.RLock()
if err != nil { defer c.statusMu.RUnlock()
return nil, err
if c.lastStatus == nil {
return &Status{Connected: false}, nil
} }
// Parse the response - format will depend on actual device response return c.lastStatus, nil
// This is a placeholder that should be updated based on real response format }
func (c *Client) parseStatus(resp string) (*Status, error) {
status := &Status{ status := &Status{
Connected: true, Connected: true,
} }
// TODO: Parse actual status response from device // Response format: S<id>|status fwd=21.19 peak=21.55 ...
// The response format needs to be determined from real device testing // Extract the data part after "S<id>|status "
// For now, we just check if we got a response idx := strings.Index(resp, "|status ")
_ = resp // Temporary: will be used when we parse the actual response format 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 return status, nil
} }
func (c *Client) SetOperate(operate bool) error { // SetOperate switches between STANDBY (0) and OPERATE (1)
var state int func (c *Client) SetOperate(value int) error {
if operate { if value != 0 && value != 1 {
state = 1 return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
} }
cmd := fmt.Sprintf("operate set=%d", state) cmd := fmt.Sprintf("operate set=%d", value)
resp, err := c.sendCommand(cmd) _, err := c.sendCommand(cmd)
if err != nil { return err
return err
}
// Check if command was successful
if resp == "" {
return fmt.Errorf("empty response from device")
}
return nil
} }
func (c *Client) SetBypass(bypass bool) error { // SetBypass sets BYPASS mode
var state int func (c *Client) SetBypass(value int) error {
if bypass { if value != 0 && value != 1 {
state = 1 return fmt.Errorf("invalid bypass value: %d (must be 0 or 1)", value)
} }
cmd := fmt.Sprintf("bypass set=%d", state) cmd := fmt.Sprintf("bypass set=%d", value)
resp, err := c.sendCommand(cmd) _, err := c.sendCommand(cmd)
if err != nil { return err
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
} }
// AutoTune starts a tuning cycle
func (c *Client) AutoTune() error { func (c *Client) AutoTune() error {
resp, err := c.sendCommand("autotune") _, err := c.sendCommand("autotune")
if err != nil { return err
return err
}
// Check if command was successful
if resp == "" {
return fmt.Errorf("empty response from device")
}
return nil
} }
// TuneRelay adjusts tuning parameters manually // TuneRelay adjusts one tuning parameter by one step
// relay: 0=C1, 1=L, 2=C2 // relay: 0=C1, 1=L, 2=C2
// move: -1 to decrease, 1 to increase // move: -1 (decrease) or 1 (increase)
func (c *Client) TuneRelay(relay int, move int) error { func (c *Client) TuneRelay(relay, move int) error {
if relay < 0 || relay > 2 { 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 { 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) cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move)
resp, err := c.sendCommand(cmd) _, err := c.sendCommand(cmd)
if err != nil { return err
return err
}
// Check if command was successful
if resp == "" {
return fmt.Errorf("empty response from device")
}
return nil
} }

View File

@@ -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
}

View File

@@ -15,7 +15,8 @@ type Client struct {
} }
type Status struct { type Status struct {
Relays []RelayState `json:"relays"` Relays []RelayState `json:"relays"`
Connected bool `json:"connected"`
} }
type RelayState struct { type RelayState struct {
@@ -67,20 +68,56 @@ func (c *Client) TurnOff(relay int) error {
} }
func (c *Client) AllOn() error { func (c *Client) AllOn() error {
for i := 1; i <= 5; i++ { // Sequence for ALL ON:
if err := c.TurnOn(i); err != nil { // 1. Turn on relays 1, 2, 3, 5 immediately
return fmt.Errorf("failed to turn on relay %d: %w", i, err) // 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 return nil
} }
func (c *Client) AllOff() error { func (c *Client) AllOff() error {
for i := 1; i <= 5; i++ { // Sequence for ALL OFF:
if err := c.TurnOff(i); err != nil { // 1. Turn off relay 4 (Flex Radio) immediately
return fmt.Errorf("failed to turn off relay %d: %w", i, err) // 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 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" // Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
status := &Status{ status := &Status{
Relays: make([]RelayState, 0, 5), Relays: make([]RelayState, 0, 5),
Connected: true,
} }
lines := strings.Split(strings.TrimSpace(string(body)), "\n") lines := strings.Split(strings.TrimSpace(string(body)), "\n")

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShackMaster - XV9Q Shack</title> <title>ShackMaster - F4BPO Shack</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">

View File

@@ -2,16 +2,20 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { wsService, connected, systemStatus } from './lib/websocket.js'; import { wsService, connected, systemStatus } from './lib/websocket.js';
import { api } from './lib/api.js'; import { api } from './lib/api.js';
import StatusBanner from './components/StatusBanner.svelte';
import WebSwitch from './components/WebSwitch.svelte'; import WebSwitch from './components/WebSwitch.svelte';
import PowerGenius from './components/PowerGenius.svelte'; import PowerGenius from './components/PowerGenius.svelte';
import TunerGenius from './components/TunerGenius.svelte'; import TunerGenius from './components/TunerGenius.svelte';
import AntennaGenius from './components/AntennaGenius.svelte'; import AntennaGenius from './components/AntennaGenius.svelte';
import RotatorGenius from './components/RotatorGenius.svelte'; import RotatorGenius from './components/RotatorGenius.svelte';
import Ultrabeam from './components/Ultrabeam.svelte';
let status = null; let status = null;
let isConnected = false; let isConnected = false;
let currentTime = new Date(); let currentTime = new Date();
let callsign = 'F4BPO'; // Default let callsign = 'F4BPO'; // Default
let latitude = null;
let longitude = null;
const unsubscribeStatus = systemStatus.subscribe(value => { const unsubscribeStatus = systemStatus.subscribe(value => {
status = value; status = value;
@@ -39,6 +43,10 @@
if (config.callsign) { if (config.callsign) {
callsign = config.callsign; callsign = config.callsign;
} }
if (config.location) {
latitude = config.location.latitude;
longitude = config.location.longitude;
}
} catch (err) { } catch (err) {
console.error('Failed to fetch config:', err); console.error('Failed to fetch config:', err);
} }
@@ -94,10 +102,10 @@
<div class="header-right"> <div class="header-right">
<div class="weather-info"> <div class="weather-info">
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)}m/s</span> <span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)} km/h</span>
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)}m/s</span> <span title="Gust">💨 {weatherData.wind_gust.toFixed(1)} km/h</span>
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)}°C</span> <span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
<span title="Feels like">{weatherData.feels_like.toFixed(1)}°C</span> <span title="Feels like">{weatherData.feels_like.toFixed(1)} °C</span>
</div> </div>
<div class="clock"> <div class="clock">
<span class="time">{formatTime(currentTime)}</span> <span class="time">{formatTime(currentTime)}</span>
@@ -106,17 +114,28 @@
</div> </div>
</header> </header>
<!-- ✅ NOUVEAU : Bandeau de statut avec fréquence FlexRadio et alertes météo -->
<StatusBanner
flexradio={status?.flexradio}
weather={status?.weather}
{latitude}
{longitude}
windWarningThreshold={30}
gustWarningThreshold={50}
/>
<main> <main>
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="row"> <div class="row">
<WebSwitch status={status?.webswitch} /> <WebSwitch status={status?.webswitch} />
<PowerGenius status={status?.power_genius} /> <PowerGenius status={status?.power_genius} />
<TunerGenius status={status?.tuner_genius} /> <TunerGenius status={status?.tuner_genius} flexradio={status?.flexradio} />
</div> </div>
<div class="row"> <div class="row">
<AntennaGenius status={status?.antenna_genius} /> <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>
</div> </div>
</main> </main>
@@ -130,12 +149,13 @@
} }
header { header {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: 16px 24px; padding: 8px 24px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
} }
@@ -176,13 +196,41 @@
} }
.solar-item { .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 { .solar-item .value {
color: var(--accent-teal); font-weight: 700;
font-weight: 500;
margin-left: 4px; 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 { .header-right {
@@ -213,6 +261,7 @@
.date { .date {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
padding-top: 0px;
} }
main { main {

View File

@@ -1,128 +1,438 @@
:root { :root {
--bg-primary: #1a1a1a; /* Modern dark theme inspired by FlexDXCluster */
--bg-secondary: #2a2a2a; --bg-primary: #0a1628;
--bg-card: #333333; --bg-secondary: #1a2332;
--text-primary: #ffffff; --bg-tertiary: #243447;
--text-secondary: #b0b0b0; --bg-hover: #2a3f5f;
--accent-teal: #00bcd4;
--accent-green: #4caf50; --text-primary: #e0e6ed;
--accent-red: #f44336; --text-secondary: #a0aec0;
--text-muted: #718096;
--accent-cyan: #4fc3f7;
--accent-blue: #2196f3; --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; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box;
} }
body { body {
margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
padding: 0; 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; sans-serif;
background-color: var(--bg-primary); -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
font-size: 13px;
line-height: 1.4;
overflow-x: hidden; overflow-x: hidden;
} }
#app { .app {
min-height: 100vh; display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
} }
button { /* ==================== HEADER ==================== */
font-family: inherit; header {
cursor: pointer; height: var(--header-height);
border: none; background: var(--bg-secondary);
outline: none; border-bottom: 1px solid var(--border-color);
transition: all 0.2s ease; display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--spacing-lg);
flex-shrink: 0;
} }
button:hover { .header-left {
transform: translateY(-1px); display: flex;
align-items: center;
gap: var(--spacing-lg);
} }
button:active { .header-left h1 {
transform: translateY(0); font-size: 16px;
font-weight: 600;
color: var(--accent-cyan);
letter-spacing: 0.5px;
} }
input, select { .connection-status {
font-family: inherit; display: flex;
outline: none; align-items: center;
} gap: var(--spacing-sm);
font-size: 12px;
.card { color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
} }
.status-indicator { .status-indicator {
display: inline-block; width: 8px;
width: 12px; height: 8px;
height: 12px;
border-radius: 50%; border-radius: 50%;
margin-right: 8px; background: var(--accent-red);
transition: background 0.3s;
} }
.status-online { .status-indicator.status-online {
background-color: var(--accent-green); background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green); box-shadow: 0 0 8px var(--accent-green);
} }
.status-offline { .header-center {
background-color: var(--text-secondary); display: flex;
gap: var(--spacing-xl);
} }
.btn { .solar-info {
padding: 12px 24px; display: flex;
border-radius: 6px; gap: var(--spacing-md);
font-weight: 500; font-size: 12px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s ease;
} }
.btn-primary { .solar-item {
background: var(--accent-green); color: var(--text-secondary);
color: white;
} }
.btn-primary:hover { .solar-item .value {
background: #45a049; color: var(--accent-cyan);
font-weight: 600;
margin-left: var(--spacing-xs);
} }
.btn-danger { .header-right {
background: var(--accent-red); display: flex;
color: white; align-items: center;
gap: var(--spacing-lg);
} }
.btn-danger:hover { .weather-info {
background: #da190b; display: flex;
} gap: var(--spacing-md);
.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 {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); 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; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 0.5px;
margin-bottom: 4px; 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%;
}
} }

View File

@@ -1,129 +1,339 @@
<script> <script>
import { api } from '../lib/api.js'; import { api } from '../lib/api.js';
export let status; export let status;
$: radio1Antenna = status?.radio1_antenna || 0;
$: radio2Antenna = status?.radio2_antenna || 0;
$: connected = status?.connected || false; $: connected = status?.connected || false;
$: portA = status?.port_a || {};
async function setRadioAntenna(radio, antenna) { $: 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 { 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) { } 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> </script>
<div class="antenna-card card"> <div class="card">
<h2> <div class="card-header">
AG 8X2 <h2>Antenna Genius</h2>
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span> <span class="status-dot" class:disconnected={!connected}></span>
</h2> </div>
<div class="radio-section"> <div class="metrics">
<div class="radio-label">Radio 1 / Radio 2</div> <!-- Radio Sources -->
<div class="sources">
<div class="radio-grid"> <div class="source-item">
<div class="radio-column"> <div class="source-label">{portA.source || 'FLEX'}</div>
<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> </div>
<div class="source-item">
<div class="radio-column"> <div class="source-label">{portB.source || 'FLEX'}</div>
<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> </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>
</div> </div>
<style> <style>
.antenna-card { .card {
min-width: 300px; 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);
}
.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 { 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-size: 14px;
font-weight: 500; font-weight: 600;
margin-bottom: 8px; color: var(--accent-cyan);
text-align: center; 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;
}
/* Sources */
.sources {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
} }
.slot { .source-item {
padding: 16px; padding: 8px;
background: var(--bg-secondary); background: var(--bg-tertiary);
border-radius: 4px;
text-align: center;
}
.source-label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary); 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: 2px solid var(--border-color);
border-radius: 6px; 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; font-weight: 500;
transition: all 0.2s ease; color: var(--text-primary);
} }
.slot:hover { .antenna-ports {
border-color: var(--accent-blue); display: flex;
gap: 6px;
} }
.slot.active { .port-btn {
background: var(--accent-blue); width: 36px;
border-color: var(--accent-blue); 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; 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> </style>

View File

@@ -1,209 +1,447 @@
<script> <script>
import { api } from '../lib/api.js';
export let status; export let status;
$: powerForward = status?.power_forward || 0; $: powerForward = status?.power_forward || 0;
$: powerReflected = status?.power_reflected || 0; $: powerReflected = status?.power_reflected || 0;
$: swr = status?.swr || 1.0; $: swr = status?.swr || 1.0;
$: voltage = status?.voltage || 0;
$: vdd = status?.vdd || 0; $: voltage = status?.voltage || 0;
$: current = status?.current || 0; $: vdd = status?.vdd || 0;
$: peakCurrent = status?.peak_current || 0; $: current = status?.current || 0;
$: temperature = status?.temperature || 0; $: peakCurrent = status?.peak_current || 0;
$: harmonicLoadTemp = status?.harmonic_load_temp || 0; $: temperature = status?.temperature || 0;
$: fanMode = status?.fan_mode || 'CONTEST'; $: harmonicLoadTemp = status?.harmonic_load_temp || 0;
$: state = status?.state || 'IDLE'; $: fanMode = status?.fan_mode || 'CONTEST';
$: bandA = status?.band_a || '0'; $: state = status?.state || 'IDLE';
$: bandB = status?.band_b || '0'; $: bandA = status?.band_a || '0';
$: connected = status?.connected || false; $: bandB = status?.band_b || '0';
$: displayState = state.replace('TRANSMIT_A', 'TRANSMIT').replace('TRANSMIT_B', 'TRANSMIT'); $: connected = status?.connected || false;
$: meffa = status?.meffa || 'STANDBY';
$: 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) { async function setFanMode(mode) {
try { try {
await api.power.setFanMode(mode); await api.power.setFanMode(mode);
} catch (err) { } catch (err) {
console.error('Failed to set fan mode:', err); console.error('Failed to set fan mode:', err);
alert('Failed to set fan mode'); // 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> </script>
<div class="powergenius-card card"> <div class="card">
<h2> <div class="card-header">
PGXL <h2>Power Genius XL</h2>
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span> <div class="header-right">
</h2> <button
class="state-badge"
<div class="status-row"> class:idle={state === 'IDLE'}
<div class="status-label" class:normal={state === 'IDLE'} class:warning={state.includes('TRANSMIT')}> class:transmit={state.includes('TRANSMIT')}
{displayState} on:click={toggleOperate}
>
{displayState}
</button>
<span class="status-dot" class:disconnected={!connected}></span>
</div>
</div> </div>
</div>
<div class="metrics"> <div class="metrics">
<div class="metric"> <!-- Power Display + SWR Side by Side -->
<div class="label">FWD PWR (W)</div> <div class="power-swr-row">
<div class="value">{powerForward.toFixed(1)}</div> <div class="power-section">
<div class="bar"> <div class="power-header">
<div class="bar-fill" style="width: {Math.min(100, (powerForward / 1500) * 100)}%"></div> <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>
<div class="scale">
<span>0</span> <!-- SWR Circle Compact -->
<span>1000</span> <div class="swr-circle-compact" style="--swr-color: {swrColor}">
<span>2000</span> <div class="swr-value-compact">{swr.toFixed(2)}</div>
<div class="swr-label-compact">SWR</div>
</div> </div>
</div> </div>
<div class="metric"> <!-- Temperature Gauges -->
<div class="label">PG XL SWR 1:1.00 use</div> <div class="temp-group">
<div class="value">{swr.toFixed(2)}</div> <div class="temp-item">
</div> <div class="temp-value" style="color: {tempColor}">{temperature.toFixed(1)}°</div>
<div class="temp-label">PA Temp</div>
<div class="metric"> <div class="temp-mini-bar">
<div class="label">Temp / HL Temp</div> <div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
<div class="value">{temperature.toFixed(0)}°C / {harmonicLoadTemp.toFixed(1)}°C</div> </div>
<div class="bar">
<div class="bar-fill" style="width: {(temperature / 80) * 100}%"></div>
</div> </div>
<div class="scale"> <div class="temp-item">
<span>25</span> <div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(1)}°</div>
<span>55</span> <div class="temp-label">HL Temp</div>
<span>80</span> <div class="temp-mini-bar">
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
</div>
</div> </div>
</div> </div>
<div class="metric-row"> <!-- Electrical Parameters -->
<div class="metric small"> <div class="params-grid">
<div class="label">VAC</div> <div class="param-box">
<div class="value">{voltage.toFixed(0)}</div> <div class="param-label">VAC</div>
<div class="param-value">{voltage.toFixed(0)}</div>
</div> </div>
<div class="metric small"> <div class="param-box">
<div class="label">VDD</div> <div class="param-label">VDD</div>
<div class="value">{vdd.toFixed(1)}</div> <div class="param-value">{vdd.toFixed(1)}</div>
</div> </div>
<div class="metric small"> <div class="param-box">
<div class="label">ID peak</div> <div class="param-label">ID Peak</div>
<div class="value">{peakCurrent.toFixed(1)}</div> <div class="param-value">{peakCurrent.toFixed(1)}</div>
</div> </div>
</div> </div>
<div class="fan-speed"> <!-- Band Display -->
<div class="label">Fan Speed</div> <div class="band-display">
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}> <div class="band-item">
<option value="STANDARD">STANDARD</option> <span class="band-label">Band A</span>
<option value="CONTEST">CONTEST</option> <span class="band-value">{bandA}</span>
<option value="BROADCAST">BROADCAST</option> </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> </select>
</div> </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>
</div> </div>
<style> <style>
.powergenius-card { .card {
min-width: 350px; 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);
}
.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 { h2 {
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
margin: 0;
letter-spacing: 0.5px;
}
.header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
font-size: 18px;
font-weight: 500;
color: var(--accent-teal);
} }
.status-label { .state-badge {
display: inline-block; padding: 4px 12px;
padding: 8px 16px; border-radius: 12px;
border-radius: 4px; font-size: 11px;
font-size: 14px; font-weight: 600;
font-weight: 500; text-transform: uppercase;
margin-bottom: 16px; letter-spacing: 0.5px;
cursor: pointer;
border: none;
transition: all 0.2s;
} }
.status-label.normal { .state-badge.idle {
background: var(--accent-green); background: rgba(76, 175, 80, 0.2);
color: white; color: #4caf50;
} }
.status-label.warning { .state-badge.transmit {
background: var(--accent-red); background: rgba(255, 152, 0, 0.2);
color: white; 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 { .metrics {
padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
} }
.metric { /* Power Display */
display: flex; /* Power + SWR Row */
flex-direction: column; .power-swr-row {
gap: 4px;
}
.metric-row {
display: flex; display: flex;
gap: 16px; gap: 16px;
align-items: center;
} }
.metric.small { .power-section {
flex: 1; flex: 1;
background: rgba(15, 23, 42, 0.6);
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(79, 195, 247, 0.2);
} }
.value { .power-header {
font-size: 20px; display: flex;
font-weight: 300; justify-content: space-between;
color: var(--accent-teal); 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%; width: 100%;
height: 8px; height: 28px;
background: #555; background: rgba(0, 0, 0, 0.3);
border-radius: 4px; border-radius: 14px;
overflow: hidden; overflow: hidden;
position: relative;
} }
.bar-fill { .power-bar-fill {
position: relative;
height: 100%; 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; 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; 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; font-size: 10px;
color: var(--text-secondary); color: var(--text-muted);
text-transform: uppercase;
} }
.fan-speed select { .temp-mini-bar {
width: 100%; 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; padding: 8px;
background: var(--bg-secondary); background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px; 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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> </style>

View File

@@ -1,334 +1,552 @@
<script> <script>
import { onMount } from 'svelte';
import { api } from '../lib/api.js'; import { api } from '../lib/api.js';
export let status; export let status;
export let ultrabeam = null;
$: rotator1 = status?.rotator1 || {}; let heading = null; // Start with null instead of 0
$: rotator2 = status?.rotator2 || {}; let connected = false;
$: currentHeading = rotator1.current_azimuth || 0;
$: targetHeading = rotator1.target_azimuth || 0; // Get Ultrabeam direction mode: 0=Normal, 1=180°, 2=Bi-Dir
$: moving = rotator1.moving || 0; $: ultrabeamDirection = ultrabeam?.direction ?? 0;
$: connected = rotator1.connected || false;
// Update heading with detailed logging to debug
let targetInput = currentHeading; $: if (status?.heading !== undefined && status?.heading !== null) {
let canvas; const newHeading = status.heading;
let ctx;
if (heading === null) {
onMount(() => { // First time: accept any value
if (canvas) { heading = newHeading;
ctx = canvas.getContext('2d'); console.log(` ✓ First load, set to ${heading}°`);
drawGlobe(); } 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 {
$: if (ctx && currentHeading !== undefined) { // Normal update
drawGlobe(); heading = newHeading;
}
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');
} }
} }
// 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() { async function rotateCW() {
try { try {
await api.rotator.cw(1); await api.rotator.rotateCW();
} catch (err) { } catch (err) {
console.error('Failed to rotate CW:', err); console.error('Failed to rotate CW:', err);
} }
} }
async function rotateCCW() { async function rotateCCW() {
try { try {
await api.rotator.ccw(1); await api.rotator.rotateCCW();
} catch (err) { } catch (err) {
console.error('Failed to rotate CCW:', err); console.error('Failed to rotate CCW:', err);
} }
} }
async function stop() { async function stop() {
try { try {
localTargetHeading = null; // Clear local target on stop
await api.rotator.stop(); await api.rotator.stop();
} catch (err) { } catch (err) {
console.error('Failed to stop:', err); console.error('Failed to stop:', err);
} }
} }
// Preset directions // Handle click on compass to set heading
const presets = [ async function handleCompassClick(event) {
{ name: 'EU-0', heading: 0 }, const svg = event.currentTarget;
{ name: 'JA-35', heading: 35 }, const rect = svg.getBoundingClientRect();
{ name: 'AS-75', heading: 75 }, const centerX = rect.width / 2;
{ name: 'VK-120', heading: 120 }, const centerY = rect.height / 2;
{ name: 'AF-180', heading: 180 },
{ name: 'SA-230', heading: 230 }, // Get click position relative to center
{ name: 'WI-270', heading: 270 }, const x = event.clientX - rect.left - centerX;
{ name: 'NA-300', heading: 300 } const y = event.clientY - rect.top - centerY;
];
// Calculate angle (0° = North/top, clockwise)
async function gotoPreset(heading) { 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 { try {
await api.rotator.move(1, heading); await api.rotator.setHeading(adjustedHeading);
// Only set local target AFTER successful API call
localTargetHeading = adjustedHeading;
} catch (err) { } catch (err) {
console.error('Failed to move to preset:', err); console.error('Failed to set heading:', err);
} }
} }
</script> </script>
<div class="rotator-card card"> <div class="card">
<h2> <div class="card-header">
ROTATOR GENIUS <h2>Rotator Genius</h2>
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span> <span class="status-dot" class:disconnected={!connected}></span>
</h2>
<div class="heading-display">
CURRENT HEADING: <span class="heading-value">{currentHeading}°</span>
</div> </div>
{#if moving > 0} <div class="metrics">
<div class="moving-indicator"> <!-- Current Heading Display with Compact Controls -->
{moving === 1 ? '↻ ROTATING CW' : '↺ ROTATING CCW'} <div class="heading-controls-row">
</div> <div class="heading-display-compact">
{/if} <div class="heading-label">CURRENT HEADING</div>
<div class="heading-value">
<canvas bind:this={canvas} width="300" height="300"></canvas> {displayHeading}°
{#if hasTarget && activeTargetHeading !== null}
<div class="controls"> <span class="target-indicator">{activeTargetHeading}°</span>
<div class="heading-input"> {/if}
<input </div>
type="number" </div>
min="0"
max="360" <div class="controls-compact">
bind:value={targetInput} <button class="btn-mini ccw" on:click={rotateCCW} title="Rotate Counter-Clockwise">
placeholder="Enter heading"
/> </button>
<button class="btn btn-primary" on:click={moveToHeading}>GO</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> </div>
<div class="rotation-controls"> <!-- Map with Beam -->
<button class="btn btn-secondary" on:click={rotateCCW}> CCW</button> <div class="map-container">
<button class="btn btn-danger" on:click={stop}>STOP</button> <svg viewBox="0 0 300 300" class="map-svg clickable-compass"
<button class="btn btn-secondary" on:click={rotateCW}>CW ↻</button> 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> </div>
</div>
<div class="presets"> <!-- Legend (only show in 180° or Bi-Dir mode) -->
{#each presets as preset} {#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
<button <div class="map-legend">
class="preset-btn" <div class="legend-item">
class:active={Math.abs(currentHeading - preset.heading) < 5} <svg width="30" height="20" viewBox="0 0 30 20">
on:click={() => gotoPreset(preset.heading)} <line x1="5" y1="10" x2="25" y2="10" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="3,3"/>
> </svg>
{preset.name} <span>Physical antenna</span>
</button> </div>
{/each} <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>
</div> </div>
<style> <style>
.rotator-card { .card {
min-width: 350px; 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);
}
.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 { h2 {
display: flex; font-size: 14px;
align-items: center; font-weight: 600;
gap: 8px; color: var(--accent-cyan);
margin-bottom: 16px; margin: 0;
font-size: 18px; letter-spacing: 0.5px;
font-weight: 500;
color: var(--accent-teal);
} }
.heading-display { .status-dot {
font-size: 16px; width: 8px;
font-weight: 500; height: 8px;
text-align: center; border-radius: 50%;
margin-bottom: 8px; background: #4caf50;
padding: 12px; box-shadow: 0 0 8px #4caf50;
background: var(--bg-secondary); }
.status-dot.disconnected {
background: #f44336;
box-shadow: 0 0 8px #f44336;
}
.metrics {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.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-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 { .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; 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 { @keyframes targetPulse {
text-align: center; 0%, 100% { opacity: 0.8; }
padding: 8px; 50% { opacity: 1; }
background: var(--accent-green);
color: white;
border-radius: 4px;
margin-bottom: 12px;
font-weight: 500;
animation: pulse 1.5s ease-in-out infinite;
} }
@keyframes pulse { .map-container {
0%, 100% { opacity: 1; } display: flex;
50% { opacity: 0.7; } justify-content: center;
} padding: 10px;
background: rgba(10, 22, 40, 0.6);
canvas {
display: block;
margin: 16px auto;
background: var(--bg-secondary);
border-radius: 8px; border-radius: 8px;
} }
.controls { .map-legend {
display: flex; display: flex;
flex-direction: column; gap: 20px;
gap: 12px; justify-content: center;
margin-bottom: 16px; padding: 8px;
} margin-top: 8px;
background: rgba(10, 22, 40, 0.4);
.heading-input { border-radius: 6px;
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;
font-size: 12px; font-size: 12px;
font-weight: 500; color: rgba(255, 255, 255, 0.7);
transition: all 0.2s ease;
} }
.preset-btn:hover { .legend-item {
background: #1976d2; display: flex;
transform: translateY(-2px); align-items: center;
gap: 6px;
} }
.preset-btn.active { .map-svg {
background: var(--accent-green); width: 100%;
max-width: 300px;
height: auto;
} }
</style>
.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>

View File

@@ -0,0 +1,625 @@
<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 frequency and mode
$: frequency = flexradio?.frequency || 0;
$: mode = flexradio?.mode || '';
$: txEnabled = flexradio?.tx || false;
$: connected = flexradio?.connected || false;
// 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();
}
// 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);
</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 connected && frequency > 0}
<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}
{#if txEnabled}
<span class="tx-indicator">
TX
</span>
{/if}
{:else}
<span class="no-signal">FlexRadio non connecté</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 non configurée</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">Météo 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; }
}
.no-signal {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
font-style: italic;
}
/* Separator */
.separator {
width: 1px;
height: 30px;
background: rgba(255, 255, 255, 0.2);
}
/* Grayline Section */
.grayline-section {
display: flex;
align-items: center;
gap: 12px;
}
.sun-times {
display: flex;
gap: 12px;
}
.sun-item {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
display: flex;
align-items: center;
gap: 6px;
}
.sun-icon {
flex-shrink: 0;
}
.sunrise-icon {
color: rgba(251, 191, 36, 0.6);
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.4));
}
.sunset-icon {
color: rgba(249, 115, 22, 0.6);
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
}
.grayline-badge {
padding: 5px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
animation: graylinePulse 1.5s ease-in-out infinite;
}
.grayline-badge.sunrise {
background: linear-gradient(135deg, rgba(255, 183, 77, 0.3) 0%, rgba(255, 138, 101, 0.3) 100%);
border: 1px solid rgba(255, 183, 77, 0.6);
color: #ffcc80;
text-shadow: 0 0 10px rgba(255, 183, 77, 0.8);
}
.grayline-badge.sunset {
background: linear-gradient(135deg, rgba(255, 138, 101, 0.3) 0%, rgba(239, 83, 80, 0.3) 100%);
border: 1px solid rgba(255, 138, 101, 0.6);
color: #ffab91;
text-shadow: 0 0 10px rgba(255, 138, 101, 0.8);
}
@keyframes graylinePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.02); }
}
.next-event {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.no-location {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
/* Weather Section */
.weather-section {
display: flex;
align-items: center;
gap: 16px;
}
.alert {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 6px;
animation: alertPulse 2s ease-in-out infinite;
}
.wind-alert {
background: rgba(245, 158, 11, 0.2);
border: 1px solid rgba(245, 158, 11, 0.5);
}
.gust-alert {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
}
@keyframes alertPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.alert-icon {
font-size: 16px;
}
.alert-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
}
.alert-text strong {
color: #fbbf24;
font-weight: 700;
}
.gust-alert .alert-text strong {
color: #f87171;
}
.status-ok {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 6px;
}
.ok-icon {
color: #22c55e;
font-weight: 700;
}
.ok-text {
font-size: 13px;
color: rgba(34, 197, 94, 0.9);
}
/* Responsive */
@media (max-width: 768px) {
.status-banner {
padding: 8px 16px;
gap: 12px;
}
.frequency {
font-size: 22px;
}
.separator {
display: none;
}
.flex-section,
.grayline-section,
.weather-section {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -1,257 +1,440 @@
<script> <script>
import { api } from '../lib/api.js'; import { api } from '../lib/api.js';
export let status; export let status;
$: operate = status?.operate || false; $: powerForward = status?.power_forward || 0;
$: activeAntenna = status?.active_antenna || 0; $: swr = status?.swr || 1.0;
$: tuningStatus = status?.tuning_status || 'READY'; $: tuningStatus = status?.tuning_status || 'READY';
$: frequencyA = status?.frequency_a || 0; $: frequencyA = status?.frequency_a || 0;
$: frequencyB = status?.frequency_b || 0; $: frequencyB = status?.frequency_b || 0;
$: c1 = status?.c1 || 0; $: bypass = status?.bypass || false;
$: l = status?.l || 0; $: state = status?.state || 0;
$: c2 = status?.c2 || 0; $: relayC1 = status?.c1 || 0;
$: relayL = status?.l || 0;
$: relayC2 = status?.c2 || 0;
$: connected = status?.connected || false; $: 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 { try {
await api.tuner.operate(!operate); await api.tuner.autoTune();
} catch (err) {
console.error('Failed to toggle operate:', err);
}
}
async function startTune() {
tuning = true;
try {
await api.tuner.tune();
} catch (err) { } catch (err) {
console.error('Failed to tune:', err); console.error('Failed to tune:', err);
alert('Tuning failed'); // Removed alert popup - check console for errors
} finally {
tuning = false;
} }
} }
async function setAntenna(ant) { async function setBypass(value) {
try { try {
await api.tuner.antenna(ant); await api.tuner.setBypass(value);
} catch (err) { } 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> </script>
<div class="tuner-card card"> <div class="card">
<h2> <div class="card-header">
TGXL <h2>Tuner Genius XL</h2>
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span> <div class="header-right">
</h2> <span class="tuning-badge" class:tuning={tuningStatus === 'TUNING'}>{tuningStatus}</span>
<span class="status-dot" class:disconnected={!connected}></span>
<div class="power-status"> </div>
<div class="label">Power 0.0w</div>
<div class="status-badge">1500</div>
<div class="status-badge">1650</div>
</div> </div>
<div class="tuning-controls"> <div class="metrics">
<div class="tuning-row"> <!-- Power Display + SWR Side by Side -->
<div class="tuning-label">TG XL SWR 1.00 use</div> <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>
<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 <button
class="antenna-btn" class="control-btn operate"
class:active={activeAntenna === 0} class:active={state === 1}
on:click={() => setAntenna(0)} on:click={() => setOperate(state === 1 ? 0 : 1)}
> >
C1 {state === 1 ? 'OPERATE' : 'STANDBY'}
</button> </button>
<button <button
class="antenna-btn" class="control-btn bypass"
class:active={activeAntenna === 1} class:active={bypass}
on:click={() => setAntenna(1)} on:click={() => setBypass(bypass ? 0 : 1)}
> >
L BYPASS
</button>
<button
class="antenna-btn"
class:active={activeAntenna === 2}
on:click={() => setAntenna(2)}
>
C2
</button> </button>
</div> </div>
<div class="tuning-values"> <button class="tune-btn" on:click={autoTune}>
<div class="value-box"> <span class="tune-icon"></span>
<div class="value">{c1}</div> AUTO TUNE
<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> </button>
<button class="btn btn-secondary" disabled>BYPASS</button>
</div> </div>
<button
class="btn btn-danger tune-btn"
disabled={tuning || !operate}
on:click={startTune}
>
{tuning ? 'TUNING...' : 'TUNE'}
</button>
</div> </div>
<style> <style>
.tuner-card { .card {
min-width: 350px; 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);
}
.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 { h2 {
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
margin: 0;
letter-spacing: 0.5px;
}
.header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 16px;
font-size: 18px;
font-weight: 500;
color: var(--accent-teal);
} }
.power-status { .tuning-badge {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.status-badge {
padding: 4px 12px; padding: 4px 12px;
background: var(--bg-secondary); border-radius: 12px;
border: 1px solid var(--border-color); font-size: 11px;
border-radius: 4px; font-weight: 600;
font-size: 12px; text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
} }
.status-badge.tuning { .tuning-badge.tuning {
background: var(--accent-green); background: rgba(255, 152, 0, 0.2);
color: white; color: #ff9800;
animation: pulse 1s infinite;
} }
.tuning-controls { @keyframes pulse {
margin-bottom: 16px; 0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
} }
.tuning-label { .status-dot {
font-size: 12px; width: 8px;
margin-bottom: 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; display: flex;
gap: 8px; flex-direction: column;
margin: 12px 0; gap: 10px;
} }
.antenna-btn { /* Power Display */
flex: 1; /* Power + SWR Row */
padding: 12px; .power-swr-row {
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 {
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-bottom: 16px; align-items: center;
} }
.metric { .power-section {
flex: 1; 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; display: flex;
gap: 12px; justify-content: space-between;
margin-bottom: 12px; align-items: center;
margin-bottom: 10px;
} }
.action-buttons button { .power-label-inline {
flex: 1; 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;
}
.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 { .tune-btn {
width: 100%; 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> </style>

View File

@@ -0,0 +1,562 @@
<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">
<h2>Ultrabeam VL2.3</h2>
<div class="header-right">
<span class="status-dot" class:disconnected={!connected}></span>
</div>
</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, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
border-radius: 16px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(79, 195, 247, 0.2);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
}
h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 500;
color: #4fc3f7;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #4caf50;
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
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;
}
/* 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>

View File

@@ -4,6 +4,7 @@
export let status; export let status;
$: relays = status?.relays || []; $: relays = status?.relays || [];
$: connected = status?.connected || false;
const relayNames = { const relayNames = {
1: 'Power Supply', 1: 'Power Supply',
@@ -51,102 +52,254 @@
} }
</script> </script>
<div class="webswitch-card card"> <div class="card">
<h2> <div class="card-header">
1216RH <h2>WebSwitch</h2>
<span class="status-indicator" class:status-online={relays.length > 0} class:status-offline={relays.length === 0}></span> <span class="status-dot" class:disconnected={!connected}></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> </div>
<div class="controls"> <div class="metrics">
<button class="btn btn-primary" on:click={allOn}>ALL ON</button> <div class="relays">
<button class="btn btn-danger" on:click={allOff}>ALL OFF</button> {#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>
</div> </div>
<style> <style>
.webswitch-card { .card {
min-width: 280px; 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);
} }
h2 { .card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 18px;
font-weight: 500;
color: var(--accent-teal);
}
.relays {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.relay-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 16px;
background: rgba(79, 195, 247, 0.05);
border-bottom: 1px solid #2d3748;
} }
.relay-name { h2 {
font-size: 14px; font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
margin: 0;
letter-spacing: 0.5px;
} }
.relay-toggle { .status-dot {
width: 60px; width: 8px;
height: 32px; height: 8px;
background: #555;
border-radius: 16px;
position: relative;
transition: background 0.3s ease;
}
.relay-toggle.active {
background: var(--accent-green);
}
.toggle-icon {
width: 24px;
height: 24px;
background: white;
border-radius: 50%; border-radius: 50%;
position: absolute; background: #4caf50;
top: 4px; box-shadow: 0 0 8px #4caf50;
left: 4px;
transition: transform 0.3s ease;
} }
.relay-toggle.active .toggle-icon { .status-dot.disconnected {
transform: translateX(28px); background: #f44336;
box-shadow: 0 0 8px #f44336;
} }
.controls { .metrics {
padding: 16px;
display: flex; display: flex;
flex-direction: column;
gap: 12px; gap: 12px;
} }
.controls button { /* Relays */
flex: 1; .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: 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 {
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: all 0.3s;
}
.relay-toggle:hover .toggle-track {
border-color: var(--accent-cyan);
}
.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: 2px;
left: 2px;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.relay-toggle.active .toggle-thumb {
transform: translateX(24px);
}
.relay-toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Controls */
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.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> </style>

View File

@@ -47,23 +47,28 @@ export const api = {
// Tuner // Tuner
tuner: { tuner: {
operate: (operate) => request('/tuner/operate', { setOperate: (value) => request('/tuner/operate', {
method: 'POST', method: 'POST',
body: JSON.stringify({ operate }), body: JSON.stringify({ value }),
}), }),
tune: () => request('/tuner/tune', { method: 'POST' }), setBypass: (value) => request('/tuner/bypass', {
antenna: (antenna) => request('/tuner/antenna', {
method: 'POST', method: 'POST',
body: JSON.stringify({ antenna }), body: JSON.stringify({ value }),
}), }),
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
}, },
// Antenna Genius // Antenna Genius
antenna: { antenna: {
set: (radio, antenna) => request('/antenna/set', { selectAntenna: (port, antenna) => request('/antenna/select', {
method: 'POST', 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 // Power Genius
@@ -72,5 +77,37 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ mode }), 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 }),
}),
}, },
}; };

View File

@@ -28,6 +28,7 @@ class WebSocketService {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.type === 'update') { if (message.type === 'update') {
console.log('System status updated:', message.data);
systemStatus.set(message.data); systemStatus.set(message.data);
lastUpdate.set(new Date(message.timestamp)); lastUpdate.set(new Date(message.timestamp));
} }