Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8884d89e3 | |||
| 5332ab9dc1 | |||
| b8db847343 | |||
| 0cb83157de | |||
| 4f484b0091 | |||
| 6b5508802a | |||
| 51e08d9463 | |||
| 2bec98a080 | |||
| 431c17347d | |||
| 4f9e1e88eb | |||
| 414d802d37 | |||
| cd93f0ea67 | |||
| 3d06dd44d5 | |||
| 9837657dd9 | |||
| 46ee44c6c9 | |||
| bcf58b208b | |||
| 0ce18d87bc | |||
| f172678560 | |||
| 5fd81a641d | |||
| eee3f48569 | |||
| 8de9a0dd87 |
+13
-1
@@ -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{
|
||||||
|
|||||||
+11
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ShackMaster - F4BPO Shack</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<script type="module" crossorigin src="/assets/index-BqlArLJ0.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+23
-13
@@ -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"
|
||||||
+267
-50
@@ -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,27 +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
|
||||||
<<<<<<< HEAD
|
|
||||||
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -98,7 +157,6 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
dm.config.Location.Longitude,
|
dm.config.Location.Longitude,
|
||||||
)
|
)
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Start device polling in background (non-blocking)
|
// Start device polling in background (non-blocking)
|
||||||
go func() {
|
go func() {
|
||||||
if err := dm.powerGenius.Start(); err != nil {
|
if err := dm.powerGenius.Start(); err != nil {
|
||||||
@@ -126,12 +184,26 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Println("RotatorGenius goroutine launched")
|
log.Println("RotatorGenius goroutine launched")
|
||||||
=======
|
|
||||||
// Start PowerGenius continuous polling
|
log.Println("About to launch Ultrabeam goroutine...")
|
||||||
if err := dm.powerGenius.Start(); err != nil {
|
go func() {
|
||||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
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")
|
||||||
}
|
}
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
log.Println("Device manager initialized")
|
log.Println("Device manager initialized")
|
||||||
return nil
|
return nil
|
||||||
@@ -143,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)
|
||||||
@@ -160,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() {
|
||||||
@@ -196,7 +339,6 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
log.Printf("Power Genius error: %v", err)
|
log.Printf("Power Genius error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// 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
|
||||||
@@ -217,51 +359,111 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("Rotator Genius error: %v", err)
|
log.Printf("Rotator Genius error: %v", err)
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
// // Tuner Genius
|
|
||||||
// if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
|
||||||
// status.TunerGenius = tgStatus
|
|
||||||
// } else {
|
|
||||||
// log.Printf("Tuner Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Antenna Genius
|
// Ultrabeam
|
||||||
// if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||||
// status.AntennaGenius = agStatus
|
status.Ultrabeam = ubStatus
|
||||||
// } else {
|
|
||||||
// log.Printf("Antenna Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Rotator Genius
|
// Sync direction with Ultrabeam if user hasn't explicitly set one
|
||||||
// if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
// This prevents auto-track from using wrong direction before user changes it
|
||||||
// status.RotatorGenius = rgStatus
|
if !dm.ultrabeamDirectionSet {
|
||||||
// } else {
|
dm.ultrabeamDirection = ubStatus.Direction
|
||||||
// log.Printf("Rotator Genius error: %v", err)
|
}
|
||||||
// }
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
// 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("Ultrabeam error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weather Data (fetched every 10 minutes, cached)
|
// FlexRadio (use direct cache access to avoid mutex contention)
|
||||||
if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil {
|
if dm.flexRadio != nil {
|
||||||
status.Weather = weatherData
|
// Access lastStatus directly from FlexRadio's internal cache
|
||||||
} else {
|
// The messageLoop updates this in real-time, no need to block on GetStatus
|
||||||
log.Printf("Weather data error: %v", err)
|
frStatus, err := dm.flexRadio.GetStatus()
|
||||||
|
if err == nil && frStatus != nil {
|
||||||
|
status.FlexRadio = frStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cached status
|
// Auto frequency tracking: Update Ultrabeam when radio frequency differs from Ultrabeam
|
||||||
dm.statusMu.Lock()
|
if dm.autoTrackEnabled {
|
||||||
dm.lastStatus = status
|
// TunerGenius tracking (FlexRadio uses immediate callback)
|
||||||
dm.statusMu.Unlock()
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,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)
|
||||||
|
}
|
||||||
|
|||||||
+112
-84
@@ -49,41 +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
|
||||||
<<<<<<< HEAD
|
|
||||||
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
||||||
=======
|
|
||||||
mux.HandleFunc("/api/rotator/move", s.handleRotatorMove)
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
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)
|
||||||
<<<<<<< HEAD
|
|
||||||
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||||
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
||||||
|
|
||||||
// Antenna Genius endpoints
|
// Antenna Genius endpoints
|
||||||
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
||||||
|
mux.HandleFunc("/api/antenna/deselect", s.handleAntennaDeselect)
|
||||||
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
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)
|
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||||
=======
|
|
||||||
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune)
|
|
||||||
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna)
|
|
||||||
|
|
||||||
// Antenna Genius endpoints
|
// Note: Static files are now served from embedded FS in main.go
|
||||||
mux.HandleFunc("/api/antenna/set", s.handleAntennaSet)
|
|
||||||
|
|
||||||
// Power Genius endpoints
|
|
||||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
// Static files (will be frontend)
|
|
||||||
mux.Handle("/", http.FileServer(http.Dir("./web/dist")))
|
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -196,23 +187,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rotator handlers
|
// Rotator handlers
|
||||||
<<<<<<< HEAD
|
|
||||||
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
||||||
=======
|
|
||||||
func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
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 {
|
||||||
<<<<<<< HEAD
|
|
||||||
Heading int `json:"heading"`
|
Heading int `json:"heading"`
|
||||||
=======
|
|
||||||
Rotator int `json:"rotator"`
|
|
||||||
Azimuth int `json:"azimuth"`
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -220,11 +202,7 @@ func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
||||||
=======
|
|
||||||
if err := s.deviceManager.RotatorGenius().MoveToAzimuth(req.Rotator, req.Azimuth); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,17 +216,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
||||||
=======
|
|
||||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
|
||||||
if err != nil || rotator < 1 || rotator > 2 {
|
|
||||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCW(rotator); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,17 +230,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
||||||
=======
|
|
||||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
|
||||||
if err != nil || rotator < 1 || rotator > 2 {
|
|
||||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCCW(rotator); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -302,11 +260,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
<<<<<<< HEAD
|
|
||||||
Value int `json:"value"`
|
Value int `json:"value"`
|
||||||
=======
|
|
||||||
Operate bool `json:"operate"`
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -314,7 +268,6 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -339,9 +292,6 @@ func (s *Server) handleTunerBypass(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
||||||
=======
|
|
||||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Operate); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -363,22 +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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Antenna Genius handlers
|
// Antenna Genius handlers
|
||||||
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
||||||
=======
|
|
||||||
func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
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 {
|
||||||
<<<<<<< HEAD
|
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
Antenna int `json:"antenna"`
|
Antenna int `json:"antenna"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +330,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
||||||
=======
|
|
||||||
if err := s.deviceManager.TunerGenius().ActivateAntenna(req.Antenna); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -399,22 +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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
|
||||||
=======
|
|
||||||
// Antenna Genius handlers
|
|
||||||
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
|
||||||
=======
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Radio int `json:"radio"`
|
Port int `json:"port"`
|
||||||
Antenna int `json:"antenna"`
|
Antenna int `json:"antenna"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,8 +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)
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
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
|
||||||
}
|
}
|
||||||
@@ -456,7 +403,6 @@ 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePowerOperate(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)
|
||||||
@@ -480,8 +426,90 @@ func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
// Ultrabeam handlers
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package antennagenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
<<<<<<< HEAD
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -53,43 +52,17 @@ type Antenna struct {
|
|||||||
RX string `json:"rx"`
|
RX string `json:"rx"`
|
||||||
InBand string `json:"in_band"`
|
InBand string `json:"in_band"`
|
||||||
Hotkey int `json:"hotkey"`
|
Hotkey int `json:"hotkey"`
|
||||||
=======
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
host string
|
|
||||||
port int
|
|
||||||
conn net.Conn
|
|
||||||
}
|
|
||||||
|
|
||||||
type Status struct {
|
|
||||||
Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index)
|
|
||||||
Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index)
|
|
||||||
Connected bool `json:"connected"`
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
<<<<<<< HEAD
|
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
=======
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
<<<<<<< HEAD
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
defer c.connMu.Unlock()
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
@@ -97,26 +70,20 @@ func (c *Client) Connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
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
|
||||||
<<<<<<< HEAD
|
|
||||||
c.reader = bufio.NewReader(c.conn)
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
|
||||||
// Read and discard banner
|
// Read and discard banner
|
||||||
_, _ = c.reader.ReadString('\n')
|
_, _ = c.reader.ReadString('\n')
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
<<<<<<< HEAD
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
defer c.connMu.Unlock()
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
@@ -124,15 +91,12 @@ func (c *Client) Close() error {
|
|||||||
close(c.stopChan)
|
close(c.stopChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
if c.running {
|
if c.running {
|
||||||
return nil
|
return nil
|
||||||
@@ -214,21 +178,22 @@ func (c *Client) pollLoop() {
|
|||||||
|
|
||||||
func (c *Client) initialize() error {
|
func (c *Client) initialize() error {
|
||||||
// Get antenna list
|
// Get antenna list
|
||||||
|
log.Println("AntennaGenius: Getting antenna list...")
|
||||||
antennas, err := c.getAntennaList()
|
antennas, err := c.getAntennaList()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get antenna list: %w", err)
|
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.antennasMu.Lock()
|
||||||
c.antennas = antennas
|
c.antennas = antennas
|
||||||
c.antennasMu.Unlock()
|
c.antennasMu.Unlock()
|
||||||
|
|
||||||
// Subscribe to port updates
|
// Initialize status BEFORE subscribing so parsePortStatus can update it
|
||||||
if err := c.subscribeToPortUpdates(); err != nil {
|
|
||||||
return fmt.Errorf("failed to subscribe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize status
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.lastStatus = &Status{
|
c.lastStatus = &Status{
|
||||||
PortA: &PortStatus{},
|
PortA: &PortStatus{},
|
||||||
@@ -238,6 +203,23 @@ func (c *Client) initialize() error {
|
|||||||
}
|
}
|
||||||
c.statusMu.Unlock()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,46 +281,10 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
|||||||
|
|
||||||
func (c *Client) getAntennaList() ([]Antenna, error) {
|
func (c *Client) getAntennaList() ([]Antenna, error) {
|
||||||
resp, err := c.sendCommand("antenna list")
|
resp, err := c.sendCommand("antenna list")
|
||||||
=======
|
|
||||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
|
||||||
if c.conn == nil {
|
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next command ID from global counter
|
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
|
||||||
|
|
||||||
// Format command with ID: C<id>|<command>
|
|
||||||
fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd)
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
_, err := c.conn.Write([]byte(fullCmd))
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to send command: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read response
|
|
||||||
reader := bufio.NewReader(c.conn)
|
|
||||||
response, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetStatus() (*Status, error) {
|
|
||||||
resp, err := c.sendCommand("status")
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
var antennas []Antenna
|
var antennas []Antenna
|
||||||
|
|
||||||
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
||||||
@@ -405,58 +351,11 @@ func (c *Client) parseAntennaLine(line string) Antenna {
|
|||||||
|
|
||||||
func (c *Client) subscribeToPortUpdates() error {
|
func (c *Client) subscribeToPortUpdates() error {
|
||||||
resp, err := c.sendCommand("sub port all")
|
resp, err := c.sendCommand("sub port all")
|
||||||
=======
|
|
||||||
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)
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("AntennaGenius: Failed to subscribe: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Parse initial port status from subscription response
|
// Parse initial port status from subscription response
|
||||||
// The response may contain S0|port messages with current status
|
// The response may contain S0|port messages with current status
|
||||||
lines := strings.Split(resp, "\n")
|
lines := strings.Split(resp, "\n")
|
||||||
@@ -465,18 +364,12 @@ func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
|||||||
if strings.HasPrefix(line, "S0|port") {
|
if strings.HasPrefix(line, "S0|port") {
|
||||||
c.parsePortStatus(line)
|
c.parsePortStatus(line)
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
// 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")
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Println("AntennaGenius: Subscription complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func (c *Client) parsePortStatus(line string) {
|
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>
|
// 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>
|
||||||
|
|
||||||
@@ -547,7 +440,14 @@ func (c *Client) GetStatus() (*Status, error) {
|
|||||||
return &Status{Connected: false}, nil
|
return &Status{Connected: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.lastStatus, 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
|
// SetAntenna sets the antenna for a specific port
|
||||||
@@ -557,25 +457,22 @@ func (c *Client) SetAntenna(port, antenna int) error {
|
|||||||
return err
|
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
|
// Reboot reboots the device
|
||||||
func (c *Client) Reboot() error {
|
func (c *Client) Reboot() error {
|
||||||
_, err := c.sendCommand("reboot")
|
_, err := c.sendCommand("reboot")
|
||||||
return err
|
return err
|
||||||
=======
|
|
||||||
// GetRadioAntenna gets which antenna a radio is currently using
|
|
||||||
func (c *Client) GetRadioAntenna(radio int) (int, error) {
|
|
||||||
if radio < 1 || radio > 2 {
|
|
||||||
return -1, fmt.Errorf("radio must be 1 or 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := c.GetStatus()
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if radio == 1 {
|
|
||||||
return status.Radio1Antenna, nil
|
|
||||||
}
|
|
||||||
return status.Radio2Antenna, nil
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -3,10 +3,7 @@ package powergenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
"log"
|
"log"
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -26,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 {
|
||||||
@@ -45,17 +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"`
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
// Peak hold for display (internal)
|
||||||
Meffa string `json:"meffa"`
|
displayPower float64
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,24 +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 {
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if c.running {
|
if c.running {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
// Initialize connection tracking
|
||||||
|
c.lastAliveTime = time.Now()
|
||||||
|
|
||||||
// Try to connect, but don't fail if it doesn't work
|
// Try to connect, but don't fail if it doesn't work
|
||||||
// The poll loop will keep trying
|
// The poll loop will keep trying
|
||||||
_ = c.Connect()
|
_ = c.Connect()
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
c.running = true
|
c.running = true
|
||||||
go c.pollLoop()
|
go c.pollLoop()
|
||||||
|
|
||||||
@@ -128,7 +128,6 @@ func (c *Client) pollLoop() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
<<<<<<< HEAD
|
|
||||||
// Try to reconnect if not connected
|
// Try to reconnect if not connected
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
@@ -152,12 +151,6 @@ func (c *Client) pollLoop() {
|
|||||||
status, err := c.queryStatus()
|
status, err := c.queryStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Connection lost, close and retry next tick
|
// Connection lost, close and retry next tick
|
||||||
=======
|
|
||||||
status, err := c.queryStatus()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("PowerGenius query error: %v", err)
|
|
||||||
// Try to reconnect
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
@@ -165,7 +158,6 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Mark as disconnected and reset all values
|
// Mark as disconnected and reset all values
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.lastStatus = &Status{
|
c.lastStatus = &Status{
|
||||||
@@ -178,21 +170,92 @@ func (c *Client) pollLoop() {
|
|||||||
// Mark as connected
|
// Mark as connected
|
||||||
status.Connected = true
|
status.Connected = true
|
||||||
|
|
||||||
=======
|
// Check if device is actually alive (not just TCP connected)
|
||||||
if err := c.Connect(); err != nil {
|
// If voltage is 0 and temperature is 0, device might be temporarily idle
|
||||||
log.Printf("PowerGenius reconnect failed: %v", err)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
@@ -361,11 +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)
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
case "meffa":
|
|
||||||
status.Meffa = value
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
case "vdd":
|
case "vdd":
|
||||||
status.VDD, _ = strconv.ParseFloat(value, 64)
|
status.VDD, _ = strconv.ParseFloat(value, 64)
|
||||||
case "id":
|
case "id":
|
||||||
@@ -430,15 +488,25 @@ 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
|
||||||
}
|
}
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
// SetOperate sets the operate mode
|
// SetOperate sets the operate mode
|
||||||
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
||||||
@@ -451,5 +519,3 @@ func (c *Client) SetOperate(value int) error {
|
|||||||
_, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -25,8 +23,9 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Heading int `json:"heading"`
|
Heading int `json:"heading"`
|
||||||
Connected bool `json:"connected"`
|
TargetHeading int `json:"target_heading"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
@@ -151,18 +150,7 @@ func (c *Client) sendCommand(cmd string) error {
|
|||||||
return fmt.Errorf("not connected")
|
return fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
_, err := c.conn.Write([]byte(cmd))
|
_, err := c.conn.Write([]byte(cmd))
|
||||||
=======
|
|
||||||
// Get next command ID from global counter
|
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
|
||||||
|
|
||||||
// Format command with ID: C<id>|<command>
|
|
||||||
fullCmd := fmt.Sprintf("C%d%s", cmdID, cmd)
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
_, err := c.conn.Write([]byte(fullCmd))
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
c.reader = nil
|
c.reader = nil
|
||||||
@@ -224,6 +212,11 @@ func (c *Client) parseStatus(response string) *Status {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
status.Heading = heading
|
status.Heading = heading
|
||||||
}
|
}
|
||||||
|
targetStr := response[19:22]
|
||||||
|
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||||
|
if err == nil {
|
||||||
|
status.TargetHeading = targetHeading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
<<<<<<< HEAD
|
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -23,11 +22,6 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
running bool
|
running bool
|
||||||
=======
|
|
||||||
host string
|
|
||||||
port int
|
|
||||||
conn net.Conn
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -54,18 +48,17 @@ type Status struct {
|
|||||||
RelayC2 int `json:"c2"`
|
RelayC2 int `json:"c2"`
|
||||||
TuningStatus string `json:"tuning_status"`
|
TuningStatus string `json:"tuning_status"`
|
||||||
Connected bool `json:"connected"`
|
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{
|
||||||
<<<<<<< HEAD
|
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
=======
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +103,6 @@ func (c *Client) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Try to connect, but don't fail if it doesn't work
|
// Try to connect, but don't fail if it doesn't work
|
||||||
// The poll loop will keep trying
|
// The poll loop will keep trying
|
||||||
_ = c.Connect()
|
_ = c.Connect()
|
||||||
@@ -171,6 +163,39 @@ func (c *Client) pollLoop() {
|
|||||||
// Mark as connected
|
// Mark as connected
|
||||||
status.Connected = true
|
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.statusMu.Lock()
|
||||||
c.lastStatus = status
|
c.lastStatus = status
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
@@ -221,8 +246,6 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
|||||||
return "", fmt.Errorf("not connected")
|
return "", fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
// Get next command ID from global counter
|
// Get next command ID from global counter
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
cmdID := GetGlobalCommandID().GetNextID()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -142,12 +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{
|
||||||
<<<<<<< HEAD
|
|
||||||
Relays: make([]RelayState, 0, 5),
|
Relays: make([]RelayState, 0, 5),
|
||||||
Connected: true,
|
Connected: true,
|
||||||
=======
|
|
||||||
Relays: make([]RelayState, 0, 5),
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||||
|
|||||||
@@ -3,11 +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">
|
||||||
<<<<<<< HEAD
|
|
||||||
<title>ShackMaster - F4BPO Shack</title>
|
<title>ShackMaster - F4BPO Shack</title>
|
||||||
=======
|
|
||||||
<title>ShackMaster - XV9Q Shack</title>
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
<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">
|
||||||
|
|||||||
+61
-12
@@ -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 {
|
||||||
|
|||||||
-129
@@ -1,5 +1,4 @@
|
|||||||
:root {
|
:root {
|
||||||
<<<<<<< HEAD
|
|
||||||
/* Modern dark theme inspired by FlexDXCluster */
|
/* Modern dark theme inspired by FlexDXCluster */
|
||||||
--bg-primary: #0a1628;
|
--bg-primary: #0a1628;
|
||||||
--bg-secondary: #1a2332;
|
--bg-secondary: #1a2332;
|
||||||
@@ -436,132 +435,4 @@ select:focus {
|
|||||||
order: 3;
|
order: 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
--bg-primary: #1a1a1a;
|
|
||||||
--bg-secondary: #2a2a2a;
|
|
||||||
--bg-card: #333333;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #b0b0b0;
|
|
||||||
--accent-teal: #00bcd4;
|
|
||||||
--accent-green: #4caf50;
|
|
||||||
--accent-red: #f44336;
|
|
||||||
--accent-blue: #2196f3;
|
|
||||||
--border-color: #444444;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background-color: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select {
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
display: inline-block;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: var(--accent-green);
|
|
||||||
box-shadow: 0 0 8px var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #45a049;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--accent-red);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #da190b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-display {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
@@ -21,41 +20,53 @@
|
|||||||
|
|
||||||
async function selectAntenna(port, antennaNum) {
|
async function selectAntenna(port, antennaNum) {
|
||||||
try {
|
try {
|
||||||
await api.antenna.selectAntenna(port, antennaNum, antennaNum);
|
// 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 select antenna:', err);
|
console.error('Failed to select/deselect antenna:', err);
|
||||||
alert('Failed to select antenna');
|
// 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() {
|
async function reboot() {
|
||||||
if (!confirm('Are you sure you want to reboot the Antenna Genius?')) {
|
if (!confirm('Are you sure you want to reboot the Antenna Genius?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.antenna.reboot();
|
await api.antenna.reboot();
|
||||||
|
console.log('Antenna Genius reboot command sent');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to reboot:', err);
|
console.error('Failed to reboot:', err);
|
||||||
alert('Failed to reboot');
|
// No popup, just log
|
||||||
=======
|
|
||||||
|
|
||||||
export let status;
|
|
||||||
|
|
||||||
$: radio1Antenna = status?.radio1_antenna || 0;
|
|
||||||
$: radio2Antenna = status?.radio2_antenna || 0;
|
|
||||||
$: connected = status?.connected || false;
|
|
||||||
|
|
||||||
async function setRadioAntenna(radio, antenna) {
|
|
||||||
try {
|
|
||||||
await api.antenna.set(radio, antenna);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set antenna:', err);
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Antenna Genius</h2>
|
<h2>Antenna Genius</h2>
|
||||||
@@ -88,29 +99,30 @@
|
|||||||
{#each antennas as antenna}
|
{#each antennas as antenna}
|
||||||
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
||||||
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
||||||
{@const isPortARx = !portA.tx && (portA.rx_ant === antenna.number || portA.tx_ant === antenna.number)}
|
{@const isPortARx = !portA.tx && portA.rx_ant === antenna.number}
|
||||||
{@const isPortBRx = !portB.tx && (portB.rx_ant === antenna.number || portB.tx_ant === antenna.number)}
|
{@const isPortBRx = !portB.tx && portB.rx_ant === antenna.number}
|
||||||
{@const isTx = isPortATx || isPortBTx}
|
{@const isTx = isPortATx || isPortBTx}
|
||||||
{@const isActive = isPortARx || isPortBRx}
|
{@const isActiveA = isPortARx || isPortATx}
|
||||||
|
{@const isActiveB = isPortBRx || isPortBTx}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="antenna-card"
|
class="antenna-card"
|
||||||
class:tx={isTx}
|
class:tx={isTx}
|
||||||
class:active-a={isPortARx}
|
class:active-a={isActiveA}
|
||||||
class:active-b={isPortBRx}
|
class:active-b={isActiveB}
|
||||||
>
|
>
|
||||||
<div class="antenna-name">{antenna.name}</div>
|
<div class="antenna-name">{antenna.name}</div>
|
||||||
<div class="antenna-ports">
|
<div class="antenna-ports">
|
||||||
<button
|
<button
|
||||||
class="port-btn"
|
class="port-btn"
|
||||||
class:active={portA.tx_ant === antenna.number || portA.rx_ant === antenna.number}
|
class:active={isActiveA}
|
||||||
on:click={() => selectAntenna(1, antenna.number)}
|
on:click={() => selectAntenna(1, antenna.number)}
|
||||||
>
|
>
|
||||||
A
|
A
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="port-btn"
|
class="port-btn"
|
||||||
class:active={portB.tx_ant === antenna.number || portB.rx_ant === antenna.number}
|
class:active={isActiveB}
|
||||||
on:click={() => selectAntenna(2, antenna.number)}
|
on:click={() => selectAntenna(2, antenna.number)}
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
@@ -125,53 +137,10 @@
|
|||||||
<span class="reboot-icon">🔄</span>
|
<span class="reboot-icon">🔄</span>
|
||||||
REBOOT
|
REBOOT
|
||||||
</button>
|
</button>
|
||||||
=======
|
|
||||||
<div class="antenna-card card">
|
|
||||||
<h2>
|
|
||||||
AG 8X2
|
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="radio-section">
|
|
||||||
<div class="radio-label">Radio 1 / Radio 2</div>
|
|
||||||
|
|
||||||
<div class="radio-grid">
|
|
||||||
<div class="radio-column">
|
|
||||||
<div class="radio-title">Radio 1</div>
|
|
||||||
<div class="antenna-slots">
|
|
||||||
{#each Array(4) as _, i}
|
|
||||||
<button
|
|
||||||
class="slot"
|
|
||||||
class:active={radio1Antenna === i}
|
|
||||||
on:click={() => setRadioAntenna(1, i)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="radio-column">
|
|
||||||
<div class="radio-title">Radio 2</div>
|
|
||||||
<div class="antenna-slots">
|
|
||||||
{#each Array(4) as _, i}
|
|
||||||
<button
|
|
||||||
class="slot"
|
|
||||||
class:active={radio2Antenna === i}
|
|
||||||
on:click={() => setRadioAntenna(2, i)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
<<<<<<< HEAD
|
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
border: 1px solid #2d3748;
|
border: 1px solid #2d3748;
|
||||||
@@ -278,12 +247,6 @@
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.antenna-card.tx {
|
|
||||||
background: rgba(244, 67, 54, 0.2);
|
|
||||||
border-color: #f44336;
|
|
||||||
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-card.active-a {
|
.antenna-card.active-a {
|
||||||
background: rgba(76, 175, 80, 0.2);
|
background: rgba(76, 175, 80, 0.2);
|
||||||
border-color: #4caf50;
|
border-color: #4caf50;
|
||||||
@@ -296,6 +259,13 @@
|
|||||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
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 {
|
.antenna-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -365,72 +335,5 @@
|
|||||||
|
|
||||||
.reboot-icon {
|
.reboot-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
=======
|
|
||||||
.antenna-card {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-column {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-slots {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot {
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot:hover {
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot.active {
|
|
||||||
background: var(--accent-blue);
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
color: white;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
<<<<<<< HEAD
|
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
@@ -7,6 +6,7 @@
|
|||||||
$: 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;
|
$: voltage = status?.voltage || 0;
|
||||||
$: vdd = status?.vdd || 0;
|
$: vdd = status?.vdd || 0;
|
||||||
$: current = status?.current || 0;
|
$: current = status?.current || 0;
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
await api.power.setOperate(operateValue);
|
await api.power.setOperate(operateValue);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to toggle operate:', err);
|
console.error('Failed to toggle operate:', err);
|
||||||
alert('Failed to toggle operate mode');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -63,54 +63,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<!-- Power Display - Big and Bold -->
|
<!-- Power Display + SWR Side by Side -->
|
||||||
<div class="power-display">
|
<div class="power-swr-row">
|
||||||
<div class="power-main">
|
<div class="power-section">
|
||||||
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
|
<div class="power-header">
|
||||||
<div class="power-label">Forward Power</div>
|
<span class="power-label-inline">Power</span>
|
||||||
</div>
|
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||||
<div class="power-bar">
|
|
||||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
|
||||||
<div class="power-bar-glow"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="power-scale">
|
<div class="power-bar-container">
|
||||||
<span>0</span>
|
<div class="power-bar-bg">
|
||||||
<span>1000</span>
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
<span>2000</span>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SWR Circle Indicator -->
|
<!-- SWR Circle Compact -->
|
||||||
<div class="swr-container">
|
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||||
<div class="swr-circle" style="--swr-color: {swrColor}">
|
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||||
<div class="swr-value">{swr.toFixed(2)}</div>
|
<div class="swr-label-compact">SWR</div>
|
||||||
<div class="swr-label">SWR</div>
|
|
||||||
</div>
|
|
||||||
<div class="swr-status">
|
|
||||||
{#if swr < 1.5}
|
|
||||||
<span class="status-text good">Excellent</span>
|
|
||||||
{:else if swr < 2.0}
|
|
||||||
<span class="status-text ok">Good</span>
|
|
||||||
{:else if swr < 3.0}
|
|
||||||
<span class="status-text warning">Caution</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status-text danger">High!</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temperature Gauges -->
|
<!-- Temperature Gauges -->
|
||||||
<div class="temp-group">
|
<div class="temp-group">
|
||||||
<div class="temp-item">
|
<div class="temp-item">
|
||||||
<div class="temp-value" style="color: {tempColor}">{temperature.toFixed(0)}°</div>
|
<div class="temp-value" style="color: {tempColor}">{temperature.toFixed(1)}°</div>
|
||||||
<div class="temp-label">PA Temp</div>
|
<div class="temp-label">PA Temp</div>
|
||||||
<div class="temp-mini-bar">
|
<div class="temp-mini-bar">
|
||||||
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="temp-item">
|
<div class="temp-item">
|
||||||
<div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(0)}°</div>
|
<div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(1)}°</div>
|
||||||
<div class="temp-label">HL Temp</div>
|
<div class="temp-label">HL Temp</div>
|
||||||
<div class="temp-mini-bar">
|
<div class="temp-mini-bar">
|
||||||
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
||||||
@@ -148,8 +133,8 @@
|
|||||||
|
|
||||||
<!-- Fan Control -->
|
<!-- Fan Control -->
|
||||||
<div class="fan-control">
|
<div class="fan-control">
|
||||||
<label class="control-label">Fan Mode</label>
|
<label for="fan-mode-select" class="control-label">Fan Mode</label>
|
||||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||||
<option value="STANDARD">Standard</option>
|
<option value="STANDARD">Standard</option>
|
||||||
<option value="CONTEST">Contest</option>
|
<option value="CONTEST">Contest</option>
|
||||||
<option value="BROADCAST">Broadcast</option>
|
<option value="BROADCAST">Broadcast</option>
|
||||||
@@ -234,280 +219,100 @@
|
|||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
=======
|
|
||||||
export let status;
|
|
||||||
|
|
||||||
$: powerForward = status?.power_forward || 0;
|
|
||||||
$: powerReflected = status?.power_reflected || 0;
|
|
||||||
$: swr = status?.swr || 1.0;
|
|
||||||
$: voltage = status?.voltage || 0;
|
|
||||||
$: vdd = status?.vdd || 0;
|
|
||||||
$: current = status?.current || 0;
|
|
||||||
$: peakCurrent = status?.peak_current || 0;
|
|
||||||
$: temperature = status?.temperature || 0;
|
|
||||||
$: harmonicLoadTemp = status?.harmonic_load_temp || 0;
|
|
||||||
$: fanMode = status?.fan_mode || 'CONTEST';
|
|
||||||
$: state = status?.state || 'IDLE';
|
|
||||||
$: bandA = status?.band_a || '0';
|
|
||||||
$: bandB = status?.band_b || '0';
|
|
||||||
$: connected = status?.connected || false;
|
|
||||||
$: displayState = state.replace('TRANSMIT_A', 'TRANSMIT').replace('TRANSMIT_B', 'TRANSMIT');
|
|
||||||
$: meffa = status?.meffa || 'STANDBY';
|
|
||||||
|
|
||||||
async function setFanMode(mode) {
|
|
||||||
try {
|
|
||||||
await api.power.setFanMode(mode);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set fan mode:', err);
|
|
||||||
alert('Failed to set fan mode');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="powergenius-card card">
|
|
||||||
<h2>
|
|
||||||
PGXL
|
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="status-label" class:normal={state === 'IDLE'} class:warning={state.includes('TRANSMIT')}>
|
|
||||||
{displayState}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metrics">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">FWD PWR (W)</div>
|
|
||||||
<div class="value">{powerForward.toFixed(1)}</div>
|
|
||||||
<div class="bar">
|
|
||||||
<div class="bar-fill" style="width: {Math.min(100, (powerForward / 1500) * 100)}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="scale">
|
|
||||||
<span>0</span>
|
|
||||||
<span>1000</span>
|
|
||||||
<span>2000</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">PG XL SWR 1:1.00 use</div>
|
|
||||||
<div class="value">{swr.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Temp / HL Temp</div>
|
|
||||||
<div class="value">{temperature.toFixed(0)}°C / {harmonicLoadTemp.toFixed(1)}°C</div>
|
|
||||||
<div class="bar">
|
|
||||||
<div class="bar-fill" style="width: {(temperature / 80) * 100}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="scale">
|
|
||||||
<span>25</span>
|
|
||||||
<span>55</span>
|
|
||||||
<span>80</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-row">
|
|
||||||
<div class="metric small">
|
|
||||||
<div class="label">VAC</div>
|
|
||||||
<div class="value">{voltage.toFixed(0)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric small">
|
|
||||||
<div class="label">VDD</div>
|
|
||||||
<div class="value">{vdd.toFixed(1)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric small">
|
|
||||||
<div class="label">ID peak</div>
|
|
||||||
<div class="value">{peakCurrent.toFixed(1)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fan-speed">
|
|
||||||
<div class="label">Fan Speed</div>
|
|
||||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
|
||||||
<option value="STANDARD">STANDARD</option>
|
|
||||||
<option value="CONTEST">CONTEST</option>
|
|
||||||
<option value="BROADCAST">BROADCAST</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="band-info">
|
|
||||||
<div class="label">Band A</div>
|
|
||||||
<div class="value">{bandA}</div>
|
|
||||||
</div>
|
|
||||||
<div class="band-info">
|
|
||||||
<div class="label">Band B</div>
|
|
||||||
<div class="value">{bandB}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.powergenius-card {
|
|
||||||
min-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label.normal {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label.warning {
|
|
||||||
background: var(--accent-red);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
/* Power Display */
|
/* Power Display */
|
||||||
.power-display {
|
/* Power + SWR Row */
|
||||||
|
.power-swr-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-main {
|
.power-section {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value {
|
.power-header {
|
||||||
font-size: 48px;
|
display: flex;
|
||||||
font-weight: 200;
|
justify-content: space-between;
|
||||||
color: var(--accent-cyan);
|
align-items: center;
|
||||||
line-height: 1;
|
margin-bottom: 10px;
|
||||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value .unit {
|
.power-label-inline {
|
||||||
font-size: 24px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar {
|
.power-value-inline {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 8px;
|
}
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
.power-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-fill {
|
.power-bar-fill {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||||
border-radius: 4px;
|
border-radius: 14px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-glow {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-scale {
|
.swr-circle-compact {
|
||||||
display: flex;
|
width: 90px;
|
||||||
justify-content: space-between;
|
height: 90px;
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SWR Circle */
|
|
||||||
.swr-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swr-circle {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||||
border: 3px solid var(--swr-color);
|
border: 4px solid var(--swr-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 0 20px var(--swr-color);
|
box-shadow: 0 0 25px var(--swr-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-value {
|
.swr-value-compact {
|
||||||
font-size: 24px;
|
font-size: 28px;
|
||||||
font-weight: 300;
|
font-weight: 700;
|
||||||
color: var(--swr-color);
|
color: var(--swr-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-label {
|
.swr-label-compact {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-status {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text.good { color: #4caf50; }
|
|
||||||
.status-text.ok { color: #ffc107; }
|
|
||||||
.status-text.warning { color: #ff9800; }
|
|
||||||
.status-text.danger { color: #f44336; }
|
|
||||||
|
|
||||||
/* Temperature */
|
/* Temperature */
|
||||||
.temp-group {
|
.temp-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -572,7 +377,7 @@ async function setFanMode(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.param-value {
|
.param-value {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -589,65 +394,10 @@ async function setFanMode(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.band-item {
|
.band-item {
|
||||||
=======
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric.small {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background: #555;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-red));
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scale {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fan-speed select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.band-info {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
.band-label {
|
.band-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -694,6 +444,4 @@ async function setFanMode(mode) {
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@
|
|||||||
await api.tuner.autoTune();
|
await api.tuner.autoTune();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to tune:', err);
|
console.error('Failed to tune:', err);
|
||||||
alert('Failed to start tuning');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
await api.tuner.setBypass(value);
|
await api.tuner.setBypass(value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set bypass:', err);
|
console.error('Failed to set bypass:', err);
|
||||||
alert('Failed to set bypass');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,54 +42,11 @@
|
|||||||
await api.tuner.setOperate(value);
|
await api.tuner.setOperate(value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set operate:', err);
|
console.error('Failed to set operate:', err);
|
||||||
alert('Failed to set operate');
|
// Removed alert popup - check console for errors
|
||||||
=======
|
|
||||||
|
|
||||||
export let status;
|
|
||||||
|
|
||||||
$: operate = status?.operate || false;
|
|
||||||
$: activeAntenna = status?.active_antenna || 0;
|
|
||||||
$: tuningStatus = status?.tuning_status || 'READY';
|
|
||||||
$: frequencyA = status?.frequency_a || 0;
|
|
||||||
$: frequencyB = status?.frequency_b || 0;
|
|
||||||
$: c1 = status?.c1 || 0;
|
|
||||||
$: l = status?.l || 0;
|
|
||||||
$: c2 = status?.c2 || 0;
|
|
||||||
$: connected = status?.connected || false;
|
|
||||||
|
|
||||||
let tuning = false;
|
|
||||||
|
|
||||||
async function toggleOperate() {
|
|
||||||
try {
|
|
||||||
await api.tuner.operate(!operate);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle operate:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTune() {
|
|
||||||
tuning = true;
|
|
||||||
try {
|
|
||||||
await api.tuner.tune();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to tune:', err);
|
|
||||||
alert('Tuning failed');
|
|
||||||
} finally {
|
|
||||||
tuning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAntenna(ant) {
|
|
||||||
try {
|
|
||||||
await api.tuner.antenna(ant);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set antenna:', err);
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Tuner Genius XL</h2>
|
<h2>Tuner Genius XL</h2>
|
||||||
@@ -101,40 +57,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<!-- Power Display -->
|
<!-- Power Display + SWR Side by Side -->
|
||||||
<div class="power-display">
|
<div class="power-swr-row">
|
||||||
<div class="power-main">
|
<div class="power-section">
|
||||||
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
|
<div class="power-header">
|
||||||
<div class="power-label">Forward Power</div>
|
<span class="power-label-inline">Power</span>
|
||||||
</div>
|
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||||
<div class="power-bar">
|
|
||||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
|
||||||
<div class="power-bar-glow"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="power-scale">
|
<div class="power-bar-container">
|
||||||
<span>0</span>
|
<div class="power-bar-bg">
|
||||||
<span>1000</span>
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
<span>2000</span>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SWR Circle -->
|
<!-- SWR Circle Compact -->
|
||||||
<div class="swr-container">
|
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||||
<div class="swr-circle" style="--swr-color: {swrColor}">
|
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||||
<div class="swr-value">{swr.toFixed(2)}</div>
|
<div class="swr-label-compact">SWR</div>
|
||||||
<div class="swr-label">SWR</div>
|
|
||||||
</div>
|
|
||||||
<div class="swr-status">
|
|
||||||
{#if swr < 1.5}
|
|
||||||
<span class="status-text good">Excellent</span>
|
|
||||||
{:else if swr < 2.0}
|
|
||||||
<span class="status-text ok">Good</span>
|
|
||||||
{:else if swr < 3.0}
|
|
||||||
<span class="status-text warning">Caution</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status-text danger">High!</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -263,128 +204,106 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Power Display */
|
/* Power Display */
|
||||||
.power-display {
|
/* Power + SWR Row */
|
||||||
|
.power-swr-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-main {
|
.power-section {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value {
|
.power-header {
|
||||||
font-size: 48px;
|
display: flex;
|
||||||
font-weight: 200;
|
justify-content: space-between;
|
||||||
color: var(--accent-cyan);
|
align-items: center;
|
||||||
line-height: 1;
|
margin-bottom: 10px;
|
||||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value .unit {
|
.power-label-inline {
|
||||||
font-size: 24px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar {
|
.power-value-inline {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 8px;
|
}
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
.power-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-fill {
|
.power-bar-fill {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||||
border-radius: 4px;
|
border-radius: 14px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-glow {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-scale {
|
.swr-circle-compact {
|
||||||
display: flex;
|
width: 90px;
|
||||||
justify-content: space-between;
|
height: 90px;
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SWR Circle */
|
|
||||||
.swr-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swr-circle {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||||
border: 3px solid var(--swr-color);
|
border: 4px solid var(--swr-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 0 20px var(--swr-color);
|
box-shadow: 0 0 25px var(--swr-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-value {
|
.swr-value-compact {
|
||||||
font-size: 24px;
|
font-size: 28px;
|
||||||
font-weight: 300;
|
font-weight: 700;
|
||||||
color: var(--swr-color);
|
color: var(--swr-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-label {
|
.swr-label-compact {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-status {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
/* SWR Circle */
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text.good { color: #4caf50; }
|
|
||||||
.status-text.ok { color: #ffc107; }
|
|
||||||
.status-text.warning { color: #ff9800; }
|
|
||||||
.status-text.danger { color: #f44336; }
|
|
||||||
|
|
||||||
/* Capacitors */
|
/* Capacitors */
|
||||||
.capacitors {
|
.capacitors {
|
||||||
@@ -405,7 +324,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cap-value {
|
.cap-value {
|
||||||
font-size: 32px;
|
font-size: 20px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||||
@@ -484,218 +403,10 @@
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
color: #000;
|
color: #000;
|
||||||
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||||
=======
|
|
||||||
<div class="tuner-card card">
|
|
||||||
<h2>
|
|
||||||
TGXL
|
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="power-status">
|
|
||||||
<div class="label">Power 0.0w</div>
|
|
||||||
<div class="status-badge">1500</div>
|
|
||||||
<div class="status-badge">1650</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tuning-controls">
|
|
||||||
<div class="tuning-row">
|
|
||||||
<div class="tuning-label">TG XL SWR 1.00 use</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="antenna-buttons">
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 0}
|
|
||||||
on:click={() => setAntenna(0)}
|
|
||||||
>
|
|
||||||
C1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 1}
|
|
||||||
on:click={() => setAntenna(1)}
|
|
||||||
>
|
|
||||||
L
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 2}
|
|
||||||
on:click={() => setAntenna(2)}
|
|
||||||
>
|
|
||||||
C2
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tuning-values">
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{c1}</div>
|
|
||||||
<div class="label">C1</div>
|
|
||||||
</div>
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{l}</div>
|
|
||||||
<div class="label">L</div>
|
|
||||||
</div>
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{c2}</div>
|
|
||||||
<div class="label">C2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Tuning Status</div>
|
|
||||||
<div class="status-badge" class:tuning={tuningStatus === 'TUNING'}>
|
|
||||||
{tuningStatus}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="frequency-row">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Frequency A</div>
|
|
||||||
<div class="value-display">{(frequencyA / 1000).toFixed(3)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Frequency B</div>
|
|
||||||
<div class="value-display">{(frequencyB / 1000).toFixed(3)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
class:btn-primary={!operate}
|
|
||||||
class:btn-danger={operate}
|
|
||||||
on:click={toggleOperate}
|
|
||||||
>
|
|
||||||
{operate ? 'STANDBY' : 'OPERATE'}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" disabled>BYPASS</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-danger tune-btn"
|
|
||||||
disabled={tuning || !operate}
|
|
||||||
on:click={startTune}
|
|
||||||
>
|
|
||||||
{tuning ? 'TUNING...' : 'TUNE'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tuner-card {
|
|
||||||
min-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-status {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.tuning {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-controls {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-label {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-btn.active {
|
|
||||||
background: var(--accent-blue);
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-values {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-box {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-box .value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequency-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons button {
|
|
||||||
flex: 1;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tune-btn {
|
.tune-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
<<<<<<< HEAD
|
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -724,8 +435,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tune-icon {
|
.tune-icon {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -4,10 +4,7 @@
|
|||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
$: relays = status?.relays || [];
|
$: relays = status?.relays || [];
|
||||||
<<<<<<< HEAD
|
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
const relayNames = {
|
const relayNames = {
|
||||||
1: 'Power Supply',
|
1: 'Power Supply',
|
||||||
@@ -55,7 +52,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>WebSwitch</h2>
|
<h2>WebSwitch</h2>
|
||||||
@@ -99,40 +95,10 @@
|
|||||||
ALL OFF
|
ALL OFF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
=======
|
|
||||||
<div class="webswitch-card card">
|
|
||||||
<h2>
|
|
||||||
1216RH
|
|
||||||
<span class="status-indicator" class:status-online={relays.length > 0} class:status-offline={relays.length === 0}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="relays">
|
|
||||||
{#each [1, 2, 3, 4, 5] as relayNum}
|
|
||||||
{@const relay = relays.find(r => r.number === relayNum)}
|
|
||||||
{@const isOn = relay?.state || false}
|
|
||||||
<div class="relay-row">
|
|
||||||
<span class="relay-name">{relayNames[relayNum]}</span>
|
|
||||||
<button
|
|
||||||
class="relay-toggle"
|
|
||||||
class:active={isOn}
|
|
||||||
disabled={loading[relayNum]}
|
|
||||||
on:click={() => toggleRelay(relayNum)}
|
|
||||||
>
|
|
||||||
<div class="toggle-icon"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button class="btn btn-primary" on:click={allOn}>ALL ON</button>
|
|
||||||
<button class="btn btn-danger" on:click={allOff}>ALL OFF</button>
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
<<<<<<< HEAD
|
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
border: 1px solid #2d3748;
|
border: 1px solid #2d3748;
|
||||||
@@ -335,73 +301,5 @@
|
|||||||
|
|
||||||
.all-off:hover {
|
.all-off:hover {
|
||||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||||
=======
|
|
||||||
.webswitch-card {
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relays {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-name {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-toggle {
|
|
||||||
width: 60px;
|
|
||||||
height: 32px;
|
|
||||||
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%;
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
left: 4px;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-toggle.active .toggle-icon {
|
|
||||||
transform: translateX(28px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button {
|
|
||||||
flex: 1;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+20
-21
@@ -47,7 +47,6 @@ export const api = {
|
|||||||
|
|
||||||
// Tuner
|
// Tuner
|
||||||
tuner: {
|
tuner: {
|
||||||
<<<<<<< HEAD
|
|
||||||
setOperate: (value) => request('/tuner/operate', {
|
setOperate: (value) => request('/tuner/operate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
@@ -57,33 +56,19 @@ export const api = {
|
|||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
}),
|
}),
|
||||||
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
||||||
=======
|
|
||||||
operate: (operate) => request('/tuner/operate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ operate }),
|
|
||||||
}),
|
|
||||||
tune: () => request('/tuner/tune', { method: 'POST' }),
|
|
||||||
antenna: (antenna) => request('/tuner/antenna', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ antenna }),
|
|
||||||
}),
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Antenna Genius
|
// Antenna Genius
|
||||||
antenna: {
|
antenna: {
|
||||||
<<<<<<< HEAD
|
|
||||||
selectAntenna: (port, antenna) => request('/antenna/select', {
|
selectAntenna: (port, antenna) => request('/antenna/select', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ port, antenna }),
|
body: JSON.stringify({ port, antenna }),
|
||||||
}),
|
}),
|
||||||
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
deselectAntenna: (port, antenna) => request('/antenna/deselect', {
|
||||||
=======
|
|
||||||
set: (radio, antenna) => request('/antenna/set', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ radio, antenna }),
|
body: JSON.stringify({ port, antenna }),
|
||||||
}),
|
}),
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Power Genius
|
// Power Genius
|
||||||
@@ -92,7 +77,6 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mode }),
|
body: JSON.stringify({ mode }),
|
||||||
}),
|
}),
|
||||||
<<<<<<< HEAD
|
|
||||||
setOperate: (value) => request('/power/operate', {
|
setOperate: (value) => request('/power/operate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
@@ -108,7 +92,22 @@ export const api = {
|
|||||||
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
||||||
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||||
stop: () => request('/rotator/stop', { method: 'POST' }),
|
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||||
=======
|
},
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
// 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 }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -28,10 +28,7 @@ class WebSocketService {
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
if (message.type === 'update') {
|
if (message.type === 'update') {
|
||||||
<<<<<<< HEAD
|
|
||||||
console.log('System status updated:', message.data);
|
console.log('System status updated:', message.data);
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
systemStatus.set(message.data);
|
systemStatus.set(message.data);
|
||||||
lastUpdate.set(new Date(message.timestamp));
|
lastUpdate.set(new Date(message.timestamp));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user