Compare commits
32 Commits
4ab192418e
...
flexradio/
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2dc76d55 | |||
| 5ced01c010 | |||
| 30688ad644 | |||
| 3e169fe615 | |||
| 21db2addff | |||
| 130efeee83 | |||
| 4eeec6bdf6 | |||
| de3fda2648 | |||
| c6ceeb103b | |||
| b8884d89e3 | |||
| 5332ab9dc1 | |||
| b8db847343 | |||
| 0cb83157de | |||
| 4f484b0091 | |||
| 6b5508802a | |||
| 51e08d9463 | |||
| 2bec98a080 | |||
| 431c17347d | |||
| 4f9e1e88eb | |||
| 414d802d37 | |||
| cd93f0ea67 | |||
| 3d06dd44d5 | |||
| 9837657dd9 | |||
| 46ee44c6c9 | |||
| bcf58b208b | |||
| 0ce18d87bc | |||
| f172678560 | |||
| 5fd81a641d | |||
| eee3f48569 | |||
| 8de9a0dd87 | |||
| bceac40518 | |||
| ac99f291a7 |
@@ -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
cmd/server/web/dist/assets/index-8_72Rq0c.js
vendored
Normal file
11
cmd/server/web/dist/assets/index-8_72Rq0c.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-BG0pu9jt.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-BG0pu9jt.css
vendored
Normal file
File diff suppressed because one or more lines are too long
11
cmd/server/web/dist/assets/index-Byafb7Nq.js
vendored
Normal file
11
cmd/server/web/dist/assets/index-Byafb7Nq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
cmd/server/web/dist/assets/index-CIxsYy1W.js
vendored
Normal file
8
cmd/server/web/dist/assets/index-CIxsYy1W.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
cmd/server/web/dist/assets/index-CQrlLShx.js
vendored
Normal file
8
cmd/server/web/dist/assets/index-CQrlLShx.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
cmd/server/web/dist/assets/index-DHBARw4b.js
vendored
Normal file
7
cmd/server/web/dist/assets/index-DHBARw4b.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
cmd/server/web/dist/assets/index-DIrlWzGj.js
vendored
Normal file
7
cmd/server/web/dist/assets/index-DIrlWzGj.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
cmd/server/web/dist/assets/index-DY7RBkJT.js
vendored
Normal file
8
cmd/server/web/dist/assets/index-DY7RBkJT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-DvnnYzjx.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-DvnnYzjx.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-Ml--d1Bc.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-Ml--d1Bc.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-PFp0U9rZ.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-PFp0U9rZ.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-dhCTx3KU.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-dhCTx3KU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
11
cmd/server/web/dist/assets/index-ghAyyhf_.js
vendored
Normal file
11
cmd/server/web/dist/assets/index-ghAyyhf_.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/server/web/dist/assets/index-oYZfaWiS.css
vendored
Normal file
1
cmd/server/web/dist/assets/index-oYZfaWiS.css
vendored
Normal file
File diff suppressed because one or more lines are too long
17
cmd/server/web/dist/index.html
vendored
Normal file
17
cmd/server/web/dist/index.html
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ShackMaster - F4BPO Shack</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<script type="module" crossorigin src="/assets/index-BqlArLJ0.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
@@ -7,9 +7,11 @@ import (
|
|||||||
|
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius"
|
||||||
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/flexradio"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
||||||
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/ultrabeam"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
|
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
|
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
|
||||||
@@ -23,6 +25,8 @@ type DeviceManager struct {
|
|||||||
tunerGenius *tunergenius.Client
|
tunerGenius *tunergenius.Client
|
||||||
antennaGenius *antennagenius.Client
|
antennaGenius *antennagenius.Client
|
||||||
rotatorGenius *rotatorgenius.Client
|
rotatorGenius *rotatorgenius.Client
|
||||||
|
ultrabeam *ultrabeam.Client
|
||||||
|
flexRadio *flexradio.Client
|
||||||
solarClient *solar.Client
|
solarClient *solar.Client
|
||||||
weatherClient *weather.Client
|
weatherClient *weather.Client
|
||||||
|
|
||||||
@@ -32,6 +36,15 @@ type DeviceManager struct {
|
|||||||
|
|
||||||
updateInterval time.Duration
|
updateInterval time.Duration
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|
||||||
|
// Auto frequency tracking
|
||||||
|
freqThreshold int // Threshold for triggering update (Hz)
|
||||||
|
autoTrackEnabled bool
|
||||||
|
ultrabeamDirection int // User-selected direction (0=normal, 1=180, 2=bi-dir)
|
||||||
|
ultrabeamDirectionSet bool // True if user has explicitly set a direction
|
||||||
|
lastFreqUpdateTime time.Time // Last time we sent frequency update
|
||||||
|
freqUpdateCooldown time.Duration // Minimum time between updates
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemStatus struct {
|
type SystemStatus struct {
|
||||||
@@ -40,6 +53,8 @@ type SystemStatus struct {
|
|||||||
TunerGenius *tunergenius.Status `json:"tuner_genius"`
|
TunerGenius *tunergenius.Status `json:"tuner_genius"`
|
||||||
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
||||||
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
||||||
|
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
|
||||||
|
FlexRadio *flexradio.Status `json:"flexradio,omitempty"`
|
||||||
Solar *solar.SolarData `json:"solar"`
|
Solar *solar.SolarData `json:"solar"`
|
||||||
Weather *weather.WeatherData `json:"weather"`
|
Weather *weather.WeatherData `json:"weather"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -47,10 +62,14 @@ type SystemStatus struct {
|
|||||||
|
|
||||||
func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
|
func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
|
||||||
return &DeviceManager{
|
return &DeviceManager{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
updateInterval: 1 * time.Second, // Update status every second
|
updateInterval: 200 * time.Millisecond, // Update status every second
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
freqThreshold: 25000, // 25 kHz default
|
||||||
|
autoTrackEnabled: true, // Enabled by default
|
||||||
|
ultrabeamDirection: 0, // Normal direction by default
|
||||||
|
freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,23 +86,70 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Initialize Tuner Genius
|
// Initialize Tuner Genius
|
||||||
|
log.Printf("Initializing TunerGenius: host=%s port=%d", dm.config.Devices.TunerGenius.Host, dm.config.Devices.TunerGenius.Port)
|
||||||
dm.tunerGenius = tunergenius.New(
|
dm.tunerGenius = tunergenius.New(
|
||||||
dm.config.Devices.TunerGenius.Host,
|
dm.config.Devices.TunerGenius.Host,
|
||||||
dm.config.Devices.TunerGenius.Port,
|
dm.config.Devices.TunerGenius.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize Antenna Genius
|
// Initialize Antenna Genius
|
||||||
|
log.Printf("Initializing AntennaGenius: host=%s port=%d", dm.config.Devices.AntennaGenius.Host, dm.config.Devices.AntennaGenius.Port)
|
||||||
dm.antennaGenius = antennagenius.New(
|
dm.antennaGenius = antennagenius.New(
|
||||||
dm.config.Devices.AntennaGenius.Host,
|
dm.config.Devices.AntennaGenius.Host,
|
||||||
dm.config.Devices.AntennaGenius.Port,
|
dm.config.Devices.AntennaGenius.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize Rotator Genius
|
// Initialize Rotator Genius
|
||||||
|
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
||||||
dm.rotatorGenius = rotatorgenius.New(
|
dm.rotatorGenius = rotatorgenius.New(
|
||||||
dm.config.Devices.RotatorGenius.Host,
|
dm.config.Devices.RotatorGenius.Host,
|
||||||
dm.config.Devices.RotatorGenius.Port,
|
dm.config.Devices.RotatorGenius.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize Ultrabeam
|
||||||
|
log.Printf("Initializing Ultrabeam: host=%s port=%d", dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port)
|
||||||
|
dm.ultrabeam = ultrabeam.New(
|
||||||
|
dm.config.Devices.Ultrabeam.Host,
|
||||||
|
dm.config.Devices.Ultrabeam.Port,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize FlexRadio if enabled
|
||||||
|
if dm.config.Devices.FlexRadio.Enabled {
|
||||||
|
log.Printf("Initializing FlexRadio: host=%s port=%d", dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port)
|
||||||
|
dm.flexRadio = flexradio.New(
|
||||||
|
dm.config.Devices.FlexRadio.Host,
|
||||||
|
dm.config.Devices.FlexRadio.Port,
|
||||||
|
)
|
||||||
|
|
||||||
|
dm.flexRadio.SetReconnectInterval(3 * time.Second) // Retry every 3 seconds
|
||||||
|
dm.flexRadio.SetMaxReconnectDelay(30 * time.Second) // Max 30 second delay
|
||||||
|
|
||||||
|
// Set callback for immediate frequency changes (no waiting for update cycle)
|
||||||
|
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
|
||||||
|
dm.handleFrequencyChange(freqMHz)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set callback to check if transmit is allowed (based on Ultrabeam motors)
|
||||||
|
dm.flexRadio.SetTransmitCheckCallback(func() bool {
|
||||||
|
// Get current Ultrabeam status
|
||||||
|
ubStatus, err := dm.ultrabeam.GetStatus()
|
||||||
|
if err != nil || ubStatus == nil {
|
||||||
|
// If we cannot get status, allow transmit (fail-safe)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block transmit if motors are moving
|
||||||
|
motorsMoving := ubStatus.MotorsMoving != 0
|
||||||
|
if motorsMoving {
|
||||||
|
log.Printf("FlexRadio PTT check: Motors moving (bitmask=%d) - BLOCKING", ubStatus.MotorsMoving)
|
||||||
|
} else {
|
||||||
|
log.Printf("FlexRadio PTT check: Motors stopped - ALLOWING")
|
||||||
|
}
|
||||||
|
|
||||||
|
return !motorsMoving
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Solar data client
|
// Initialize Solar data client
|
||||||
dm.solarClient = solar.New()
|
dm.solarClient = solar.New()
|
||||||
|
|
||||||
@@ -94,9 +160,52 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
dm.config.Location.Longitude,
|
dm.config.Location.Longitude,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start PowerGenius continuous polling
|
// Start device polling in background (non-blocking)
|
||||||
if err := dm.powerGenius.Start(); err != nil {
|
go func() {
|
||||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
if err := dm.powerGenius.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := dm.tunerGenius.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start TunerGenius polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := dm.antennaGenius.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start AntennaGenius polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Println("About to launch RotatorGenius goroutine...")
|
||||||
|
go func() {
|
||||||
|
log.Println("Starting RotatorGenius polling goroutine...")
|
||||||
|
if err := dm.rotatorGenius.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start RotatorGenius polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("RotatorGenius goroutine launched")
|
||||||
|
|
||||||
|
log.Println("About to launch Ultrabeam goroutine...")
|
||||||
|
go func() {
|
||||||
|
log.Println("Starting Ultrabeam polling goroutine...")
|
||||||
|
if err := dm.ultrabeam.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start Ultrabeam polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("Ultrabeam goroutine launched")
|
||||||
|
|
||||||
|
// Start FlexRadio if enabled
|
||||||
|
if dm.flexRadio != nil {
|
||||||
|
log.Println("Starting FlexRadio connection...")
|
||||||
|
go func() {
|
||||||
|
if err := dm.flexRadio.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start FlexRadio: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("FlexRadio goroutine launched")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Device manager initialized")
|
log.Println("Device manager initialized")
|
||||||
@@ -109,6 +218,74 @@ func (dm *DeviceManager) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleFrequencyChange is called immediately when FlexRadio frequency changes
|
||||||
|
// This provides instant auto-track response instead of waiting for updateStatus cycle
|
||||||
|
func (dm *DeviceManager) handleFrequencyChange(freqMHz float64) {
|
||||||
|
// Check if ultrabeam is initialized
|
||||||
|
// Check if auto-track is enabled
|
||||||
|
if !dm.autoTrackEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dm.ultrabeam == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cooldown first
|
||||||
|
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||||
|
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached status instead of calling GetStatus (which can block)
|
||||||
|
dm.statusMu.RLock()
|
||||||
|
hasStatus := dm.lastStatus != nil
|
||||||
|
var ubStatus *ultrabeam.Status
|
||||||
|
if hasStatus {
|
||||||
|
ubStatus = dm.lastStatus.Ultrabeam
|
||||||
|
}
|
||||||
|
dm.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if ubStatus == nil || !ubStatus.Connected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't update if motors are already moving
|
||||||
|
if ubStatus.MotorsMoving != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
freqKhz := int(freqMHz * 1000)
|
||||||
|
ultrabeamFreqKhz := ubStatus.Frequency
|
||||||
|
|
||||||
|
// Only track if in Ultrabeam range (7-54 MHz)
|
||||||
|
if freqKhz < 7000 || freqKhz > 54000 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
freqDiff := freqKhz - ultrabeamFreqKhz
|
||||||
|
if freqDiff < 0 {
|
||||||
|
freqDiff = -freqDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
freqDiffHz := freqDiff * 1000
|
||||||
|
|
||||||
|
if freqDiffHz >= dm.freqThreshold {
|
||||||
|
directionToUse := dm.ultrabeamDirection
|
||||||
|
if !dm.ultrabeamDirectionSet && ubStatus.Direction != 0 {
|
||||||
|
directionToUse = ubStatus.Direction
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Auto-track (immediate): Updating to %d kHz (diff=%d kHz)", freqKhz, freqDiff)
|
||||||
|
|
||||||
|
if err := dm.ultrabeam.SetFrequency(freqKhz, directionToUse); err != nil {
|
||||||
|
log.Printf("Auto-track (immediate): Failed: %v", err)
|
||||||
|
} else {
|
||||||
|
dm.lastFreqUpdateTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (dm *DeviceManager) Stop() {
|
func (dm *DeviceManager) Stop() {
|
||||||
log.Println("Stopping device manager...")
|
log.Println("Stopping device manager...")
|
||||||
close(dm.stopChan)
|
close(dm.stopChan)
|
||||||
@@ -126,6 +303,9 @@ func (dm *DeviceManager) Stop() {
|
|||||||
if dm.rotatorGenius != nil {
|
if dm.rotatorGenius != nil {
|
||||||
dm.rotatorGenius.Close()
|
dm.rotatorGenius.Close()
|
||||||
}
|
}
|
||||||
|
if dm.ultrabeam != nil {
|
||||||
|
dm.ultrabeam.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dm *DeviceManager) monitorDevices() {
|
func (dm *DeviceManager) monitorDevices() {
|
||||||
@@ -162,49 +342,129 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
log.Printf("Power Genius error: %v", err)
|
log.Printf("Power Genius error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// // Tuner Genius
|
// Tuner Genius
|
||||||
// if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
||||||
// status.TunerGenius = tgStatus
|
status.TunerGenius = tgStatus
|
||||||
// } else {
|
|
||||||
// log.Printf("Tuner Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Antenna Genius
|
|
||||||
// if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
|
||||||
// status.AntennaGenius = agStatus
|
|
||||||
// } else {
|
|
||||||
// log.Printf("Antenna Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Rotator Genius
|
|
||||||
// if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
|
||||||
// status.RotatorGenius = rgStatus
|
|
||||||
// } else {
|
|
||||||
// log.Printf("Rotator Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Solar Data (fetched every 15 minutes, cached)
|
|
||||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
|
||||||
status.Solar = solarData
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Solar data error: %v", err)
|
log.Printf("Tuner Genius error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weather Data (fetched every 10 minutes, cached)
|
// Antenna Genius
|
||||||
if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil {
|
if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
||||||
status.Weather = weatherData
|
status.AntennaGenius = agStatus
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Weather data error: %v", err)
|
log.Printf("Antenna Genius error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cached status
|
// Rotator Genius
|
||||||
dm.statusMu.Lock()
|
if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
||||||
dm.lastStatus = status
|
status.RotatorGenius = rgStatus
|
||||||
dm.statusMu.Unlock()
|
} else {
|
||||||
|
log.Printf("Rotator Genius error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultrabeam
|
||||||
|
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||||
|
status.Ultrabeam = ubStatus
|
||||||
|
|
||||||
|
// Sync direction with Ultrabeam if user hasn't explicitly set one
|
||||||
|
// This prevents auto-track from using wrong direction before user changes it
|
||||||
|
if !dm.ultrabeamDirectionSet {
|
||||||
|
dm.ultrabeamDirection = ubStatus.Direction
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
log.Printf("Ultrabeam error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexRadio (use direct cache access to avoid mutex contention)
|
||||||
|
if dm.flexRadio != nil {
|
||||||
|
// Access lastStatus directly from FlexRadio's internal cache
|
||||||
|
// The messageLoop updates this in real-time, no need to block on GetStatus
|
||||||
|
frStatus, err := dm.flexRadio.GetStatus()
|
||||||
|
if err == nil && frStatus != nil {
|
||||||
|
status.FlexRadio = frStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto frequency tracking: Update Ultrabeam when radio frequency differs from Ultrabeam
|
||||||
|
if dm.autoTrackEnabled {
|
||||||
|
// TunerGenius tracking (FlexRadio uses immediate callback)
|
||||||
|
var radioFreqKhz int
|
||||||
|
var radioSource string
|
||||||
|
|
||||||
|
if status.TunerGenius != nil && status.TunerGenius.Connected {
|
||||||
|
// Fallback to TunerGenius frequency (already in kHz)
|
||||||
|
radioFreqKhz = int(status.TunerGenius.FreqA)
|
||||||
|
radioSource = "TunerGenius"
|
||||||
|
}
|
||||||
|
|
||||||
|
if radioFreqKhz > 0 && status.Ultrabeam != nil && status.Ultrabeam.Connected {
|
||||||
|
ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz
|
||||||
|
|
||||||
|
// Only do auto-track if frequency is in Ultrabeam range (40M-6M: 7000-54000 kHz)
|
||||||
|
// This prevents retraction when slice is closed (FreqA becomes 0) or on out-of-range bands
|
||||||
|
if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 {
|
||||||
|
freqDiff := radioFreqKhz - ultrabeamFreqKhz
|
||||||
|
if freqDiff < 0 {
|
||||||
|
freqDiff = -freqDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert diff to Hz for comparison with threshold (which is in Hz)
|
||||||
|
freqDiffHz := freqDiff * 1000
|
||||||
|
|
||||||
|
// Don't send command if motors are already moving
|
||||||
|
if status.Ultrabeam.MotorsMoving == 0 {
|
||||||
|
if freqDiffHz >= dm.freqThreshold {
|
||||||
|
// Use user's explicitly set direction, or fallback to current Ultrabeam direction
|
||||||
|
directionToUse := dm.ultrabeamDirection
|
||||||
|
if !dm.ultrabeamDirectionSet && status.Ultrabeam.Direction != 0 {
|
||||||
|
directionToUse = status.Ultrabeam.Direction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cooldown to prevent rapid fire commands
|
||||||
|
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||||
|
if timeSinceLastUpdate > dm.freqUpdateCooldown {
|
||||||
|
log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse)
|
||||||
|
|
||||||
|
// Send to Ultrabeam with saved or current direction
|
||||||
|
if err := dm.ultrabeam.SetFrequency(radioFreqKhz, directionToUse); err != nil {
|
||||||
|
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
|
||||||
|
dm.lastFreqUpdateTime = time.Now() // Update cooldown timer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If out of range, simply skip auto-track but continue with status broadcast
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solar Data (fetched every 15 minutes, cached)
|
||||||
|
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||||
|
status.Solar = solarData
|
||||||
|
} else {
|
||||||
|
log.Printf("Solar data error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather Data (fetched every 10 minutes, cached)
|
||||||
|
if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil {
|
||||||
|
status.Weather = weatherData
|
||||||
|
} else {
|
||||||
|
log.Printf("Weather data error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cached status
|
||||||
|
dm.statusMu.Lock()
|
||||||
|
dm.lastStatus = status
|
||||||
|
dm.statusMu.Unlock()
|
||||||
|
|
||||||
|
// Broadcast to all connected clients
|
||||||
|
if dm.hub != nil {
|
||||||
|
dm.hub.BroadcastStatusUpdate(status)
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
|
||||||
if dm.hub != nil {
|
|
||||||
dm.hub.BroadcastStatusUpdate(status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,3 +501,18 @@ func (dm *DeviceManager) AntennaGenius() *antennagenius.Client {
|
|||||||
func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,24 +49,32 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
||||||
|
|
||||||
// Rotator endpoints
|
// Rotator endpoints
|
||||||
mux.HandleFunc("/api/rotator/move", s.handleRotatorMove)
|
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
||||||
mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW)
|
mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW)
|
||||||
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
||||||
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
||||||
|
|
||||||
|
// Ultrabeam endpoints
|
||||||
|
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/autotrack", s.handleUltrabeamAutoTrack)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/direction", s.handleUltrabeamDirection)
|
||||||
|
|
||||||
// Tuner endpoints
|
// Tuner endpoints
|
||||||
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
||||||
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune)
|
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||||
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna)
|
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
||||||
|
|
||||||
// Antenna Genius endpoints
|
// Antenna Genius endpoints
|
||||||
mux.HandleFunc("/api/antenna/set", s.handleAntennaSet)
|
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
||||||
|
mux.HandleFunc("/api/antenna/deselect", s.handleAntennaDeselect)
|
||||||
|
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
||||||
|
|
||||||
// Power Genius endpoints
|
// Power Genius endpoints
|
||||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
||||||
|
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||||
|
|
||||||
// Static files (will be frontend)
|
// Note: Static files are now served from embedded FS in main.go
|
||||||
mux.Handle("/", http.FileServer(http.Dir("./web/dist")))
|
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -179,15 +187,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rotator handlers
|
// Rotator handlers
|
||||||
func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Rotator int `json:"rotator"`
|
Heading int `json:"heading"`
|
||||||
Azimuth int `json:"azimuth"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -195,7 +202,7 @@ func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().MoveToAzimuth(req.Rotator, req.Azimuth); err != nil {
|
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -209,13 +216,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
||||||
if err != nil || rotator < 1 || rotator > 2 {
|
|
||||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCW(rotator); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -229,13 +230,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
||||||
if err != nil || rotator < 1 || rotator > 2 {
|
|
||||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCCW(rotator); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -265,7 +260,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Operate bool `json:"operate"`
|
Value int `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -273,7 +268,30 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Operate); err != nil {
|
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTunerBypass(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -295,13 +313,15 @@ func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
// Antenna Genius handlers
|
||||||
|
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
|
Port int `json:"port"`
|
||||||
Antenna int `json:"antenna"`
|
Antenna int `json:"antenna"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +330,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.TunerGenius().ActivateAntenna(req.Antenna); err != nil {
|
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -318,15 +338,14 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antenna Genius handlers
|
func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Radio int `json:"radio"`
|
Port int `json:"port"`
|
||||||
Antenna int `json:"antenna"`
|
Antenna int `json:"antenna"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +354,24 @@ func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.AntennaGenius().SetRadioAntenna(req.Radio, req.Antenna); err != nil {
|
log.Printf("Deselecting antenna %d from port %d", req.Antenna, req.Port)
|
||||||
|
if err := s.deviceManager.AntennaGenius().DeselectAntenna(req.Port, req.Antenna); err != nil {
|
||||||
|
log.Printf("Failed to deselect antenna: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Successfully deselected antenna %d from port %d", req.Antenna, req.Port)
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -367,6 +403,113 @@ func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.PowerGenius().SetOperate(req.Value); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultrabeam handlers
|
||||||
|
func (s *Server) handleUltrabeamFrequency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Frequency int `json:"frequency"` // KHz
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save direction for auto-track to use
|
||||||
|
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||||
|
|
||||||
|
if err := s.deviceManager.Ultrabeam().SetFrequency(req.Frequency, req.Direction); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.Ultrabeam().Retract(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamAutoTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Threshold int `json:"threshold"` // kHz
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.deviceManager.SetAutoTrack(req.Enabled, req.Threshold*1000) // Convert kHz to Hz
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just save the direction preference for auto-track to use
|
||||||
|
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -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,157 +3,476 @@ package antennagenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
connMu sync.Mutex
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
antennas []Antenna
|
||||||
|
antennasMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index)
|
PortA *PortStatus `json:"port_a"`
|
||||||
Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index)
|
PortB *PortStatus `json:"port_b"`
|
||||||
Connected bool `json:"connected"`
|
Antennas []Antenna `json:"antennas"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortStatus struct {
|
||||||
|
Auto bool `json:"auto"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Band int `json:"band"`
|
||||||
|
Frequency float64 `json:"frequency"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
RxAnt int `json:"rx_ant"`
|
||||||
|
TxAnt int `json:"tx_ant"`
|
||||||
|
InBand int `json:"in_band"`
|
||||||
|
TX bool `json:"tx"`
|
||||||
|
Inhibit int `json:"inhibit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Antenna struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TX string `json:"tx"`
|
||||||
|
RX string `json:"rx"`
|
||||||
|
InBand string `json:"in_band"`
|
||||||
|
Hotkey int `json:"hotkey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect: %w", err)
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
|
||||||
|
// Read and discard banner
|
||||||
|
_, _ = c.reader.ReadString('\n')
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.stopChan != nil {
|
||||||
|
close(c.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
func (c *Client) Start() error {
|
||||||
if c.conn == nil {
|
if c.running {
|
||||||
if err := c.Connect(); err != nil {
|
return nil
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get next command ID from global counter
|
_ = c.Connect()
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
|
||||||
|
|
||||||
// Format command with ID: C<id>|<command>
|
c.running = true
|
||||||
fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd)
|
go c.pollLoop()
|
||||||
|
|
||||||
// Send command
|
|
||||||
_, err := c.conn.Write([]byte(fullCmd))
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to send command: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read response
|
|
||||||
reader := bufio.NewReader(c.conn)
|
|
||||||
response, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetStatus() (*Status, error) {
|
|
||||||
resp, err := c.sendCommand("status")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.parseStatus(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) parseStatus(resp string) (*Status, error) {
|
|
||||||
status := &Status{
|
|
||||||
Connected: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse response format from 4O3A API
|
|
||||||
// Expected format will vary - this is a basic parser
|
|
||||||
pairs := strings.Fields(resp)
|
|
||||||
|
|
||||||
for _, pair := range pairs {
|
|
||||||
parts := strings.SplitN(pair, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := parts[0]
|
|
||||||
value := parts[1]
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "radio1", "r1":
|
|
||||||
status.Radio1Antenna, _ = strconv.Atoi(value)
|
|
||||||
case "radio2", "r2":
|
|
||||||
status.Radio2Antenna, _ = strconv.Atoi(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRadioAntenna sets which antenna a radio should use
|
|
||||||
// radio: 1 or 2
|
|
||||||
// antenna: 0-7 (antenna index)
|
|
||||||
func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
|
||||||
if radio < 1 || radio > 2 {
|
|
||||||
return fmt.Errorf("radio must be 1 or 2")
|
|
||||||
}
|
|
||||||
if antenna < 0 || antenna > 7 {
|
|
||||||
return fmt.Errorf("antenna must be between 0 and 7")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("set radio%d=%d", radio, antenna)
|
|
||||||
resp, err := c.sendCommand(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check response for success
|
|
||||||
if !strings.Contains(strings.ToLower(resp), "ok") && resp != "" {
|
|
||||||
// If response doesn't contain "ok" but isn't empty, assume success
|
|
||||||
// (some devices may return the new state instead of "ok")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRadioAntenna gets which antenna a radio is currently using
|
func (c *Client) pollLoop() {
|
||||||
func (c *Client) GetRadioAntenna(radio int) (int, error) {
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
if radio < 1 || radio > 2 {
|
defer ticker.Stop()
|
||||||
return -1, fmt.Errorf("radio must be 1 or 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := c.GetStatus()
|
initialized := false
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if radio == 1 {
|
for {
|
||||||
return status.Radio1Antenna, nil
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
initialized = false
|
||||||
|
c.connMu.Lock()
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Initialize: get antenna list and subscribe
|
||||||
|
if !initialized {
|
||||||
|
if err := c.initialize(); err != nil {
|
||||||
|
log.Printf("AntennaGenius init error: %v", err)
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read spontaneous messages from subscription
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil && c.reader != nil {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond))
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "S") {
|
||||||
|
c.parsePortStatus(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return status.Radio2Antenna, nil
|
}
|
||||||
|
|
||||||
|
func (c *Client) initialize() error {
|
||||||
|
// Get antenna list
|
||||||
|
log.Println("AntennaGenius: Getting antenna list...")
|
||||||
|
antennas, err := c.getAntennaList()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get antenna list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("AntennaGenius: Found %d antennas", len(antennas))
|
||||||
|
for i, ant := range antennas {
|
||||||
|
log.Printf("AntennaGenius: Antenna %d: number=%d, name=%s", i, ant.Number, ant.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.antennasMu.Lock()
|
||||||
|
c.antennas = antennas
|
||||||
|
c.antennasMu.Unlock()
|
||||||
|
|
||||||
|
// Initialize status BEFORE subscribing so parsePortStatus can update it
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{
|
||||||
|
PortA: &PortStatus{},
|
||||||
|
PortB: &PortStatus{},
|
||||||
|
Antennas: antennas,
|
||||||
|
Connected: true,
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
log.Println("AntennaGenius: Status initialized, now subscribing to port updates...")
|
||||||
|
|
||||||
|
// Subscribe to port updates (this will parse and update port status)
|
||||||
|
if err := c.subscribeToPortUpdates(); err != nil {
|
||||||
|
return fmt.Errorf("failed to subscribe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request initial status for both ports
|
||||||
|
log.Println("AntennaGenius: Requesting additional port status...")
|
||||||
|
_, _ = c.sendCommand("port get 1") // Port A
|
||||||
|
_, _ = c.sendCommand("port get 2") // Port B
|
||||||
|
|
||||||
|
c.statusMu.RLock()
|
||||||
|
log.Printf("AntennaGenius: Initialization complete - PortA.RxAnt=%d, PortB.RxAnt=%d",
|
||||||
|
c.lastStatus.PortA.RxAnt, c.lastStatus.PortB.RxAnt)
|
||||||
|
c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return "", fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AntennaGenius only accepts C1| for all commands
|
||||||
|
fullCmd := fmt.Sprintf("C1|%s\n", cmd)
|
||||||
|
|
||||||
|
_, err := c.conn.Write([]byte(fullCmd))
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
return "", fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all response lines using shared reader
|
||||||
|
var response strings.Builder
|
||||||
|
|
||||||
|
// Set a read timeout to avoid blocking forever
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
defer c.conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if response.Len() > 0 {
|
||||||
|
// We got some data, return it
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteString(line)
|
||||||
|
|
||||||
|
// Parse spontaneous status updates
|
||||||
|
trimmedLine := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmedLine, "S0|") {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
c.parsePortStatus(trimmedLine)
|
||||||
|
c.connMu.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the last line (empty line or timeout)
|
||||||
|
if trimmedLine == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getAntennaList() ([]Antenna, error) {
|
||||||
|
resp, err := c.sendCommand("antenna list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var antennas []Antenna
|
||||||
|
|
||||||
|
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
||||||
|
lines := strings.Split(resp, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.Contains(line, "antenna ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
antenna := c.parseAntennaLine(line)
|
||||||
|
|
||||||
|
// Skip unconfigured antennas (name = Antenna X with space)
|
||||||
|
if strings.HasPrefix(antenna.Name, "Antenna ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
antennas = append(antennas, antenna)
|
||||||
|
}
|
||||||
|
|
||||||
|
return antennas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseAntennaLine(line string) Antenna {
|
||||||
|
antenna := Antenna{}
|
||||||
|
|
||||||
|
// Extract antenna number
|
||||||
|
if idx := strings.Index(line, "antenna "); idx != -1 {
|
||||||
|
rest := line[idx+8:]
|
||||||
|
parts := strings.Fields(rest)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
antenna.Number, _ = strconv.Atoi(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key=value pairs
|
||||||
|
pairs := strings.Fields(line)
|
||||||
|
for _, pair := range pairs {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := kv[0]
|
||||||
|
value := kv[1]
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "name":
|
||||||
|
// Replace underscores with spaces
|
||||||
|
antenna.Name = strings.ReplaceAll(value, "_", " ")
|
||||||
|
case "tx":
|
||||||
|
antenna.TX = value
|
||||||
|
case "rx":
|
||||||
|
antenna.RX = value
|
||||||
|
case "inband":
|
||||||
|
antenna.InBand = value
|
||||||
|
case "hotkey":
|
||||||
|
antenna.Hotkey, _ = strconv.Atoi(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return antenna
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) subscribeToPortUpdates() error {
|
||||||
|
resp, err := c.sendCommand("sub port all")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AntennaGenius: Failed to subscribe: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse initial port status from subscription response
|
||||||
|
// The response may contain S0|port messages with current status
|
||||||
|
lines := strings.Split(resp, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "S0|port") {
|
||||||
|
c.parsePortStatus(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("AntennaGenius: Subscription complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parsePortStatus(line string) {
|
||||||
|
// Format: S0|port <id> auto=<0|1> source=<src> band=<n> freq=<f> nickname=<name> rxant=<n> txant=<n> inband=<n> tx=<0|1> inhibit=<n>
|
||||||
|
|
||||||
|
var portID int
|
||||||
|
portStatus := &PortStatus{}
|
||||||
|
|
||||||
|
// Extract port ID
|
||||||
|
if idx := strings.Index(line, "port "); idx != -1 {
|
||||||
|
rest := line[idx+5:]
|
||||||
|
parts := strings.Fields(rest)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
portID, _ = strconv.Atoi(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse key=value pairs
|
||||||
|
pairs := strings.Fields(line)
|
||||||
|
for _, pair := range pairs {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := kv[0]
|
||||||
|
value := kv[1]
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "auto":
|
||||||
|
portStatus.Auto = value == "1"
|
||||||
|
case "source":
|
||||||
|
portStatus.Source = value
|
||||||
|
case "band":
|
||||||
|
portStatus.Band, _ = strconv.Atoi(value)
|
||||||
|
case "freq":
|
||||||
|
portStatus.Frequency, _ = strconv.ParseFloat(value, 64)
|
||||||
|
case "nickname":
|
||||||
|
portStatus.Nickname = value
|
||||||
|
case "rxant":
|
||||||
|
portStatus.RxAnt, _ = strconv.Atoi(value)
|
||||||
|
case "txant":
|
||||||
|
portStatus.TxAnt, _ = strconv.Atoi(value)
|
||||||
|
case "inband":
|
||||||
|
portStatus.InBand, _ = strconv.Atoi(value)
|
||||||
|
case "tx":
|
||||||
|
portStatus.TX = value == "1"
|
||||||
|
case "inhibit":
|
||||||
|
portStatus.Inhibit, _ = strconv.Atoi(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
if portID == 1 {
|
||||||
|
c.lastStatus.PortA = portStatus
|
||||||
|
} else if portID == 2 {
|
||||||
|
c.lastStatus.PortB = portStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device is actually alive
|
||||||
|
// If no antennas and all values are default, device is probably off
|
||||||
|
status := *c.lastStatus
|
||||||
|
if len(status.Antennas) == 0 || (status.PortA != nil && status.PortA.Source == "" && status.PortB != nil && status.PortB.Source == "") {
|
||||||
|
status.Connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAntenna sets the antenna for a specific port
|
||||||
|
func (c *Client) SetAntenna(port, antenna int) error {
|
||||||
|
cmd := fmt.Sprintf("port set %d rxant=%d", port, antenna)
|
||||||
|
_, err := c.sendCommand(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeselectAntenna deselects an antenna from a port (sets rxant=00)
|
||||||
|
// Command format: "C1|port set <port> rxant=00"
|
||||||
|
func (c *Client) DeselectAntenna(port, antenna int) error {
|
||||||
|
cmd := fmt.Sprintf("port set %d rxant=00", port)
|
||||||
|
log.Printf("AntennaGenius: Sending deselect command: %s", cmd)
|
||||||
|
resp, err := c.sendCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AntennaGenius: Deselect failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("AntennaGenius: Deselect response: %s", resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reboot reboots the device
|
||||||
|
func (c *Client) Reboot() error {
|
||||||
|
_, err := c.sendCommand("reboot")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
737
internal/devices/flexradio/flexradio.go
Normal file
737
internal/devices/flexradio/flexradio.go
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
package flexradio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
connMu sync.Mutex
|
||||||
|
writeMu sync.Mutex
|
||||||
|
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
|
||||||
|
cmdSeq int
|
||||||
|
cmdSeqMu sync.Mutex
|
||||||
|
|
||||||
|
running bool
|
||||||
|
stopChan chan struct{}
|
||||||
|
|
||||||
|
reconnectInterval time.Duration
|
||||||
|
reconnectAttempts int
|
||||||
|
maxReconnectDelay time.Duration
|
||||||
|
|
||||||
|
radioInfo map[string]string
|
||||||
|
radioInfoMu sync.RWMutex
|
||||||
|
lastInfoCheck time.Time
|
||||||
|
infoCheckTimer *time.Timer
|
||||||
|
|
||||||
|
activeSlices []int
|
||||||
|
activeSlicesMu sync.RWMutex
|
||||||
|
sliceListTimer *time.Timer
|
||||||
|
|
||||||
|
onFrequencyChange func(freqMHz float64)
|
||||||
|
checkTransmitAllowed func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
reconnectInterval: 5 * time.Second,
|
||||||
|
maxReconnectDelay: 60 * time.Second,
|
||||||
|
radioInfo: make(map[string]string),
|
||||||
|
activeSlices: []int{},
|
||||||
|
lastStatus: &Status{
|
||||||
|
Connected: false,
|
||||||
|
RadioOn: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReconnectInterval sets the reconnection interval
|
||||||
|
func (c *Client) SetReconnectInterval(interval time.Duration) {
|
||||||
|
c.reconnectInterval = interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxReconnectDelay sets the maximum delay for exponential backoff
|
||||||
|
func (c *Client) SetMaxReconnectDelay(delay time.Duration) {
|
||||||
|
c.maxReconnectDelay = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequencyChangeCallback sets the callback function called when frequency changes
|
||||||
|
func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) {
|
||||||
|
c.onFrequencyChange = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTransmitCheckCallback sets the callback to check if transmit is allowed
|
||||||
|
func (c *Client) SetTransmitCheckCallback(callback func() bool) {
|
||||||
|
c.checkTransmitAllowed = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||||
|
log.Printf("FlexRadio: Connecting to %s...", addr)
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
c.reconnectAttempts = 0
|
||||||
|
|
||||||
|
log.Println("FlexRadio: TCP connection established")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try initial connection
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
log.Printf("FlexRadio: Initial connection failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update connected status
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Connected = (c.conn != nil)
|
||||||
|
c.lastStatus.RadioOn = false
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
c.running = true
|
||||||
|
|
||||||
|
// Start message listener
|
||||||
|
go c.messageLoop()
|
||||||
|
|
||||||
|
// Start reconnection monitor
|
||||||
|
go c.reconnectionMonitor()
|
||||||
|
|
||||||
|
// Start radio status checker
|
||||||
|
go c.radioStatusChecker()
|
||||||
|
|
||||||
|
// Start slice list checker
|
||||||
|
go c.sliceListChecker()
|
||||||
|
|
||||||
|
// Try to get initial radio info and subscribe to slices
|
||||||
|
if c.conn != nil {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.SendInfo()
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.SendSliceList()
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.SubscribeToSlices()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
|
||||||
|
// Stop timers
|
||||||
|
if c.infoCheckTimer != nil {
|
||||||
|
c.infoCheckTimer.Stop()
|
||||||
|
}
|
||||||
|
if c.sliceListTimer != nil {
|
||||||
|
c.sliceListTimer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Connected = false
|
||||||
|
c.lastStatus.RadioOn = false
|
||||||
|
c.lastStatus.RadioInfo = "Disconnected"
|
||||||
|
c.lastStatus.ActiveSlices = 0
|
||||||
|
c.lastStatus.Frequency = 0
|
||||||
|
c.lastStatus.Mode = ""
|
||||||
|
c.lastStatus.Tx = false
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for common commands
|
||||||
|
func (c *Client) SendInfo() error {
|
||||||
|
return c.sendCommand("info")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendSliceList() error {
|
||||||
|
return c.sendCommand("slice list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SubscribeToSlices() error {
|
||||||
|
return c.sendCommand("sub slice all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendCommand(cmd string) error {
|
||||||
|
c.writeMu.Lock()
|
||||||
|
defer c.writeMu.Unlock()
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := c.getNextSeq()
|
||||||
|
fullCmd := fmt.Sprintf("C%d|%s\n", seq, cmd)
|
||||||
|
|
||||||
|
log.Printf("FlexRadio TX: %s", strings.TrimSpace(fullCmd))
|
||||||
|
|
||||||
|
_, err := conn.Write([]byte(fullCmd))
|
||||||
|
if err != nil {
|
||||||
|
// Mark connection as broken
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Connected = false
|
||||||
|
c.lastStatus.RadioOn = false
|
||||||
|
c.lastStatus.RadioInfo = "Connection lost"
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getNextSeq() int {
|
||||||
|
c.cmdSeqMu.Lock()
|
||||||
|
defer c.cmdSeqMu.Unlock()
|
||||||
|
c.cmdSeq++
|
||||||
|
return c.cmdSeq
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) messageLoop() {
|
||||||
|
log.Println("FlexRadio: Message loop started")
|
||||||
|
|
||||||
|
for c.running {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("FlexRadio: Read error: %v", err)
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Connected = false
|
||||||
|
c.lastStatus.RadioOn = false
|
||||||
|
c.lastStatus.RadioInfo = "Connection lost"
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handleMessage(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("FlexRadio: Message loop stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message handling - SIMPLIFIED VERSION
|
||||||
|
func (c *Client) handleMessage(msg string) {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if msg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG: Log tous les messages reçus
|
||||||
|
log.Printf("FlexRadio RAW: %s", msg)
|
||||||
|
|
||||||
|
// Router selon le premier caractère
|
||||||
|
switch msg[0] {
|
||||||
|
case 'R': // Réponse à une commande
|
||||||
|
c.handleCommandResponse(msg)
|
||||||
|
case 'S': // Message de statut
|
||||||
|
c.handleStatusMessage(msg)
|
||||||
|
case 'V': // Version/Handle
|
||||||
|
log.Printf("FlexRadio: Version/Handle: %s", msg)
|
||||||
|
case 'M': // Message général
|
||||||
|
log.Printf("FlexRadio: Message: %s", msg)
|
||||||
|
default:
|
||||||
|
log.Printf("FlexRadio: Unknown message type: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleCommandResponse(msg string) {
|
||||||
|
// Format: R<seq>|<status>|<data>
|
||||||
|
parts := strings.SplitN(msg, "|", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
log.Printf("FlexRadio: Malformed response: %s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seqStr := strings.TrimPrefix(parts[0], "R")
|
||||||
|
status := parts[1]
|
||||||
|
data := parts[2]
|
||||||
|
|
||||||
|
seq, _ := strconv.Atoi(seqStr)
|
||||||
|
|
||||||
|
if status != "0" {
|
||||||
|
log.Printf("FlexRadio: Command error (seq=%d): %s", seq, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("FlexRadio: Command success (seq=%d)", seq)
|
||||||
|
|
||||||
|
// Identifier le type de réponse par son contenu
|
||||||
|
switch {
|
||||||
|
case strings.Contains(data, "model="):
|
||||||
|
c.parseInfoResponse(data)
|
||||||
|
|
||||||
|
case isSliceListResponse(data):
|
||||||
|
c.parseSliceListResponse(data)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Printf("FlexRadio: Generic response: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSliceListResponse(data string) bool {
|
||||||
|
data = strings.TrimSpace(data)
|
||||||
|
if data == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, char := range data {
|
||||||
|
if !unicode.IsDigit(char) && char != ' ' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleStatusMessage(msg string) {
|
||||||
|
parts := strings.SplitN(msg, "|", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handle := parts[0][1:]
|
||||||
|
data := parts[1]
|
||||||
|
|
||||||
|
statusMap := make(map[string]string)
|
||||||
|
pairs := strings.Fields(data)
|
||||||
|
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if kv := strings.SplitN(pair, "=", 2); len(kv) == 2 {
|
||||||
|
statusMap[kv[0]] = kv[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "interlock"):
|
||||||
|
c.handleInterlockStatus(handle, statusMap)
|
||||||
|
|
||||||
|
case strings.Contains(msg, "slice"):
|
||||||
|
c.handleSliceStatus(handle, statusMap)
|
||||||
|
|
||||||
|
case strings.Contains(msg, "radio"):
|
||||||
|
c.handleRadioStatus(handle, statusMap)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Printf("FlexRadio: Unknown status (handle=%s): %s", handle, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleInterlockStatus(handle string, statusMap map[string]string) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
|
||||||
|
if state, ok := statusMap["state"]; ok {
|
||||||
|
c.lastStatus.Tx = (state == "TRANSMITTING" || state == "TUNE")
|
||||||
|
log.Printf("FlexRadio: Interlock state=%s, TX=%v", state, c.lastStatus.Tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleSliceStatus(handle string, statusMap map[string]string) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
|
||||||
|
// Quand on reçoit un message de slice, on a au moins une slice active
|
||||||
|
c.lastStatus.ActiveSlices = 1
|
||||||
|
|
||||||
|
if rfFreq, ok := statusMap["RF_frequency"]; ok {
|
||||||
|
if freq, err := strconv.ParseFloat(rfFreq, 64); err == nil && freq > 0 {
|
||||||
|
c.lastStatus.Frequency = freq
|
||||||
|
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", freq)
|
||||||
|
|
||||||
|
if c.onFrequencyChange != nil {
|
||||||
|
go c.onFrequencyChange(freq)
|
||||||
|
}
|
||||||
|
} else if freq == 0 {
|
||||||
|
// Fréquence 0 dans le message de slice = slice inactive
|
||||||
|
c.lastStatus.Frequency = 0
|
||||||
|
c.lastStatus.RadioInfo = "Slice inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode, ok := statusMap["mode"]; ok {
|
||||||
|
c.lastStatus.Mode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx, ok := statusMap["tx"]; ok {
|
||||||
|
c.lastStatus.Tx = (tx == "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleRadioStatus(handle string, statusMap map[string]string) {
|
||||||
|
if slices, ok := statusMap["slices"]; ok {
|
||||||
|
if num, err := strconv.Atoi(slices); err == nil {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus.NumSlices = num
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseInfoResponse(data string) {
|
||||||
|
log.Printf("FlexRadio: Parsing info response: %s", data)
|
||||||
|
|
||||||
|
pairs := []string{}
|
||||||
|
current := ""
|
||||||
|
inQuotes := false
|
||||||
|
|
||||||
|
for _, char := range data {
|
||||||
|
if char == '"' {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
}
|
||||||
|
if char == ',' && !inQuotes {
|
||||||
|
pairs = append(pairs, strings.TrimSpace(current))
|
||||||
|
current = ""
|
||||||
|
} else {
|
||||||
|
current += string(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
pairs = append(pairs, strings.TrimSpace(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.radioInfoMu.Lock()
|
||||||
|
c.radioInfo = make(map[string]string)
|
||||||
|
|
||||||
|
for _, pair := range pairs {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
key := strings.TrimSpace(kv[0])
|
||||||
|
value := strings.TrimSpace(kv[1])
|
||||||
|
|
||||||
|
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
||||||
|
value = value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.radioInfo[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.radioInfoMu.Unlock()
|
||||||
|
|
||||||
|
c.updateRadioStatus(true, "Radio is on")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
c.SendSliceList()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseSliceListResponse(data string) {
|
||||||
|
slices := []int{}
|
||||||
|
|
||||||
|
if strings.TrimSpace(data) != "" {
|
||||||
|
parts := strings.Fields(data)
|
||||||
|
for _, part := range parts {
|
||||||
|
if sliceNum, err := strconv.Atoi(part); err == nil {
|
||||||
|
slices = append(slices, sliceNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.activeSlicesMu.Lock()
|
||||||
|
c.activeSlices = slices
|
||||||
|
c.activeSlicesMu.Unlock()
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.ActiveSlices = len(slices)
|
||||||
|
|
||||||
|
// NE PAS effacer la fréquence ici !
|
||||||
|
// La fréquence est gérée par handleSliceStatus
|
||||||
|
|
||||||
|
// Seulement mettre à jour RadioInfo si vraiment pas de slices
|
||||||
|
if len(slices) == 0 && c.lastStatus.Frequency == 0 {
|
||||||
|
c.lastStatus.RadioInfo = "Radio is on without any active slice"
|
||||||
|
} else if len(slices) == 0 && c.lastStatus.Frequency > 0 {
|
||||||
|
// Cas spécial : fréquence mais pas de slice dans la liste
|
||||||
|
// Peut arriver temporairement, garder l'info actuelle
|
||||||
|
c.lastStatus.RadioInfo = fmt.Sprintf("Active on %.3f MHz", c.lastStatus.Frequency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("FlexRadio: Active slices updated: %v (total: %d)", slices, len(slices))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) updateRadioStatus(isOn bool, info string) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.RadioOn = isOn
|
||||||
|
c.lastStatus.RadioInfo = info
|
||||||
|
|
||||||
|
c.radioInfoMu.RLock()
|
||||||
|
if callsign, ok := c.radioInfo["callsign"]; ok {
|
||||||
|
c.lastStatus.Callsign = callsign
|
||||||
|
}
|
||||||
|
if model, ok := c.radioInfo["model"]; ok {
|
||||||
|
c.lastStatus.Model = model
|
||||||
|
}
|
||||||
|
if softwareVer, ok := c.radioInfo["software_ver"]; ok {
|
||||||
|
c.lastStatus.SoftwareVer = softwareVer
|
||||||
|
}
|
||||||
|
if numSlicesStr, ok := c.radioInfo["num_slice"]; ok {
|
||||||
|
if numSlices, err := strconv.Atoi(numSlicesStr); err == nil {
|
||||||
|
c.lastStatus.NumSlices = numSlices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.radioInfoMu.RUnlock()
|
||||||
|
|
||||||
|
if isOn && c.lastStatus.Frequency == 0 && c.lastStatus.ActiveSlices == 0 {
|
||||||
|
c.lastStatus.RadioInfo = "Radio is on without any active slice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) reconnectionMonitor() {
|
||||||
|
log.Println("FlexRadio: Reconnection monitor started")
|
||||||
|
|
||||||
|
for c.running {
|
||||||
|
c.connMu.Lock()
|
||||||
|
connected := (c.conn != nil)
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
c.reconnectAttempts++
|
||||||
|
|
||||||
|
delay := c.calculateReconnectDelay()
|
||||||
|
|
||||||
|
log.Printf("FlexRadio: Attempting to reconnect in %v (attempt %d)...", delay, c.reconnectAttempts)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
if err := c.reconnect(); err != nil {
|
||||||
|
log.Printf("FlexRadio: Reconnection attempt %d failed: %v", c.reconnectAttempts, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("FlexRadio: Reconnected successfully on attempt %d", c.reconnectAttempts)
|
||||||
|
c.reconnectAttempts = 0
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.SendInfo()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
select {
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) calculateReconnectDelay() time.Duration {
|
||||||
|
delay := c.reconnectInterval
|
||||||
|
|
||||||
|
if c.reconnectAttempts > 1 {
|
||||||
|
multiplier := 1 << (c.reconnectAttempts - 1)
|
||||||
|
delay = c.reconnectInterval * time.Duration(multiplier)
|
||||||
|
|
||||||
|
if delay > c.maxReconnectDelay {
|
||||||
|
delay = c.maxReconnectDelay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) reconnect() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Close existing connection if any
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||||
|
log.Printf("FlexRadio: Reconnecting to %s...", addr)
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Connected = false
|
||||||
|
c.lastStatus.RadioOn = false
|
||||||
|
c.lastStatus.RadioInfo = "Disconnected"
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return fmt.Errorf("reconnect failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
c.lastStatus.Connected = true
|
||||||
|
c.lastStatus.RadioInfo = "TCP connected, checking radio..."
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
log.Println("FlexRadio: TCP connection reestablished")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) radioStatusChecker() {
|
||||||
|
c.infoCheckTimer = time.NewTimer(10 * time.Second)
|
||||||
|
|
||||||
|
for c.running {
|
||||||
|
select {
|
||||||
|
case <-c.infoCheckTimer.C:
|
||||||
|
c.SendInfo()
|
||||||
|
c.infoCheckTimer.Reset(10 * time.Second)
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sliceListChecker() {
|
||||||
|
c.sliceListTimer = time.NewTimer(10 * time.Second)
|
||||||
|
|
||||||
|
for c.running {
|
||||||
|
select {
|
||||||
|
case <-c.sliceListTimer.C:
|
||||||
|
if c.IsRadioOn() {
|
||||||
|
c.SendSliceList()
|
||||||
|
}
|
||||||
|
c.sliceListTimer.Reset(10 * time.Second)
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{
|
||||||
|
Connected: false,
|
||||||
|
RadioOn: false,
|
||||||
|
RadioInfo: "Not initialized",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status := *c.lastStatus
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRadioOn returns true if radio is powered on and responding
|
||||||
|
func (c *Client) IsRadioOn() bool {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.lastStatus.RadioOn
|
||||||
|
}
|
||||||
25
internal/devices/flexradio/types.go
Normal file
25
internal/devices/flexradio/types.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package flexradio
|
||||||
|
|
||||||
|
// Status represents the FlexRadio status
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Frequency float64 `json:"frequency"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Tx bool `json:"tx"`
|
||||||
|
RadioOn bool `json:"radio_on"` // Radio is powered on and responding
|
||||||
|
RadioInfo string `json:"radio_info"` // Additional info about radio state
|
||||||
|
Callsign string `json:"callsign"` // From info command
|
||||||
|
Model string `json:"model"` // From info command
|
||||||
|
SoftwareVer string `json:"software_ver"` // From info command
|
||||||
|
NumSlices int `json:"num_slices"` // From info command
|
||||||
|
ActiveSlices int `json:"active_slices"` // Count of active slices
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterlockState represents possible interlock states
|
||||||
|
const (
|
||||||
|
InterlockStateReady = "READY"
|
||||||
|
InterlockStateNotReady = "NOT_READY"
|
||||||
|
InterlockStatePTTRequested = "PTT_REQUESTED"
|
||||||
|
InterlockStateTransmitting = "TRANSMITTING"
|
||||||
|
InterlockStateUnkeyRequested = "UNKEY_REQUESTED"
|
||||||
|
)
|
||||||
@@ -23,6 +23,14 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
running bool
|
running bool
|
||||||
|
|
||||||
|
// Connection health tracking
|
||||||
|
lastAliveTime time.Time
|
||||||
|
|
||||||
|
// Auto fan management
|
||||||
|
autoFanEnabled bool
|
||||||
|
lastFanMode string // Remember last manual mode
|
||||||
|
autoFanActive bool // Track if auto-fan is currently active (in Broadcast mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -42,14 +50,19 @@ type Status struct {
|
|||||||
BandB string `json:"band_b"`
|
BandB string `json:"band_b"`
|
||||||
FaultPresent bool `json:"fault_present"`
|
FaultPresent bool `json:"fault_present"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
Meffa string `json:"meffa"`
|
|
||||||
|
// Peak hold for display (internal)
|
||||||
|
displayPower float64
|
||||||
|
peakTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
autoFanEnabled: false, // Auto fan DISABLED - manual control only
|
||||||
|
lastFanMode: "Contest",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +103,17 @@ func (c *Client) Close() error {
|
|||||||
|
|
||||||
// Start begins continuous polling of the device
|
// Start begins continuous polling of the device
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.running {
|
if c.running {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize connection tracking
|
||||||
|
c.lastAliveTime = time.Now()
|
||||||
|
|
||||||
|
// Try to connect, but don't fail if it doesn't work
|
||||||
|
// The poll loop will keep trying
|
||||||
|
_ = c.Connect()
|
||||||
|
|
||||||
c.running = true
|
c.running = true
|
||||||
go c.pollLoop()
|
go c.pollLoop()
|
||||||
|
|
||||||
@@ -112,10 +128,29 @@ func (c *Client) pollLoop() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
// Try to reconnect if not connected
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected and reset all values
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{
|
||||||
|
Connected: false,
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
// Silent fail, will retry on next tick
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.connMu.Lock()
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
status, err := c.queryStatus()
|
status, err := c.queryStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("PowerGenius query error: %v", err)
|
// Connection lost, close and retry next tick
|
||||||
// Try to reconnect
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
@@ -123,19 +158,104 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
|
|
||||||
if err := c.Connect(); err != nil {
|
// Mark as disconnected and reset all values
|
||||||
log.Printf("PowerGenius reconnect failed: %v", err)
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{
|
||||||
|
Connected: false,
|
||||||
}
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as connected
|
||||||
|
status.Connected = true
|
||||||
|
|
||||||
|
// Check if device is actually alive (not just TCP connected)
|
||||||
|
// If voltage is 0 and temperature is 0, device might be temporarily idle
|
||||||
|
// Use a 3-second timeout before marking as disconnected (helps with morse code pauses)
|
||||||
|
if status.Voltage == 0 && status.Temperature == 0 {
|
||||||
|
// Check if we've seen valid data recently (within 3 seconds)
|
||||||
|
if time.Since(c.lastAliveTime) > 3*time.Second {
|
||||||
|
status.Connected = false
|
||||||
|
}
|
||||||
|
// else: Keep Connected = true (device is probably just idle between morse letters)
|
||||||
|
} else {
|
||||||
|
// Valid data received, update lastAliveTime
|
||||||
|
c.lastAliveTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak hold logic - keep highest power for 1 second
|
||||||
|
now := time.Now()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
// If new power is higher, update peak
|
||||||
|
if status.PowerForward > c.lastStatus.displayPower {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
} else {
|
||||||
|
// Check if peak has expired (1 second)
|
||||||
|
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||||
|
// Keep old peak
|
||||||
|
status.displayPower = c.lastStatus.displayPower
|
||||||
|
status.peakTime = c.lastStatus.peakTime
|
||||||
|
} else {
|
||||||
|
// Peak expired, use current value
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override PowerForward with display power for frontend
|
||||||
|
status.PowerForward = status.displayPower
|
||||||
|
|
||||||
|
// Auto fan management based on temperature
|
||||||
|
// Do this BEFORE merging to use the fresh temperature value
|
||||||
|
if c.autoFanEnabled {
|
||||||
|
// Use the temperature from the current status message
|
||||||
|
// If it's 0, use the last known temperature
|
||||||
|
temp := status.Temperature
|
||||||
|
if temp == 0 && c.lastStatus != nil {
|
||||||
|
temp = c.lastStatus.Temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMode := strings.ToUpper(status.FanMode)
|
||||||
|
if currentMode == "" && c.lastStatus != nil {
|
||||||
|
currentMode = strings.ToUpper(c.lastStatus.FanMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only act on valid temperature readings
|
||||||
|
if temp > 5.0 { // Ignore invalid/startup readings below 5°C
|
||||||
|
// If temp >= 60°C, switch to Broadcast
|
||||||
|
if temp >= 60.0 && currentMode != "BROADCAST" {
|
||||||
|
if !c.autoFanActive {
|
||||||
|
log.Printf("PowerGenius: Temperature %.1f°C >= 60°C, switching fan to Broadcast mode", temp)
|
||||||
|
c.autoFanActive = true
|
||||||
|
}
|
||||||
|
if err := c.setFanModeInternal("BROADCAST"); err != nil {
|
||||||
|
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If temp <= 55°C, switch back to Contest
|
||||||
|
if temp <= 55.0 && currentMode == "BROADCAST" {
|
||||||
|
if c.autoFanActive {
|
||||||
|
log.Printf("PowerGenius: Temperature %.1f°C <= 55°C, switching fan back to Contest mode", temp)
|
||||||
|
c.autoFanActive = false
|
||||||
|
}
|
||||||
|
if err := c.setFanModeInternal("CONTEST"); err != nil {
|
||||||
|
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Merge with existing status (spontaneous messages may only update some fields)
|
// Merge with existing status (spontaneous messages may only update some fields)
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
// Keep existing values for fields not in the new status
|
// Keep existing values for fields not in the new status
|
||||||
if status.PowerForward == 0 && c.lastStatus.PowerForward != 0 {
|
|
||||||
status.PowerForward = c.lastStatus.PowerForward
|
|
||||||
}
|
|
||||||
if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
|
if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
|
||||||
status.Temperature = c.lastStatus.Temperature
|
status.Temperature = c.lastStatus.Temperature
|
||||||
}
|
}
|
||||||
@@ -304,8 +424,6 @@ func (c *Client) parseStatus(resp string) (*Status, error) {
|
|||||||
}
|
}
|
||||||
case "vac":
|
case "vac":
|
||||||
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
||||||
case "meffa":
|
|
||||||
status.Meffa = value
|
|
||||||
case "vdd":
|
case "vdd":
|
||||||
status.VDD, _ = strconv.ParseFloat(value, 64)
|
status.VDD, _ = strconv.ParseFloat(value, 64)
|
||||||
case "id":
|
case "id":
|
||||||
@@ -370,11 +488,34 @@ func (c *Client) SetFanMode(mode string) error {
|
|||||||
"BROADCAST": true,
|
"BROADCAST": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validModes[mode] {
|
// Normalize mode to title case for comparison
|
||||||
|
modeUpper := strings.ToUpper(mode)
|
||||||
|
if !validModes[modeUpper] {
|
||||||
return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode)
|
return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember last manual mode (if not triggered by auto-fan)
|
||||||
|
// We store it in title case: "Standard", "Contest", "Broadcast"
|
||||||
|
c.lastFanMode = strings.Title(strings.ToLower(mode))
|
||||||
|
|
||||||
|
return c.setFanModeInternal(modeUpper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFanModeInternal sets fan mode without updating lastFanMode (for auto-fan)
|
||||||
|
func (c *Client) setFanModeInternal(mode string) error {
|
||||||
cmd := fmt.Sprintf("setup fanmode=%s", mode)
|
cmd := fmt.Sprintf("setup fanmode=%s", mode)
|
||||||
_, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOperate sets the operate mode
|
||||||
|
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
||||||
|
func (c *Client) SetOperate(value int) error {
|
||||||
|
if value != 0 && value != 1 {
|
||||||
|
return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("operate=%d", value)
|
||||||
|
_, err := c.sendCommand(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,235 +6,250 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
connMu sync.Mutex
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Rotator1 RotatorData `json:"rotator1"`
|
Heading int `json:"heading"`
|
||||||
Rotator2 RotatorData `json:"rotator2"`
|
TargetHeading int `json:"target_heading"`
|
||||||
Panic bool `json:"panic"`
|
Connected bool `json:"connected"`
|
||||||
}
|
|
||||||
|
|
||||||
type RotatorData struct {
|
|
||||||
CurrentAzimuth int `json:"current_azimuth"`
|
|
||||||
LimitCW int `json:"limit_cw"`
|
|
||||||
LimitCCW int `json:"limit_ccw"`
|
|
||||||
Configuration string `json:"configuration"` // "A" for azimuth, "E" for elevation
|
|
||||||
Moving int `json:"moving"` // 0=stopped, 1=CW, 2=CCW
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
TargetAzimuth int `json:"target_azimuth"`
|
|
||||||
StartAzimuth int `json:"start_azimuth"`
|
|
||||||
OutsideLimit bool `json:"outside_limit"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Connected bool `json:"connected"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("RotatorGenius: Attempting to connect to %s:%d\n", c.host, c.port)
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("RotatorGenius: Connection failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to connect: %w", err)
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
|
||||||
|
fmt.Println("RotatorGenius: Connected successfully")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.stopChan != nil {
|
||||||
|
close(c.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
func (c *Client) Start() error {
|
||||||
if c.conn == nil {
|
fmt.Println("RotatorGenius Start() called")
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
return "", err
|
if c.running {
|
||||||
|
fmt.Println("RotatorGenius already running, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("RotatorGenius attempting initial connection...")
|
||||||
|
_ = c.Connect()
|
||||||
|
|
||||||
|
c.running = true
|
||||||
|
fmt.Println("RotatorGenius launching pollLoop...")
|
||||||
|
go c.pollLoop()
|
||||||
|
|
||||||
|
fmt.Println("RotatorGenius Start() completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.connMu.Lock()
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
status, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status.Connected = true
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = status
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendCommand(cmd string) error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.conn.Write([]byte(cmd))
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
return fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send |h command
|
||||||
|
_, err := c.conn.Write([]byte("|h"))
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
return nil, fmt.Errorf("failed to send query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response - RotatorGenius doesn't send newline, read fixed amount
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||||
|
defer c.conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
n, err := c.reader.Read(buf)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := string(buf[:n])
|
||||||
|
|
||||||
|
return c.parseStatus(response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseStatus(response string) *Status {
|
||||||
|
status := &Status{}
|
||||||
|
|
||||||
|
// Response format: |h2<null><heading>...
|
||||||
|
// Example: |h2\x00183 8 10A0...
|
||||||
|
// After |h2 there's a null byte, then 3 digits for heading
|
||||||
|
|
||||||
|
if !strings.HasPrefix(response, "|h2") {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip |h2 (3 chars) and null byte (1 char), then read 3 digits
|
||||||
|
if len(response) >= 7 {
|
||||||
|
// Position 3 is the null byte, position 4-6 are the heading
|
||||||
|
headingStr := response[4:7]
|
||||||
|
heading, err := strconv.Atoi(strings.TrimSpace(headingStr))
|
||||||
|
if err == nil {
|
||||||
|
status.Heading = heading
|
||||||
|
}
|
||||||
|
targetStr := response[19:22]
|
||||||
|
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||||
|
if err == nil {
|
||||||
|
status.TargetHeading = targetHeading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get next command ID from global counter
|
return status
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
|
||||||
|
|
||||||
// Format command with ID: C<id>|<command>
|
|
||||||
fullCmd := fmt.Sprintf("C%d%s", cmdID, cmd)
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
_, err := c.conn.Write([]byte(fullCmd))
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to send command: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read response
|
|
||||||
reader := bufio.NewReader(c.conn)
|
|
||||||
response, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(response), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetStatus() (*Status, error) {
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
resp, err := c.sendCommand("|h")
|
c.statusMu.RLock()
|
||||||
if err != nil {
|
defer c.statusMu.RUnlock()
|
||||||
return nil, err
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseStatusResponse(resp)
|
return c.lastStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseStatusResponse(resp string) (*Status, error) {
|
// SetHeading rotates to a specific azimuth
|
||||||
if len(resp) < 80 {
|
func (c *Client) SetHeading(azimuth int) error {
|
||||||
return nil, fmt.Errorf("response too short: %d bytes", len(resp))
|
cmd := fmt.Sprintf("|A1%d", azimuth)
|
||||||
}
|
return c.sendCommand(cmd)
|
||||||
|
|
||||||
status := &Status{}
|
|
||||||
|
|
||||||
// Parse panic flag
|
|
||||||
status.Panic = resp[3] != 0x00
|
|
||||||
|
|
||||||
// Parse Rotator 1 (positions 4-38)
|
|
||||||
status.Rotator1 = parseRotatorData(resp[4:38])
|
|
||||||
|
|
||||||
// Parse Rotator 2 (positions 38-72)
|
|
||||||
if len(resp) >= 72 {
|
|
||||||
status.Rotator2 = parseRotatorData(resp[38:72])
|
|
||||||
}
|
|
||||||
|
|
||||||
return status, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRotatorData(data string) RotatorData {
|
// RotateCW rotates clockwise
|
||||||
rd := RotatorData{}
|
func (c *Client) RotateCW() error {
|
||||||
|
return c.sendCommand("|P1")
|
||||||
// Current azimuth (3 bytes)
|
|
||||||
if azStr := strings.TrimSpace(data[0:3]); azStr != "999" {
|
|
||||||
rd.CurrentAzimuth, _ = strconv.Atoi(azStr)
|
|
||||||
rd.Connected = true
|
|
||||||
} else {
|
|
||||||
rd.CurrentAzimuth = 999
|
|
||||||
rd.Connected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limits
|
|
||||||
rd.LimitCW, _ = strconv.Atoi(strings.TrimSpace(data[3:6]))
|
|
||||||
rd.LimitCCW, _ = strconv.Atoi(strings.TrimSpace(data[6:9]))
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
rd.Configuration = string(data[9])
|
|
||||||
|
|
||||||
// Moving state
|
|
||||||
rd.Moving, _ = strconv.Atoi(string(data[10]))
|
|
||||||
|
|
||||||
// Offset
|
|
||||||
rd.Offset, _ = strconv.Atoi(strings.TrimSpace(data[11:15]))
|
|
||||||
|
|
||||||
// Target azimuth
|
|
||||||
if targetStr := strings.TrimSpace(data[15:18]); targetStr != "999" {
|
|
||||||
rd.TargetAzimuth, _ = strconv.Atoi(targetStr)
|
|
||||||
} else {
|
|
||||||
rd.TargetAzimuth = 999
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start azimuth
|
|
||||||
if startStr := strings.TrimSpace(data[18:21]); startStr != "999" {
|
|
||||||
rd.StartAzimuth, _ = strconv.Atoi(startStr)
|
|
||||||
} else {
|
|
||||||
rd.StartAzimuth = 999
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit flag
|
|
||||||
rd.OutsideLimit = data[21] == '1'
|
|
||||||
|
|
||||||
// Name
|
|
||||||
rd.Name = strings.TrimSpace(data[22:34])
|
|
||||||
|
|
||||||
return rd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) MoveToAzimuth(rotator int, azimuth int) error {
|
// RotateCCW rotates counter-clockwise
|
||||||
if rotator < 1 || rotator > 2 {
|
func (c *Client) RotateCCW() error {
|
||||||
return fmt.Errorf("rotator must be 1 or 2")
|
return c.sendCommand("|M1")
|
||||||
}
|
|
||||||
if azimuth < 0 || azimuth > 360 {
|
|
||||||
return fmt.Errorf("azimuth must be between 0 and 360")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("|A%d%03d", rotator, azimuth)
|
|
||||||
resp, err := c.sendCommand(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(resp, "K") {
|
|
||||||
return fmt.Errorf("command failed: %s", resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) RotateCW(rotator int) error {
|
|
||||||
if rotator < 1 || rotator > 2 {
|
|
||||||
return fmt.Errorf("rotator must be 1 or 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("|P%d", rotator)
|
|
||||||
resp, err := c.sendCommand(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(resp, "K") {
|
|
||||||
return fmt.Errorf("command failed: %s", resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) RotateCCW(rotator int) error {
|
|
||||||
if rotator < 1 || rotator > 2 {
|
|
||||||
return fmt.Errorf("rotator must be 1 or 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("|M%d", rotator)
|
|
||||||
resp, err := c.sendCommand(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(resp, "K") {
|
|
||||||
return fmt.Errorf("command failed: %s", resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop stops rotation
|
||||||
func (c *Client) Stop() error {
|
func (c *Client) Stop() error {
|
||||||
resp, err := c.sendCommand("|S")
|
return c.sendCommand("|S")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(resp, "K") {
|
|
||||||
return fmt.Errorf("command failed: %s", resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,64 +3,248 @@ package tunergenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
connMu sync.Mutex
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
PowerForward float64 `json:"power_forward"`
|
||||||
Bypass bool `json:"bypass"` // Bypass mode
|
PowerPeak float64 `json:"power_peak"`
|
||||||
ActiveAntenna int `json:"active_antenna"` // 0=ANT1, 1=ANT2, 2=ANT3
|
PowerMax float64 `json:"power_max"`
|
||||||
TuningStatus string `json:"tuning_status"`
|
SWR float64 `json:"swr"`
|
||||||
FrequencyA float64 `json:"frequency_a"`
|
PTTA int `json:"ptt_a"`
|
||||||
FrequencyB float64 `json:"frequency_b"`
|
BandA int `json:"band_a"`
|
||||||
C1 int `json:"c1"`
|
FreqA float64 `json:"frequency_a"`
|
||||||
L int `json:"l"`
|
BypassA bool `json:"bypass_a"`
|
||||||
C2 int `json:"c2"`
|
AntA int `json:"antenna_a"`
|
||||||
SWR float64 `json:"swr"`
|
PTTB int `json:"ptt_b"`
|
||||||
Power float64 `json:"power"`
|
BandB int `json:"band_b"`
|
||||||
Temperature float64 `json:"temperature"`
|
FreqB float64 `json:"frequency_b"`
|
||||||
Connected bool `json:"connected"`
|
BypassB bool `json:"bypass_b"`
|
||||||
|
AntB int `json:"antenna_b"`
|
||||||
|
State int `json:"state"`
|
||||||
|
Active int `json:"active"`
|
||||||
|
Tuning int `json:"tuning"`
|
||||||
|
Bypass bool `json:"bypass"`
|
||||||
|
RelayC1 int `json:"c1"`
|
||||||
|
RelayL int `json:"l"`
|
||||||
|
RelayC2 int `json:"c2"`
|
||||||
|
TuningStatus string `json:"tuning_status"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
|
||||||
|
// Peak hold for display (internal)
|
||||||
|
displayPower float64
|
||||||
|
peakTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect: %w", err)
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
|
|
||||||
|
// Read and discard version banner
|
||||||
|
reader := bufio.NewReader(c.conn)
|
||||||
|
_, _ = reader.ReadString('\n')
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.stopChan != nil {
|
||||||
|
close(c.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
// Start begins continuous polling of the device
|
||||||
if c.conn == nil {
|
func (c *Client) Start() error {
|
||||||
if err := c.Connect(); err != nil {
|
if c.running {
|
||||||
return "", err
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to connect, but don't fail if it doesn't work
|
||||||
|
// The poll loop will keep trying
|
||||||
|
_ = c.Connect()
|
||||||
|
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop continuously polls the device for status
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
// Try to reconnect if not connected
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected and reset all values
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{
|
||||||
|
Connected: false,
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
// Silent fail, will retry on next tick
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.connMu.Lock()
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
status, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
// Connection lost, close and retry next tick
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected and reset all values
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{
|
||||||
|
Connected: false,
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as connected
|
||||||
|
status.Connected = true
|
||||||
|
|
||||||
|
// Check if device is actually alive
|
||||||
|
// If all frequencies are 0, device is probably off
|
||||||
|
if status.FreqA == 0 && status.FreqB == 0 && status.PowerForward == 0 {
|
||||||
|
status.Connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak hold logic - keep highest power for 1 second
|
||||||
|
now := time.Now()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
// If new power is higher, update peak
|
||||||
|
if status.PowerForward > c.lastStatus.displayPower {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
} else {
|
||||||
|
// Check if peak has expired (1 second)
|
||||||
|
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||||
|
// Keep old peak
|
||||||
|
status.displayPower = c.lastStatus.displayPower
|
||||||
|
status.peakTime = c.lastStatus.peakTime
|
||||||
|
} else {
|
||||||
|
// Peak expired, use current value
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override PowerForward with display power for frontend
|
||||||
|
status.PowerForward = status.displayPower
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = status
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next command ID from global counter
|
||||||
|
cmdID := GetGlobalCommandID().GetNextID()
|
||||||
|
|
||||||
|
// Format command with ID: C<id>|status get
|
||||||
|
fullCmd := fmt.Sprintf("C%d|status get\n", cmdID)
|
||||||
|
|
||||||
|
// Send command
|
||||||
|
_, err := c.conn.Write([]byte(fullCmd))
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil
|
||||||
|
return nil, fmt.Errorf("failed to send command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
reader := bufio.NewReader(c.conn)
|
||||||
|
response, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.parseStatus(strings.TrimSpace(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return "", fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
// Get next command ID from global counter
|
// Get next command ID from global counter
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
cmdID := GetGlobalCommandID().GetNextID()
|
||||||
@@ -87,119 +271,156 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetStatus() (*Status, error) {
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
resp, err := c.sendCommand("status")
|
c.statusMu.RLock()
|
||||||
if err != nil {
|
defer c.statusMu.RUnlock()
|
||||||
return nil, err
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response - format will depend on actual device response
|
return c.lastStatus, nil
|
||||||
// This is a placeholder that should be updated based on real response format
|
}
|
||||||
|
|
||||||
|
func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||||
status := &Status{
|
status := &Status{
|
||||||
Connected: true,
|
Connected: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Parse actual status response from device
|
// Response format: S<id>|status fwd=21.19 peak=21.55 ...
|
||||||
// The response format needs to be determined from real device testing
|
// Extract the data part after "S<id>|status "
|
||||||
// For now, we just check if we got a response
|
idx := strings.Index(resp, "|status ")
|
||||||
_ = resp // Temporary: will be used when we parse the actual response format
|
if idx == -1 {
|
||||||
|
return nil, fmt.Errorf("invalid response format: %s", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := resp[idx+8:] // Skip "|status "
|
||||||
|
|
||||||
|
// Parse key=value pairs separated by spaces
|
||||||
|
pairs := strings.Fields(data)
|
||||||
|
|
||||||
|
for _, pair := range pairs {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := kv[0]
|
||||||
|
value := kv[1]
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "fwd":
|
||||||
|
// fwd is in dBm (e.g., 42.62 dBm)
|
||||||
|
// Formula: watts = 10^(dBm/10) / 1000
|
||||||
|
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
milliwatts := math.Pow(10, dBm/10.0)
|
||||||
|
status.PowerForward = milliwatts / 1000.0
|
||||||
|
}
|
||||||
|
case "peak":
|
||||||
|
// peak power in dBm
|
||||||
|
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
milliwatts := math.Pow(10, dBm/10.0)
|
||||||
|
status.PowerPeak = milliwatts / 1000.0
|
||||||
|
}
|
||||||
|
case "max":
|
||||||
|
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
milliwatts := math.Pow(10, dBm/10.0)
|
||||||
|
status.PowerMax = milliwatts / 1000.0
|
||||||
|
}
|
||||||
|
case "swr":
|
||||||
|
// SWR from return loss
|
||||||
|
// Formula: returnLoss = abs(swr) / 20
|
||||||
|
// swr = (10^returnLoss + 1) / (10^returnLoss - 1)
|
||||||
|
if swrRaw, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
|
returnLoss := math.Abs(swrRaw) / 20.0
|
||||||
|
tenPowRL := math.Pow(10, returnLoss)
|
||||||
|
calculatedSWR := (tenPowRL + 1) / (tenPowRL - 1)
|
||||||
|
status.SWR = calculatedSWR
|
||||||
|
}
|
||||||
|
case "pttA":
|
||||||
|
status.PTTA, _ = strconv.Atoi(value)
|
||||||
|
case "bandA":
|
||||||
|
status.BandA, _ = strconv.Atoi(value)
|
||||||
|
case "freqA":
|
||||||
|
status.FreqA, _ = strconv.ParseFloat(value, 64)
|
||||||
|
case "bypassA":
|
||||||
|
status.BypassA = value == "1"
|
||||||
|
case "antA":
|
||||||
|
status.AntA, _ = strconv.Atoi(value)
|
||||||
|
case "pttB":
|
||||||
|
status.PTTB, _ = strconv.Atoi(value)
|
||||||
|
case "bandB":
|
||||||
|
status.BandB, _ = strconv.Atoi(value)
|
||||||
|
case "freqB":
|
||||||
|
status.FreqB, _ = strconv.ParseFloat(value, 64)
|
||||||
|
case "bypassB":
|
||||||
|
status.BypassB = value == "1"
|
||||||
|
case "antB":
|
||||||
|
status.AntB, _ = strconv.Atoi(value)
|
||||||
|
case "state":
|
||||||
|
status.State, _ = strconv.Atoi(value)
|
||||||
|
case "active":
|
||||||
|
status.Active, _ = strconv.Atoi(value)
|
||||||
|
case "tuning":
|
||||||
|
status.Tuning, _ = strconv.Atoi(value)
|
||||||
|
if status.Tuning == 1 {
|
||||||
|
status.TuningStatus = "TUNING"
|
||||||
|
} else {
|
||||||
|
status.TuningStatus = "READY"
|
||||||
|
}
|
||||||
|
case "bypass":
|
||||||
|
status.Bypass = value == "1"
|
||||||
|
case "relayC1":
|
||||||
|
status.RelayC1, _ = strconv.Atoi(value)
|
||||||
|
case "relayL":
|
||||||
|
status.RelayL, _ = strconv.Atoi(value)
|
||||||
|
case "relayC2":
|
||||||
|
status.RelayC2, _ = strconv.Atoi(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SetOperate(operate bool) error {
|
// SetOperate switches between STANDBY (0) and OPERATE (1)
|
||||||
var state int
|
func (c *Client) SetOperate(value int) error {
|
||||||
if operate {
|
if value != 0 && value != 1 {
|
||||||
state = 1
|
return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := fmt.Sprintf("operate set=%d", state)
|
cmd := fmt.Sprintf("operate set=%d", value)
|
||||||
resp, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if command was successful
|
|
||||||
if resp == "" {
|
|
||||||
return fmt.Errorf("empty response from device")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SetBypass(bypass bool) error {
|
// SetBypass sets BYPASS mode
|
||||||
var state int
|
func (c *Client) SetBypass(value int) error {
|
||||||
if bypass {
|
if value != 0 && value != 1 {
|
||||||
state = 1
|
return fmt.Errorf("invalid bypass value: %d (must be 0 or 1)", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := fmt.Sprintf("bypass set=%d", state)
|
cmd := fmt.Sprintf("bypass set=%d", value)
|
||||||
resp, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if command was successful
|
|
||||||
if resp == "" {
|
|
||||||
return fmt.Errorf("empty response from device")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) ActivateAntenna(antenna int) error {
|
|
||||||
if antenna < 0 || antenna > 2 {
|
|
||||||
return fmt.Errorf("antenna must be 0 (ANT1), 1 (ANT2), or 2 (ANT3)")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("activate ant=%d", antenna)
|
|
||||||
resp, err := c.sendCommand(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if command was successful
|
|
||||||
if resp == "" {
|
|
||||||
return fmt.Errorf("empty response from device")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoTune starts a tuning cycle
|
||||||
func (c *Client) AutoTune() error {
|
func (c *Client) AutoTune() error {
|
||||||
resp, err := c.sendCommand("autotune")
|
_, err := c.sendCommand("autotune")
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if command was successful
|
|
||||||
if resp == "" {
|
|
||||||
return fmt.Errorf("empty response from device")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TuneRelay adjusts tuning parameters manually
|
// TuneRelay adjusts one tuning parameter by one step
|
||||||
// relay: 0=C1, 1=L, 2=C2
|
// relay: 0=C1, 1=L, 2=C2
|
||||||
// move: -1 to decrease, 1 to increase
|
// move: -1 (decrease) or 1 (increase)
|
||||||
func (c *Client) TuneRelay(relay int, move int) error {
|
func (c *Client) TuneRelay(relay, move int) error {
|
||||||
if relay < 0 || relay > 2 {
|
if relay < 0 || relay > 2 {
|
||||||
return fmt.Errorf("relay must be 0 (C1), 1 (L), or 2 (C2)")
|
return fmt.Errorf("invalid relay: %d (must be 0, 1, or 2)", relay)
|
||||||
}
|
}
|
||||||
if move != -1 && move != 1 {
|
if move != -1 && move != 1 {
|
||||||
return fmt.Errorf("move must be -1 or 1")
|
return fmt.Errorf("invalid move: %d (must be -1 or 1)", move)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move)
|
cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move)
|
||||||
resp, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if command was successful
|
|
||||||
if resp == "" {
|
|
||||||
return fmt.Errorf("empty response from device")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
448
internal/devices/ultrabeam/ultrabeam.go
Normal file
448
internal/devices/ultrabeam/ultrabeam.go
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
package ultrabeam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol constants
|
||||||
|
const (
|
||||||
|
STX byte = 0xF5 // 245 decimal
|
||||||
|
ETX byte = 0xFA // 250 decimal
|
||||||
|
DLE byte = 0xF6 // 246 decimal
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command codes
|
||||||
|
const (
|
||||||
|
CMD_STATUS byte = 1 // General status query
|
||||||
|
CMD_RETRACT byte = 2 // Retract elements
|
||||||
|
CMD_FREQ byte = 3 // Change frequency
|
||||||
|
CMD_READ_BANDS byte = 9 // Read current band adjustments
|
||||||
|
CMD_PROGRESS byte = 10 // Read progress bar
|
||||||
|
CMD_MODIFY_ELEM byte = 12 // Modify element length
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reply codes
|
||||||
|
const (
|
||||||
|
UB_OK byte = 0 // Normal execution
|
||||||
|
UB_BAD byte = 1 // Invalid command
|
||||||
|
UB_PAR byte = 2 // Bad parameters
|
||||||
|
UB_ERR byte = 3 // Error executing command
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction modes
|
||||||
|
const (
|
||||||
|
DIR_NORMAL byte = 0
|
||||||
|
DIR_180 byte = 1
|
||||||
|
DIR_BIDIR byte = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
conn net.Conn
|
||||||
|
connMu sync.Mutex
|
||||||
|
reader *bufio.Reader
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
|
seqNum byte
|
||||||
|
seqMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
FirmwareMinor int `json:"firmware_minor"`
|
||||||
|
FirmwareMajor int `json:"firmware_major"`
|
||||||
|
CurrentOperation int `json:"current_operation"`
|
||||||
|
Frequency int `json:"frequency"` // KHz
|
||||||
|
Band int `json:"band"`
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
OffState bool `json:"off_state"`
|
||||||
|
MotorsMoving int `json:"motors_moving"` // Bitmask
|
||||||
|
FreqMin int `json:"freq_min"` // MHz
|
||||||
|
FreqMax int `json:"freq_max"` // MHz
|
||||||
|
ElementLengths []int `json:"element_lengths"` // mm
|
||||||
|
ProgressTotal int `json:"progress_total"` // mm
|
||||||
|
ProgressCurrent int `json:"progress_current"` // 0-60
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
seqNum: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
pollCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
pollCount++
|
||||||
|
|
||||||
|
// Try to connect if not connected
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ultrabeam: Connection failed: %v", err)
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Query status
|
||||||
|
status, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
||||||
|
// Close connection and retry
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as connected
|
||||||
|
status.Connected = true
|
||||||
|
|
||||||
|
// Query progress if motors moving
|
||||||
|
if status.MotorsMoving != 0 {
|
||||||
|
progress, err := c.queryProgress()
|
||||||
|
if err == nil {
|
||||||
|
status.ProgressTotal = progress[0]
|
||||||
|
status.ProgressCurrent = progress[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Motors stopped - reset progress
|
||||||
|
status.ProgressTotal = 0
|
||||||
|
status.ProgressCurrent = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = status
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.lastStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextSeq returns the next sequence number
|
||||||
|
func (c *Client) getNextSeq() byte {
|
||||||
|
c.seqMu.Lock()
|
||||||
|
defer c.seqMu.Unlock()
|
||||||
|
|
||||||
|
seq := c.seqNum
|
||||||
|
c.seqNum = (c.seqNum + 1) % 128
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateChecksum calculates the checksum for a packet
|
||||||
|
func calculateChecksum(data []byte) byte {
|
||||||
|
chk := byte(0x55)
|
||||||
|
for _, b := range data {
|
||||||
|
chk ^= b
|
||||||
|
chk++
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteByte handles DLE escaping
|
||||||
|
func quoteByte(b byte) []byte {
|
||||||
|
if b == STX || b == ETX || b == DLE {
|
||||||
|
return []byte{DLE, b & 0x7F} // Clear MSB
|
||||||
|
}
|
||||||
|
return []byte{b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPacket creates a complete packet with checksum and escaping
|
||||||
|
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
||||||
|
seq := c.getNextSeq()
|
||||||
|
|
||||||
|
// Calculate checksum on unquoted data
|
||||||
|
payload := append([]byte{seq, cmd}, data...)
|
||||||
|
chk := calculateChecksum(payload)
|
||||||
|
|
||||||
|
// Build packet with quoting
|
||||||
|
packet := []byte{STX}
|
||||||
|
|
||||||
|
// Add quoted SEQ
|
||||||
|
packet = append(packet, quoteByte(seq)...)
|
||||||
|
|
||||||
|
// Add quoted CMD
|
||||||
|
packet = append(packet, quoteByte(cmd)...)
|
||||||
|
|
||||||
|
// Add quoted data
|
||||||
|
for _, b := range data {
|
||||||
|
packet = append(packet, quoteByte(b)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quoted checksum
|
||||||
|
packet = append(packet, quoteByte(chk)...)
|
||||||
|
|
||||||
|
// Add ETX
|
||||||
|
packet = append(packet, ETX)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePacket parses a received packet, handling DLE unescaping
|
||||||
|
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
|
||||||
|
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
|
||||||
|
return 0, 0, nil, fmt.Errorf("packet too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[0] != STX {
|
||||||
|
return 0, 0, nil, fmt.Errorf("missing STX")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[len(data)-1] != ETX {
|
||||||
|
return 0, 0, nil, fmt.Errorf("missing ETX")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unquote the data
|
||||||
|
var unquoted []byte
|
||||||
|
dle := false
|
||||||
|
for i := 1; i < len(data)-1; i++ {
|
||||||
|
b := data[i]
|
||||||
|
if b == DLE {
|
||||||
|
dle = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dle {
|
||||||
|
b |= 0x80 // Set MSB
|
||||||
|
dle = false
|
||||||
|
}
|
||||||
|
unquoted = append(unquoted, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unquoted) < 3 {
|
||||||
|
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
seq = unquoted[0]
|
||||||
|
cmd = unquoted[1]
|
||||||
|
chk := unquoted[len(unquoted)-1]
|
||||||
|
payload = unquoted[2 : len(unquoted)-1]
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
|
||||||
|
if calcChk != chk {
|
||||||
|
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return seq, cmd, payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCommand sends a command and waits for reply
|
||||||
|
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and send packet
|
||||||
|
packet := c.buildPacket(cmd, data)
|
||||||
|
|
||||||
|
_, err := c.conn.Write(packet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply with timeout
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
|
||||||
|
|
||||||
|
// Read until we get a complete packet
|
||||||
|
var buffer []byte
|
||||||
|
for {
|
||||||
|
b, err := c.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = append(buffer, b)
|
||||||
|
|
||||||
|
// Check if we have a complete packet
|
||||||
|
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent infinite loop
|
||||||
|
if len(buffer) > 256 {
|
||||||
|
return nil, fmt.Errorf("packet too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reply
|
||||||
|
_, replyCmd, payload, err := parsePacket(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log for debugging unknown codes
|
||||||
|
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
|
||||||
|
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
switch replyCmd {
|
||||||
|
case UB_BAD:
|
||||||
|
return nil, fmt.Errorf("invalid command")
|
||||||
|
case UB_PAR:
|
||||||
|
return nil, fmt.Errorf("bad parameters")
|
||||||
|
case UB_ERR:
|
||||||
|
return nil, fmt.Errorf("execution error")
|
||||||
|
case UB_OK:
|
||||||
|
return payload, nil
|
||||||
|
default:
|
||||||
|
// Unknown codes might indicate "busy" or "in progress"
|
||||||
|
// Treat as non-fatal, return empty payload
|
||||||
|
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
|
||||||
|
return []byte{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryStatus queries general status (command 1)
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 12 {
|
||||||
|
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &Status{
|
||||||
|
FirmwareMinor: int(reply[0]),
|
||||||
|
FirmwareMajor: int(reply[1]),
|
||||||
|
CurrentOperation: int(reply[2]),
|
||||||
|
Frequency: int(reply[3]) | (int(reply[4]) << 8),
|
||||||
|
Band: int(reply[5]),
|
||||||
|
Direction: int(reply[6] & 0x0F),
|
||||||
|
OffState: (reply[7] & 0x02) != 0,
|
||||||
|
MotorsMoving: int(reply[9]),
|
||||||
|
FreqMin: int(reply[10]),
|
||||||
|
FreqMax: int(reply[11]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryProgress queries motor progress (command 10)
|
||||||
|
func (c *Client) queryProgress() ([]int, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 4 {
|
||||||
|
return nil, fmt.Errorf("progress reply too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int(reply[0]) | (int(reply[1]) << 8)
|
||||||
|
current := int(reply[2]) | (int(reply[3]) << 8)
|
||||||
|
|
||||||
|
return []int{total, current}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
data := []byte{
|
||||||
|
byte(freqKhz & 0xFF),
|
||||||
|
byte((freqKhz >> 8) & 0xFF),
|
||||||
|
byte(direction),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.sendCommand(CMD_FREQ, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract retracts all elements (command 2)
|
||||||
|
func (c *Client) Retract() error {
|
||||||
|
_, err := c.sendCommand(CMD_RETRACT, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyElement modifies element length (command 12)
|
||||||
|
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||||
|
if elementNum < 0 || elementNum > 5 {
|
||||||
|
return fmt.Errorf("invalid element number: %d", elementNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte{
|
||||||
|
byte(elementNum),
|
||||||
|
0, // Reserved
|
||||||
|
byte(lengthMm & 0xFF),
|
||||||
|
byte((lengthMm >> 8) & 0xFF),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Relays []RelayState `json:"relays"`
|
Relays []RelayState `json:"relays"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelayState struct {
|
type RelayState struct {
|
||||||
@@ -67,20 +68,56 @@ func (c *Client) TurnOff(relay int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) AllOn() error {
|
func (c *Client) AllOn() error {
|
||||||
for i := 1; i <= 5; i++ {
|
// Sequence for ALL ON:
|
||||||
if err := c.TurnOn(i); err != nil {
|
// 1. Turn on relays 1, 2, 3, 5 immediately
|
||||||
return fmt.Errorf("failed to turn on relay %d: %w", i, err)
|
// 2. Wait 5 seconds
|
||||||
|
// 3. Turn on relay 4 (Flex Radio Start)
|
||||||
|
|
||||||
|
// Turn on relays 1, 2, 3, 5
|
||||||
|
for _, relay := range []int{1, 2, 3, 5} {
|
||||||
|
if err := c.TurnOn(relay); err != nil {
|
||||||
|
return fmt.Errorf("failed to turn on relay %d: %w", relay, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait 5 seconds for power supply to stabilize
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
// Turn on relay 4 (Flex Radio)
|
||||||
|
if err := c.TurnOn(4); err != nil {
|
||||||
|
return fmt.Errorf("failed to turn on relay 4: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) AllOff() error {
|
func (c *Client) AllOff() error {
|
||||||
for i := 1; i <= 5; i++ {
|
// Sequence for ALL OFF:
|
||||||
if err := c.TurnOff(i); err != nil {
|
// 1. Turn off relay 4 (Flex Radio) immediately
|
||||||
return fmt.Errorf("failed to turn off relay %d: %w", i, err)
|
// 2. Turn off relays 2, 3, 5 immediately
|
||||||
|
// 3. Wait 35 seconds for Flex Radio to shut down
|
||||||
|
// 4. Turn off relay 1 (Power Supply)
|
||||||
|
|
||||||
|
// Turn off relay 4 (Flex Radio)
|
||||||
|
if err := c.TurnOff(4); err != nil {
|
||||||
|
return fmt.Errorf("failed to turn off relay 4: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn off relays 2, 3, 5
|
||||||
|
for _, relay := range []int{2, 3, 5} {
|
||||||
|
if err := c.TurnOff(relay); err != nil {
|
||||||
|
return fmt.Errorf("failed to turn off relay %d: %w", relay, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait 35 seconds for Flex Radio to shut down properly
|
||||||
|
time.Sleep(35 * time.Second)
|
||||||
|
|
||||||
|
// Turn off relay 1 (Power Supply)
|
||||||
|
if err := c.TurnOff(1); err != nil {
|
||||||
|
return fmt.Errorf("failed to turn off relay 1: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +142,8 @@ func (c *Client) GetStatus() (*Status, error) {
|
|||||||
|
|
||||||
// Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
|
// Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
|
||||||
status := &Status{
|
status := &Status{
|
||||||
Relays: make([]RelayState, 0, 5),
|
Relays: make([]RelayState, 0, 5),
|
||||||
|
Connected: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ShackMaster - XV9Q Shack</title>
|
<title>ShackMaster - F4BPO Shack</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
478
web/src/app.css
478
web/src/app.css
@@ -1,128 +1,438 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1a1a;
|
/* Modern dark theme inspired by FlexDXCluster */
|
||||||
--bg-secondary: #2a2a2a;
|
--bg-primary: #0a1628;
|
||||||
--bg-card: #333333;
|
--bg-secondary: #1a2332;
|
||||||
--text-primary: #ffffff;
|
--bg-tertiary: #243447;
|
||||||
--text-secondary: #b0b0b0;
|
--bg-hover: #2a3f5f;
|
||||||
--accent-teal: #00bcd4;
|
|
||||||
--accent-green: #4caf50;
|
--text-primary: #e0e6ed;
|
||||||
--accent-red: #f44336;
|
--text-secondary: #a0aec0;
|
||||||
|
--text-muted: #718096;
|
||||||
|
|
||||||
|
--accent-cyan: #4fc3f7;
|
||||||
--accent-blue: #2196f3;
|
--accent-blue: #2196f3;
|
||||||
--border-color: #444444;
|
--accent-green: #4caf50;
|
||||||
|
--accent-orange: #ff9800;
|
||||||
|
--accent-red: #f44336;
|
||||||
|
--accent-purple: #9c27b0;
|
||||||
|
--accent-yellow: #ffc107;
|
||||||
|
|
||||||
|
--border-color: #2d3748;
|
||||||
|
--border-light: #374151;
|
||||||
|
|
||||||
|
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
--card-radius: 6px;
|
||||||
|
|
||||||
|
--header-height: 56px;
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 12px;
|
||||||
|
--spacing-lg: 16px;
|
||||||
|
--spacing-xl: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
padding: 0;
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
sans-serif;
|
||||||
background-color: var(--bg-primary);
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
.app {
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
/* ==================== HEADER ==================== */
|
||||||
font-family: inherit;
|
header {
|
||||||
cursor: pointer;
|
height: var(--header-height);
|
||||||
border: none;
|
background: var(--bg-secondary);
|
||||||
outline: none;
|
border-bottom: 1px solid var(--border-color);
|
||||||
transition: all 0.2s ease;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
.header-left {
|
||||||
transform: translateY(-1px);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:active {
|
.header-left h1 {
|
||||||
transform: translateY(0);
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select {
|
.connection-status {
|
||||||
font-family: inherit;
|
display: flex;
|
||||||
outline: none;
|
align-items: center;
|
||||||
}
|
gap: var(--spacing-sm);
|
||||||
|
font-size: 12px;
|
||||||
.card {
|
color: var(--text-secondary);
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
display: inline-block;
|
width: 8px;
|
||||||
width: 12px;
|
height: 8px;
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 8px;
|
background: var(--accent-red);
|
||||||
|
transition: background 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-online {
|
.status-indicator.status-online {
|
||||||
background-color: var(--accent-green);
|
background: var(--accent-green);
|
||||||
box-shadow: 0 0 8px var(--accent-green);
|
box-shadow: 0 0 8px var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-offline {
|
.header-center {
|
||||||
background-color: var(--text-secondary);
|
display: flex;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.solar-info {
|
||||||
padding: 12px 24px;
|
display: flex;
|
||||||
border-radius: 6px;
|
gap: var(--spacing-md);
|
||||||
font-weight: 500;
|
font-size: 12px;
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.solar-item {
|
||||||
background: var(--accent-green);
|
color: var(--text-secondary);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.solar-item .value {
|
||||||
background: #45a049;
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.header-right {
|
||||||
background: var(--accent-red);
|
display: flex;
|
||||||
color: white;
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.weather-info {
|
||||||
background: #da190b;
|
display: flex;
|
||||||
}
|
gap: var(--spacing-md);
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-display {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
}
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 4px;
|
.clock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock .time {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock .date {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== MAIN CONTENT ==================== */
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== CARDS ==================== */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--card-radius);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2::before {
|
||||||
|
content: '';
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== LABELS & VALUES ==================== */
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== BUTTONS ==================== */
|
||||||
|
button, .button {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover, .button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active, .button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: #29b6f6;
|
||||||
|
border-color: #29b6f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.success {
|
||||||
|
background: var(--accent-green);
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: var(--accent-red);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== SELECT ==================== */
|
||||||
|
select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:hover {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== BADGES ==================== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.green {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.red {
|
||||||
|
background: rgba(244, 67, 54, 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.orange {
|
||||||
|
background: rgba(255, 152, 0, 0.2);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.cyan {
|
||||||
|
background: rgba(79, 195, 247, 0.2);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.purple {
|
||||||
|
background: rgba(156, 39, 176, 0.2);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== PROGRESS BARS ==================== */
|
||||||
|
.bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-green), var(--accent-orange), var(--accent-red));
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== METRICS ==================== */
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric.small {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== SCROLLBAR ==================== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== RESPONSIVE ==================== */
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,127 +3,337 @@
|
|||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
$: radio1Antenna = status?.radio1_antenna || 0;
|
|
||||||
$: radio2Antenna = status?.radio2_antenna || 0;
|
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
|
$: portA = status?.port_a || {};
|
||||||
|
$: portB = status?.port_b || {};
|
||||||
|
$: antennas = status?.antennas || [];
|
||||||
|
|
||||||
async function setRadioAntenna(radio, antenna) {
|
// Band names
|
||||||
|
const bandNames = {
|
||||||
|
0: '160M', 1: '80M', 2: '60M', 3: '40M', 4: '30M',
|
||||||
|
5: '20M', 6: '17M', 7: '15M', 8: '12M', 9: '10M',
|
||||||
|
10: '6M', 11: '4M', 12: '2M', 13: '1.25M', 14: '70CM', 15: 'GEN'
|
||||||
|
};
|
||||||
|
|
||||||
|
$: bandAName = bandNames[portA.band] || 'None';
|
||||||
|
$: bandBName = bandNames[portB.band] || 'None';
|
||||||
|
|
||||||
|
async function selectAntenna(port, antennaNum) {
|
||||||
try {
|
try {
|
||||||
await api.antenna.set(radio, antenna);
|
// Check if antenna is already selected on this port
|
||||||
|
const isAlreadySelected = (port === 1 && portA.rx_ant === antennaNum) ||
|
||||||
|
(port === 2 && portB.rx_ant === antennaNum);
|
||||||
|
|
||||||
|
if (isAlreadySelected) {
|
||||||
|
// Deselect: set rxant to 00
|
||||||
|
console.log(`Deselecting antenna ${antennaNum} from port ${port}`);
|
||||||
|
await api.antenna.deselectAntenna(port, antennaNum);
|
||||||
|
} else {
|
||||||
|
// Select normally
|
||||||
|
console.log(`Selecting antenna ${antennaNum} on port ${port}`);
|
||||||
|
await api.antenna.selectAntenna(port, antennaNum);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set antenna:', err);
|
console.error('Failed to select/deselect antenna:', err);
|
||||||
|
// No popup, just log the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug TX state - only log when tx state changes, not on every update
|
||||||
|
let lastTxStateA = false;
|
||||||
|
let lastTxStateB = false;
|
||||||
|
$: if (status && (portA.tx !== lastTxStateA || portB.tx !== lastTxStateB)) {
|
||||||
|
console.log('AntennaGenius TX state changed:', {
|
||||||
|
portA_tx: portA.tx,
|
||||||
|
portB_tx: portB.tx,
|
||||||
|
portA_tx_ant: portA.tx_ant,
|
||||||
|
portB_tx_ant: portB.tx_ant
|
||||||
|
});
|
||||||
|
lastTxStateA = portA.tx;
|
||||||
|
lastTxStateB = portB.tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reboot() {
|
||||||
|
if (!confirm('Are you sure you want to reboot the Antenna Genius?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.antenna.reboot();
|
||||||
|
console.log('Antenna Genius reboot command sent');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reboot:', err);
|
||||||
|
// No popup, just log
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="antenna-card card">
|
<div class="card">
|
||||||
<h2>
|
<div class="card-header">
|
||||||
AG 8X2
|
<h2>Antenna Genius</h2>
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<div class="radio-section">
|
<div class="metrics">
|
||||||
<div class="radio-label">Radio 1 / Radio 2</div>
|
<!-- Radio Sources -->
|
||||||
|
<div class="sources">
|
||||||
<div class="radio-grid">
|
<div class="source-item">
|
||||||
<div class="radio-column">
|
<div class="source-label">{portA.source || 'FLEX'}</div>
|
||||||
<div class="radio-title">Radio 1</div>
|
|
||||||
<div class="antenna-slots">
|
|
||||||
{#each Array(4) as _, i}
|
|
||||||
<button
|
|
||||||
class="slot"
|
|
||||||
class:active={radio1Antenna === i}
|
|
||||||
on:click={() => setRadioAntenna(1, i)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="source-item">
|
||||||
<div class="radio-column">
|
<div class="source-label">{portB.source || 'FLEX'}</div>
|
||||||
<div class="radio-title">Radio 2</div>
|
|
||||||
<div class="antenna-slots">
|
|
||||||
{#each Array(4) as _, i}
|
|
||||||
<button
|
|
||||||
class="slot"
|
|
||||||
class:active={radio2Antenna === i}
|
|
||||||
on:click={() => setRadioAntenna(2, i)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bands -->
|
||||||
|
<div class="bands">
|
||||||
|
<div class="band-item">
|
||||||
|
<div class="band-value">{bandAName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="band-item">
|
||||||
|
<div class="band-value">{bandBName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Antennas -->
|
||||||
|
<div class="antennas">
|
||||||
|
{#each antennas as antenna}
|
||||||
|
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
||||||
|
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
||||||
|
{@const isPortARx = !portA.tx && portA.rx_ant === antenna.number}
|
||||||
|
{@const isPortBRx = !portB.tx && portB.rx_ant === antenna.number}
|
||||||
|
{@const isTx = isPortATx || isPortBTx}
|
||||||
|
{@const isActiveA = isPortARx || isPortATx}
|
||||||
|
{@const isActiveB = isPortBRx || isPortBTx}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="antenna-card"
|
||||||
|
class:tx={isTx}
|
||||||
|
class:active-a={isActiveA}
|
||||||
|
class:active-b={isActiveB}
|
||||||
|
>
|
||||||
|
<div class="antenna-name">{antenna.name}</div>
|
||||||
|
<div class="antenna-ports">
|
||||||
|
<button
|
||||||
|
class="port-btn"
|
||||||
|
class:active={isActiveA}
|
||||||
|
on:click={() => selectAntenna(1, antenna.number)}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="port-btn"
|
||||||
|
class:active={isActiveB}
|
||||||
|
on:click={() => selectAntenna(2, antenna.number)}
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reboot Button -->
|
||||||
|
<button class="reboot-btn" on:click={reboot}>
|
||||||
|
<span class="reboot-icon">🔄</span>
|
||||||
|
REBOOT
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.antenna-card {
|
.card {
|
||||||
min-width: 300px;
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-column {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-title {
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
margin-bottom: 8px;
|
color: var(--accent-cyan);
|
||||||
text-align: center;
|
margin: 0;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.antenna-slots {
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 8px #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #f44336;
|
||||||
|
box-shadow: 0 0 8px #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sources */
|
||||||
|
.sources {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot {
|
.source-item {
|
||||||
padding: 16px;
|
padding: 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bands */
|
||||||
|
.bands {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-item {
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(79, 195, 247, 0.1);
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Antennas */
|
||||||
|
.antennas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antenna-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 16px;
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antenna-card.active-a {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
border-color: #4caf50;
|
||||||
|
box-shadow: 0 0 20px rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.antenna-card.active-b {
|
||||||
|
background: rgba(33, 150, 243, 0.2);
|
||||||
|
border-color: #2196f3;
|
||||||
|
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TX must come AFTER active-a/active-b to override */
|
||||||
|
.antenna-card.tx {
|
||||||
|
background: rgba(244, 67, 54, 0.2) !important;
|
||||||
|
border-color: #f44336 !important;
|
||||||
|
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antenna-name {
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot:hover {
|
.antenna-ports {
|
||||||
border-color: var(--accent-blue);
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot.active {
|
.port-btn {
|
||||||
background: var(--accent-blue);
|
width: 36px;
|
||||||
border-color: var(--accent-blue);
|
height: 36px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-btn:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 0 12px rgba(79, 195, 247, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reboot Button */
|
||||||
|
.reboot-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #ff9800, #f57c00);
|
||||||
color: white;
|
color: white;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(255, 152, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reboot-icon {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,209 +1,447 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
$: powerForward = status?.power_forward || 0;
|
$: powerForward = status?.power_forward || 0;
|
||||||
$: powerReflected = status?.power_reflected || 0;
|
$: powerReflected = status?.power_reflected || 0;
|
||||||
$: swr = status?.swr || 1.0;
|
$: swr = status?.swr || 1.0;
|
||||||
$: voltage = status?.voltage || 0;
|
|
||||||
$: vdd = status?.vdd || 0;
|
|
||||||
$: 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) {
|
$: voltage = status?.voltage || 0;
|
||||||
try {
|
$: vdd = status?.vdd || 0;
|
||||||
await api.power.setFanMode(mode);
|
$: current = status?.current || 0;
|
||||||
} catch (err) {
|
$: peakCurrent = status?.peak_current || 0;
|
||||||
console.error('Failed to set fan mode:', err);
|
$: temperature = status?.temperature || 0;
|
||||||
alert('Failed to set fan mode');
|
$: harmonicLoadTemp = status?.harmonic_load_temp || 0;
|
||||||
|
$: fanMode = status?.fan_mode || 'CONTEST';
|
||||||
|
$: state = status?.state || 'IDLE';
|
||||||
|
$: bandA = status?.band_a || '0';
|
||||||
|
$: bandB = status?.band_b || '0';
|
||||||
|
$: connected = status?.connected || false;
|
||||||
|
|
||||||
|
$: displayState = state.replace('TRANSMIT_A', 'TRANSMIT').replace('TRANSMIT_B', 'TRANSMIT');
|
||||||
|
|
||||||
|
// Color functions
|
||||||
|
$: tempColor = temperature < 40 ? '#4caf50' : temperature < 60 ? '#ffc107' : temperature < 75 ? '#ff9800' : '#f44336';
|
||||||
|
$: swrColor = swr < 1.5 ? '#4caf50' : swr < 2.0 ? '#ffc107' : swr < 3.0 ? '#ff9800' : '#f44336';
|
||||||
|
$: powerPercent = Math.min((powerForward / 2000) * 100, 100);
|
||||||
|
|
||||||
|
async function setFanMode(mode) {
|
||||||
|
try {
|
||||||
|
await api.power.setFanMode(mode);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set fan mode:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
async function toggleOperate() {
|
||||||
|
try {
|
||||||
|
const operateValue = state === 'IDLE' ? 0 : 1;
|
||||||
|
await api.power.setOperate(operateValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle operate:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="powergenius-card card">
|
<div class="card">
|
||||||
<h2>
|
<div class="card-header">
|
||||||
PGXL
|
<h2>Power Genius XL</h2>
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
<div class="header-right">
|
||||||
</h2>
|
<button
|
||||||
|
class="state-badge"
|
||||||
<div class="status-row">
|
class:idle={state === 'IDLE'}
|
||||||
<div class="status-label" class:normal={state === 'IDLE'} class:warning={state.includes('TRANSMIT')}>
|
class:transmit={state.includes('TRANSMIT')}
|
||||||
{displayState}
|
on:click={toggleOperate}
|
||||||
|
>
|
||||||
|
{displayState}
|
||||||
|
</button>
|
||||||
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<div class="metric">
|
<!-- Power Display + SWR Side by Side -->
|
||||||
<div class="label">FWD PWR (W)</div>
|
<div class="power-swr-row">
|
||||||
<div class="value">{powerForward.toFixed(1)}</div>
|
<div class="power-section">
|
||||||
<div class="bar">
|
<div class="power-header">
|
||||||
<div class="bar-fill" style="width: {Math.min(100, (powerForward / 1500) * 100)}%"></div>
|
<span class="power-label-inline">Power</span>
|
||||||
|
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||||
|
</div>
|
||||||
|
<div class="power-bar-container">
|
||||||
|
<div class="power-bar-bg">
|
||||||
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="scale">
|
|
||||||
<span>0</span>
|
<!-- SWR Circle Compact -->
|
||||||
<span>1000</span>
|
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||||
<span>2000</span>
|
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||||
|
<div class="swr-label-compact">SWR</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric">
|
<!-- Temperature Gauges -->
|
||||||
<div class="label">PG XL SWR 1:1.00 use</div>
|
<div class="temp-group">
|
||||||
<div class="value">{swr.toFixed(2)}</div>
|
<div class="temp-item">
|
||||||
</div>
|
<div class="temp-value" style="color: {tempColor}">{temperature.toFixed(1)}°</div>
|
||||||
|
<div class="temp-label">PA Temp</div>
|
||||||
<div class="metric">
|
<div class="temp-mini-bar">
|
||||||
<div class="label">Temp / HL Temp</div>
|
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
||||||
<div class="value">{temperature.toFixed(0)}°C / {harmonicLoadTemp.toFixed(1)}°C</div>
|
</div>
|
||||||
<div class="bar">
|
|
||||||
<div class="bar-fill" style="width: {(temperature / 80) * 100}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="scale">
|
<div class="temp-item">
|
||||||
<span>25</span>
|
<div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(1)}°</div>
|
||||||
<span>55</span>
|
<div class="temp-label">HL Temp</div>
|
||||||
<span>80</span>
|
<div class="temp-mini-bar">
|
||||||
|
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-row">
|
<!-- Electrical Parameters -->
|
||||||
<div class="metric small">
|
<div class="params-grid">
|
||||||
<div class="label">VAC</div>
|
<div class="param-box">
|
||||||
<div class="value">{voltage.toFixed(0)}</div>
|
<div class="param-label">VAC</div>
|
||||||
|
<div class="param-value">{voltage.toFixed(0)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric small">
|
<div class="param-box">
|
||||||
<div class="label">VDD</div>
|
<div class="param-label">VDD</div>
|
||||||
<div class="value">{vdd.toFixed(1)}</div>
|
<div class="param-value">{vdd.toFixed(1)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric small">
|
<div class="param-box">
|
||||||
<div class="label">ID peak</div>
|
<div class="param-label">ID Peak</div>
|
||||||
<div class="value">{peakCurrent.toFixed(1)}</div>
|
<div class="param-value">{peakCurrent.toFixed(1)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fan-speed">
|
<!-- Band Display -->
|
||||||
<div class="label">Fan Speed</div>
|
<div class="band-display">
|
||||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
<div class="band-item">
|
||||||
<option value="STANDARD">STANDARD</option>
|
<span class="band-label">Band A</span>
|
||||||
<option value="CONTEST">CONTEST</option>
|
<span class="band-value">{bandA}</span>
|
||||||
<option value="BROADCAST">BROADCAST</option>
|
</div>
|
||||||
|
<div class="band-item">
|
||||||
|
<span class="band-label">Band B</span>
|
||||||
|
<span class="band-value">{bandB}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fan Control -->
|
||||||
|
<div class="fan-control">
|
||||||
|
<label for="fan-mode-select" class="control-label">Fan Mode</label>
|
||||||
|
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||||
|
<option value="STANDARD">Standard</option>
|
||||||
|
<option value="CONTEST">Contest</option>
|
||||||
|
<option value="BROADCAST">Broadcast</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="band-info">
|
|
||||||
<div class="label">Band A</div>
|
|
||||||
<div class="value">{bandA}</div>
|
|
||||||
</div>
|
|
||||||
<div class="band-info">
|
|
||||||
<div class="label">Band B</div>
|
|
||||||
<div class="value">{bandB}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.powergenius-card {
|
.card {
|
||||||
min-width: 350px;
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label {
|
.state-badge {
|
||||||
display: inline-block;
|
padding: 4px 12px;
|
||||||
padding: 8px 16px;
|
border-radius: 12px;
|
||||||
border-radius: 4px;
|
font-size: 11px;
|
||||||
font-size: 14px;
|
font-weight: 600;
|
||||||
font-weight: 500;
|
text-transform: uppercase;
|
||||||
margin-bottom: 16px;
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label.normal {
|
.state-badge.idle {
|
||||||
background: var(--accent-green);
|
background: rgba(76, 175, 80, 0.2);
|
||||||
color: white;
|
color: #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-label.warning {
|
.state-badge.transmit {
|
||||||
background: var(--accent-red);
|
background: rgba(255, 152, 0, 0.2);
|
||||||
color: white;
|
color: #ff9800;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 8px #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #f44336;
|
||||||
|
box-shadow: 0 0 8px #f44336;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
/* Power Display */
|
||||||
display: flex;
|
/* Power + SWR Row */
|
||||||
flex-direction: column;
|
.power-swr-row {
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric.small {
|
.power-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.power-header {
|
||||||
font-size: 20px;
|
display: flex;
|
||||||
font-weight: 300;
|
justify-content: space-between;
|
||||||
color: var(--accent-teal);
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.power-label-inline {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-value-inline {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-bg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 28px;
|
||||||
background: #555;
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-fill {
|
.power-bar-fill {
|
||||||
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-red));
|
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||||
|
border-radius: 14px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scale {
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-circle-compact {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||||
|
border: 4px solid var(--swr-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 0 25px var(--swr-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-value-compact {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--swr-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-label-compact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Temperature */
|
||||||
|
.temp-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fan-speed select {
|
.temp-mini-bar {
|
||||||
width: 100%;
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-mini-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parameters Grid */
|
||||||
|
.params-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-box {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.band-info {
|
.param-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Band Display */
|
||||||
|
.band-display {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.band-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fan Control */
|
||||||
|
.fan-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,135 +1,75 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
export let ultrabeam = null;
|
||||||
|
|
||||||
$: rotator1 = status?.rotator1 || {};
|
let heading = null; // Start with null instead of 0
|
||||||
$: rotator2 = status?.rotator2 || {};
|
let connected = false;
|
||||||
$: currentHeading = rotator1.current_azimuth || 0;
|
|
||||||
$: targetHeading = rotator1.target_azimuth || 0;
|
|
||||||
$: moving = rotator1.moving || 0;
|
|
||||||
$: connected = rotator1.connected || false;
|
|
||||||
|
|
||||||
let targetInput = currentHeading;
|
// Get Ultrabeam direction mode: 0=Normal, 1=180°, 2=Bi-Dir
|
||||||
let canvas;
|
$: ultrabeamDirection = ultrabeam?.direction ?? 0;
|
||||||
let ctx;
|
|
||||||
|
|
||||||
onMount(() => {
|
// Update heading with detailed logging to debug
|
||||||
if (canvas) {
|
$: if (status?.heading !== undefined && status?.heading !== null) {
|
||||||
ctx = canvas.getContext('2d');
|
const newHeading = status.heading;
|
||||||
drawGlobe();
|
|
||||||
|
if (heading === null) {
|
||||||
|
// First time: accept any value
|
||||||
|
heading = newHeading;
|
||||||
|
console.log(` ✓ First load, set to ${heading}°`);
|
||||||
|
} else if (newHeading === 0 && heading > 10 && heading < 350) {
|
||||||
|
// Ignore sudden jump to 0 from middle range (glitch)
|
||||||
|
console.log(` ✗ IGNORED glitch jump from ${heading}° to 0°`);
|
||||||
|
} else {
|
||||||
|
// Normal update
|
||||||
|
heading = newHeading;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$: if (ctx && currentHeading !== undefined) {
|
|
||||||
drawGlobe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawGlobe() {
|
// Display heading: use cached value or 0 if never set
|
||||||
if (!ctx) return;
|
$: displayHeading = heading !== null ? heading : 0;
|
||||||
|
|
||||||
const width = canvas.width;
|
$: connected = status?.connected || false;
|
||||||
const height = canvas.height;
|
|
||||||
const centerX = width / 2;
|
|
||||||
const centerY = height / 2;
|
|
||||||
const radius = Math.min(width, height) / 2 - 20;
|
|
||||||
|
|
||||||
// Clear canvas
|
// ✅ Target heading from rotator status (when controlled by PST Rotator or other software)
|
||||||
ctx.clearRect(0, 0, width, height);
|
$: statusTargetHeading = status?.target_heading ?? null;
|
||||||
|
|
||||||
// Draw globe circle
|
// Local target (when clicking on map in ShackMaster)
|
||||||
ctx.beginPath();
|
let localTargetHeading = null;
|
||||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
|
||||||
ctx.strokeStyle = '#444';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw grid lines (latitude/longitude)
|
// ✅ Determine if antenna is moving to a target from status
|
||||||
ctx.strokeStyle = '#333';
|
// (target differs from current heading by more than 2 degrees)
|
||||||
ctx.lineWidth = 1;
|
$: isMovingFromStatus = statusTargetHeading !== null &&
|
||||||
|
heading !== null &&
|
||||||
|
(() => {
|
||||||
|
const diff = Math.abs(statusTargetHeading - heading);
|
||||||
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
|
return wrappedDiff > 2;
|
||||||
|
})();
|
||||||
|
|
||||||
// Latitude lines
|
// ✅ Active target: prefer status target when moving, otherwise use local target
|
||||||
for (let i = 1; i < 4; i++) {
|
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
|
||||||
const y = centerY - radius + (radius * 2 * i / 4);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(centerX - radius, y);
|
|
||||||
ctx.lineTo(centerX + radius, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Longitude lines
|
// ✅ Has target if there's an active target that differs from current heading
|
||||||
for (let i = 1; i < 4; i++) {
|
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
|
||||||
const x = centerX - radius + (radius * 2 * i / 4);
|
const diff = Math.abs(activeTargetHeading - heading);
|
||||||
ctx.beginPath();
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
ctx.moveTo(x, centerY - radius);
|
return wrappedDiff > 2;
|
||||||
ctx.lineTo(x, centerY + radius);
|
})();
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw cardinal directions
|
// Clear local target when we reach it (within 3 degrees)
|
||||||
ctx.fillStyle = '#888';
|
$: if (localTargetHeading !== null && heading !== null) {
|
||||||
ctx.font = '14px Roboto';
|
const diff = Math.abs(heading - localTargetHeading);
|
||||||
ctx.textAlign = 'center';
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
ctx.textBaseline = 'middle';
|
if (wrappedDiff < 3) {
|
||||||
|
localTargetHeading = null;
|
||||||
ctx.fillText('N', centerX, centerY - radius - 10);
|
|
||||||
ctx.fillText('S', centerX, centerY + radius + 10);
|
|
||||||
ctx.fillText('E', centerX + radius + 10, centerY);
|
|
||||||
ctx.fillText('W', centerX - radius - 10, centerY);
|
|
||||||
|
|
||||||
// Draw heading indicator
|
|
||||||
const angle = (currentHeading - 90) * Math.PI / 180;
|
|
||||||
const lineLength = radius - 10;
|
|
||||||
const endX = centerX + Math.cos(angle) * lineLength;
|
|
||||||
const endY = centerY + Math.sin(angle) * lineLength;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(centerX, centerY);
|
|
||||||
ctx.lineTo(endX, endY);
|
|
||||||
ctx.strokeStyle = '#00bcd4';
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw arrow head
|
|
||||||
const arrowSize = 15;
|
|
||||||
const arrowAngle1 = angle + Math.PI * 0.85;
|
|
||||||
const arrowAngle2 = angle - Math.PI * 0.85;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(endX, endY);
|
|
||||||
ctx.lineTo(endX + Math.cos(arrowAngle1) * arrowSize, endY + Math.sin(arrowAngle1) * arrowSize);
|
|
||||||
ctx.moveTo(endX, endY);
|
|
||||||
ctx.lineTo(endX + Math.cos(arrowAngle2) * arrowSize, endY + Math.sin(arrowAngle2) * arrowSize);
|
|
||||||
ctx.strokeStyle = '#00bcd4';
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Draw center dot
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(centerX, centerY, 5, 0, 2 * Math.PI);
|
|
||||||
ctx.fillStyle = '#f44336';
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveToHeading() {
|
|
||||||
const heading = parseInt(targetInput);
|
|
||||||
if (isNaN(heading) || heading < 0 || heading > 360) {
|
|
||||||
alert('Please enter a valid heading (0-360)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.rotator.move(1, heading);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to move rotator:', err);
|
|
||||||
alert('Failed to move rotator');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateCW() {
|
async function rotateCW() {
|
||||||
try {
|
try {
|
||||||
await api.rotator.cw(1);
|
await api.rotator.rotateCW();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to rotate CW:', err);
|
console.error('Failed to rotate CW:', err);
|
||||||
}
|
}
|
||||||
@@ -137,7 +77,7 @@
|
|||||||
|
|
||||||
async function rotateCCW() {
|
async function rotateCCW() {
|
||||||
try {
|
try {
|
||||||
await api.rotator.ccw(1);
|
await api.rotator.rotateCCW();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to rotate CCW:', err);
|
console.error('Failed to rotate CCW:', err);
|
||||||
}
|
}
|
||||||
@@ -145,190 +85,468 @@
|
|||||||
|
|
||||||
async function stop() {
|
async function stop() {
|
||||||
try {
|
try {
|
||||||
|
localTargetHeading = null; // Clear local target on stop
|
||||||
await api.rotator.stop();
|
await api.rotator.stop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop:', err);
|
console.error('Failed to stop:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preset directions
|
// Handle click on compass to set heading
|
||||||
const presets = [
|
async function handleCompassClick(event) {
|
||||||
{ name: 'EU-0', heading: 0 },
|
const svg = event.currentTarget;
|
||||||
{ name: 'JA-35', heading: 35 },
|
const rect = svg.getBoundingClientRect();
|
||||||
{ name: 'AS-75', heading: 75 },
|
const centerX = rect.width / 2;
|
||||||
{ name: 'VK-120', heading: 120 },
|
const centerY = rect.height / 2;
|
||||||
{ name: 'AF-180', heading: 180 },
|
|
||||||
{ name: 'SA-230', heading: 230 },
|
|
||||||
{ name: 'WI-270', heading: 270 },
|
|
||||||
{ name: 'NA-300', heading: 300 }
|
|
||||||
];
|
|
||||||
|
|
||||||
async function gotoPreset(heading) {
|
// Get click position relative to center
|
||||||
|
const x = event.clientX - rect.left - centerX;
|
||||||
|
const y = event.clientY - rect.top - centerY;
|
||||||
|
|
||||||
|
// Calculate angle (0° = North/top, clockwise)
|
||||||
|
let angle = Math.atan2(x, -y) * (180 / Math.PI);
|
||||||
|
if (angle < 0) angle += 360;
|
||||||
|
|
||||||
|
// Round to nearest 5 degrees
|
||||||
|
const roundedHeading = Math.round(angle / 5) * 5;
|
||||||
|
const adjustedHeading = (roundedHeading + 360) % 360;
|
||||||
|
|
||||||
|
// ✅ CORRIGÉ : Send command first, then set localTargetHeading only on success
|
||||||
try {
|
try {
|
||||||
await api.rotator.move(1, heading);
|
await api.rotator.setHeading(adjustedHeading);
|
||||||
|
// Only set local target AFTER successful API call
|
||||||
|
localTargetHeading = adjustedHeading;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to move to preset:', err);
|
console.error('Failed to set heading:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rotator-card card">
|
<div class="card">
|
||||||
<h2>
|
<div class="card-header">
|
||||||
ROTATOR GENIUS
|
<h2>Rotator Genius</h2>
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="heading-display">
|
|
||||||
CURRENT HEADING: <span class="heading-value">{currentHeading}°</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if moving > 0}
|
<div class="metrics">
|
||||||
<div class="moving-indicator">
|
<!-- Current Heading Display with Compact Controls -->
|
||||||
{moving === 1 ? '↻ ROTATING CW' : '↺ ROTATING CCW'}
|
<div class="heading-controls-row">
|
||||||
</div>
|
<div class="heading-display-compact">
|
||||||
{/if}
|
<div class="heading-label">CURRENT HEADING</div>
|
||||||
|
<div class="heading-value">
|
||||||
|
{displayHeading}°
|
||||||
|
{#if hasTarget && activeTargetHeading !== null}
|
||||||
|
<span class="target-indicator">→ {activeTargetHeading}°</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<canvas bind:this={canvas} width="300" height="300"></canvas>
|
<div class="controls-compact">
|
||||||
|
<button class="btn-mini ccw" on:click={rotateCCW} title="Rotate Counter-Clockwise">
|
||||||
<div class="controls">
|
↺
|
||||||
<div class="heading-input">
|
</button>
|
||||||
<input
|
<button class="btn-mini stop" on:click={stop} title="Stop Rotation">
|
||||||
type="number"
|
■
|
||||||
min="0"
|
</button>
|
||||||
max="360"
|
<button class="btn-mini cw" on:click={rotateCW} title="Rotate Clockwise">
|
||||||
bind:value={targetInput}
|
↻
|
||||||
placeholder="Enter heading"
|
</button>
|
||||||
/>
|
</div>
|
||||||
<button class="btn btn-primary" on:click={moveToHeading}>GO</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rotation-controls">
|
<!-- Map with Beam -->
|
||||||
<button class="btn btn-secondary" on:click={rotateCCW}>↺ CCW</button>
|
<div class="map-container">
|
||||||
<button class="btn btn-danger" on:click={stop}>STOP</button>
|
<svg viewBox="0 0 300 300" class="map-svg clickable-compass"
|
||||||
<button class="btn btn-secondary" on:click={rotateCW}>CW ↻</button>
|
on:click={handleCompassClick}
|
||||||
</div>
|
on:keydown={(e) => e.key === 'Enter' && handleCompassClick(e)}
|
||||||
</div>
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Click to rotate antenna to direction">
|
||||||
|
<defs>
|
||||||
|
<!-- Gradient for beam -->
|
||||||
|
<radialGradient id="beamGradient">
|
||||||
|
<stop offset="0%" style="stop-color:rgba(79, 195, 247, 0.7);stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:rgba(79, 195, 247, 0);stop-opacity:0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<div class="presets">
|
<!-- Ocean background -->
|
||||||
{#each presets as preset}
|
<circle cx="150" cy="150" r="140" fill="rgba(30, 64, 175, 0.15)" stroke="rgba(79, 195, 247, 0.4)" stroke-width="2"/>
|
||||||
<button
|
|
||||||
class="preset-btn"
|
<!-- Distance circles -->
|
||||||
class:active={Math.abs(currentHeading - preset.heading) < 5}
|
<circle cx="150" cy="150" r="105" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||||
on:click={() => gotoPreset(preset.heading)}
|
<circle cx="150" cy="150" r="70" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||||
>
|
<circle cx="150" cy="150" r="35" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||||
{preset.name}
|
|
||||||
</button>
|
<!-- Rotated group for beam -->
|
||||||
{/each}
|
<g transform="translate(150, 150)">
|
||||||
|
<!-- Physical antenna direction indicator (only in 180° or Bi-Dir mode) -->
|
||||||
|
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||||
|
<g transform="rotate({displayHeading})">
|
||||||
|
<!-- Gray dashed line showing physical antenna direction -->
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-125"
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-dasharray="5,5"
|
||||||
|
opacity="0.6"/>
|
||||||
|
<!-- Small triangle at end to show physical direction -->
|
||||||
|
<g transform="translate(0, -125)">
|
||||||
|
<polygon points="0,-8 -5,5 5,5"
|
||||||
|
fill="rgba(255, 255, 255, 0.4)"
|
||||||
|
stroke="rgba(255, 255, 255, 0.5)"
|
||||||
|
stroke-width="1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Beam (rotates with heading) -->
|
||||||
|
<g transform="rotate({displayHeading})">
|
||||||
|
|
||||||
|
<!-- NORMAL MODE (0): Forward beam only -->
|
||||||
|
{#if ultrabeamDirection === 0}
|
||||||
|
<!-- Beam sector (±15° = 30° total beamwidth) -->
|
||||||
|
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
A 130,130 0 0,1 {Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130} Z"
|
||||||
|
fill="url(#beamGradient)"
|
||||||
|
opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Beam outline -->
|
||||||
|
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||||
|
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- Direction arrow -->
|
||||||
|
<g transform="translate(0, -110)">
|
||||||
|
<polygon points="0,-20 -8,5 0,0 8,5"
|
||||||
|
fill="#4fc3f7"
|
||||||
|
stroke="#0288d1"
|
||||||
|
stroke-width="2"
|
||||||
|
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 180° MODE (1): Backward beam only -->
|
||||||
|
{#if ultrabeamDirection === 1}
|
||||||
|
<!-- Beam sector pointing BACKWARD (180° opposite) -->
|
||||||
|
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
A 130,130 0 0,0 {Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130} Z"
|
||||||
|
fill="url(#beamGradient)"
|
||||||
|
opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Beam outline -->
|
||||||
|
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||||
|
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- Direction arrow pointing BACKWARD -->
|
||||||
|
<g transform="translate(0, 110)">
|
||||||
|
<polygon points="0,20 -8,-5 0,0 8,-5"
|
||||||
|
fill="#4fc3f7"
|
||||||
|
stroke="#0288d1"
|
||||||
|
stroke-width="2"
|
||||||
|
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- BI-DIRECTIONAL MODE (2): Both forward AND backward beams -->
|
||||||
|
{#if ultrabeamDirection === 2}
|
||||||
|
<!-- Forward beam -->
|
||||||
|
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
A 130,130 0 0,1 {Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130} Z"
|
||||||
|
fill="url(#beamGradient)"
|
||||||
|
opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Backward beam -->
|
||||||
|
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
A 130,130 0 0,0 {Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130} Z"
|
||||||
|
fill="url(#beamGradient)"
|
||||||
|
opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Beam outlines -->
|
||||||
|
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||||
|
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Direction arrows (both directions) -->
|
||||||
|
<g transform="translate(0, -110)">
|
||||||
|
<polygon points="0,-20 -8,5 0,0 8,5"
|
||||||
|
fill="#4fc3f7"
|
||||||
|
stroke="#0288d1"
|
||||||
|
stroke-width="2"
|
||||||
|
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(0, 110)">
|
||||||
|
<polygon points="0,20 -8,-5 0,0 8,-5"
|
||||||
|
fill="#4fc3f7"
|
||||||
|
stroke="#0288d1"
|
||||||
|
stroke-width="2"
|
||||||
|
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ✅ Target arrow (yellow) - shown when antenna is moving to target -->
|
||||||
|
{#if hasTarget && activeTargetHeading !== null}
|
||||||
|
<g transform="rotate({activeTargetHeading})">
|
||||||
|
<!-- Target direction line (dashed yellow) -->
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-135"
|
||||||
|
stroke="#ffc107"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-dasharray="8,4"
|
||||||
|
opacity="0.9"/>
|
||||||
|
<!-- Target arrow head with pulse animation -->
|
||||||
|
<g transform="translate(0, -135)">
|
||||||
|
<polygon points="0,-12 -8,6 0,2 8,6"
|
||||||
|
fill="#ffc107"
|
||||||
|
stroke="#ff9800"
|
||||||
|
stroke-width="1.5"
|
||||||
|
style="filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.8))">
|
||||||
|
<animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
|
||||||
|
</polygon>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Center dot (your QTH) -->
|
||||||
|
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
||||||
|
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="0" cy="0" r="10" fill="none" stroke="#f44336" stroke-width="1.5" opacity="0.5">
|
||||||
|
<animate attributeName="r" values="10;16;10" dur="2s" repeatCount="indefinite"/>
|
||||||
|
<animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite"/>
|
||||||
|
</circle>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Cardinal points -->
|
||||||
|
<text x="150" y="20" text-anchor="middle" class="cardinal">N</text>
|
||||||
|
<text x="280" y="155" text-anchor="middle" class="cardinal">E</text>
|
||||||
|
<text x="150" y="285" text-anchor="middle" class="cardinal">S</text>
|
||||||
|
<text x="20" y="155" text-anchor="middle" class="cardinal">W</text>
|
||||||
|
|
||||||
|
<!-- Degree markers every 45° -->
|
||||||
|
{#each [45, 135, 225, 315] as angle}
|
||||||
|
{@const x = 150 + 125 * Math.sin(angle * Math.PI / 180)}
|
||||||
|
{@const y = 150 - 125 * Math.cos(angle * Math.PI / 180)}
|
||||||
|
<text x={x} y={y} text-anchor="middle" dominant-baseline="middle" class="degree-label">{angle}°</text>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend (only show in 180° or Bi-Dir mode) -->
|
||||||
|
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||||
|
<div class="map-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<svg width="30" height="20" viewBox="0 0 30 20">
|
||||||
|
<line x1="5" y1="10" x2="25" y2="10" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="3,3"/>
|
||||||
|
</svg>
|
||||||
|
<span>Physical antenna</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<svg width="30" height="20" viewBox="0 0 30 20">
|
||||||
|
<line x1="5" y1="10" x2="25" y2="10" stroke="#4fc3f7" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
<span>Radiation pattern</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.rotator-card {
|
.card {
|
||||||
min-width: 350px;
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
display: flex;
|
font-size: 14px;
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
gap: 8px;
|
color: var(--accent-cyan);
|
||||||
margin-bottom: 16px;
|
margin: 0;
|
||||||
font-size: 18px;
|
letter-spacing: 0.5px;
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-display {
|
.status-dot {
|
||||||
font-size: 16px;
|
width: 8px;
|
||||||
font-weight: 500;
|
height: 8px;
|
||||||
text-align: center;
|
border-radius: 50%;
|
||||||
margin-bottom: 8px;
|
background: #4caf50;
|
||||||
padding: 12px;
|
box-shadow: 0 0 8px #4caf50;
|
||||||
background: var(--bg-secondary);
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #f44336;
|
||||||
|
box-shadow: 0 0 8px #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(79, 195, 247, 0.1);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-display-compact {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-compact {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(79, 195, 247, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini:hover {
|
||||||
|
border-color: rgba(79, 195, 247, 0.6);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
background: rgba(79, 195, 247, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 15px rgba(79, 195, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini.ccw:hover {
|
||||||
|
transform: translateY(-1px) rotate(-5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini.cw:hover {
|
||||||
|
transform: translateY(-1px) rotate(5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini.stop:hover {
|
||||||
|
border-color: #f44336;
|
||||||
|
color: #f44336;
|
||||||
|
background: rgba(244, 67, 54, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-value {
|
.heading-value {
|
||||||
color: var(--accent-blue);
|
font-size: 42px;
|
||||||
|
font-weight: 200;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-indicator {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin-left: 8px;
|
font-weight: 400;
|
||||||
|
color: #ffc107;
|
||||||
|
margin-left: 20px;
|
||||||
|
text-shadow: 0 0 15px rgba(255, 193, 7, 0.6);
|
||||||
|
animation: targetPulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moving-indicator {
|
@keyframes targetPulse {
|
||||||
text-align: center;
|
0%, 100% { opacity: 0.8; }
|
||||||
padding: 8px;
|
50% { opacity: 1; }
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.map-container {
|
||||||
0%, 100% { opacity: 1; }
|
display: flex;
|
||||||
50% { opacity: 0.7; }
|
justify-content: center;
|
||||||
}
|
padding: 10px;
|
||||||
|
background: rgba(10, 22, 40, 0.6);
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
margin: 16px auto;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.map-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 20px;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
margin-bottom: 16px;
|
padding: 8px;
|
||||||
}
|
margin-top: 8px;
|
||||||
|
background: rgba(10, 22, 40, 0.4);
|
||||||
.heading-input {
|
border-radius: 6px;
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-input input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotation-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotation-controls button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.presets {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-btn {
|
|
||||||
padding: 12px 8px;
|
|
||||||
background: var(--accent-blue);
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
color: rgba(255, 255, 255, 0.7);
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn:hover {
|
.legend-item {
|
||||||
background: #1976d2;
|
display: flex;
|
||||||
transform: translateY(-2px);
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn.active {
|
.map-svg {
|
||||||
background: var(--accent-green);
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-compass {
|
||||||
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-compass:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardinal {
|
||||||
|
fill: var(--accent-cyan);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-shadow: 0 0 10px rgba(79, 195, 247, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.degree-label {
|
||||||
|
fill: rgba(79, 195, 247, 0.7);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
741
web/src/components/StatusBanner.svelte
Normal file
741
web/src/components/StatusBanner.svelte
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
|
export let flexradio = null;
|
||||||
|
export let weather = null;
|
||||||
|
export let latitude = null;
|
||||||
|
export let longitude = null;
|
||||||
|
export let windWarningThreshold = 30; // km/h
|
||||||
|
export let gustWarningThreshold = 50; // km/h
|
||||||
|
export let graylineWindow = 30; // minutes avant/après sunrise/sunset
|
||||||
|
|
||||||
|
// FlexRadio status
|
||||||
|
$: frequency = flexradio?.frequency || 0;
|
||||||
|
$: mode = flexradio?.mode || '';
|
||||||
|
$: txEnabled = flexradio?.tx || false;
|
||||||
|
$: connected = flexradio?.connected || false;
|
||||||
|
$: radioOn = flexradio?.radio_on || false;
|
||||||
|
$: radioInfo = flexradio?.radio_info || '';
|
||||||
|
$: callsign = flexradio?.callsign || '';
|
||||||
|
$: model = flexradio?.model || '';
|
||||||
|
$: activeSlices = flexradio?.active_slices || 0;
|
||||||
|
|
||||||
|
// Grayline calculation
|
||||||
|
let sunrise = null;
|
||||||
|
let sunset = null;
|
||||||
|
let isGrayline = false;
|
||||||
|
let graylineType = ''; // 'sunrise' ou 'sunset'
|
||||||
|
let timeToNextEvent = '';
|
||||||
|
let currentTime = new Date();
|
||||||
|
let clockInterval;
|
||||||
|
|
||||||
|
// Update time every minute for grayline check
|
||||||
|
onMount(() => {
|
||||||
|
calculateSunTimes();
|
||||||
|
clockInterval = setInterval(() => {
|
||||||
|
currentTime = new Date();
|
||||||
|
checkGrayline();
|
||||||
|
updateTimeToNextEvent();
|
||||||
|
}, 10000); // Update every 10 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (clockInterval) clearInterval(clockInterval);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate when location changes
|
||||||
|
$: if (latitude && longitude) {
|
||||||
|
calculateSunTimes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SunCalc algorithm (simplified version)
|
||||||
|
function calculateSunTimes() {
|
||||||
|
if (!latitude || !longitude) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const times = getSunTimes(now, latitude, longitude);
|
||||||
|
sunrise = times.sunrise;
|
||||||
|
sunset = times.sunset;
|
||||||
|
checkGrayline();
|
||||||
|
updateTimeToNextEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: console.log('FlexRadio status:', {
|
||||||
|
connected,
|
||||||
|
radioOn,
|
||||||
|
frequency,
|
||||||
|
activeSlices,
|
||||||
|
radioInfo,
|
||||||
|
callsign,
|
||||||
|
model
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simplified sun calculation (based on NOAA algorithm)
|
||||||
|
function getSunTimes(date, lat, lon) {
|
||||||
|
const rad = Math.PI / 180;
|
||||||
|
const dayOfYear = getDayOfYear(date);
|
||||||
|
|
||||||
|
// Fractional year
|
||||||
|
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (date.getHours() - 12) / 24);
|
||||||
|
|
||||||
|
// Equation of time (minutes)
|
||||||
|
const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma)
|
||||||
|
- 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma));
|
||||||
|
|
||||||
|
// Solar declination (radians)
|
||||||
|
const decl = 0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma)
|
||||||
|
- 0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma)
|
||||||
|
- 0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma);
|
||||||
|
|
||||||
|
// Hour angle for sunrise/sunset
|
||||||
|
const latRad = lat * rad;
|
||||||
|
const zenith = 90.833 * rad; // Official zenith for sunrise/sunset
|
||||||
|
|
||||||
|
const cosHA = (Math.cos(zenith) / (Math.cos(latRad) * Math.cos(decl)))
|
||||||
|
- Math.tan(latRad) * Math.tan(decl);
|
||||||
|
|
||||||
|
// Check for polar day/night
|
||||||
|
if (cosHA > 1 || cosHA < -1) {
|
||||||
|
return { sunrise: null, sunset: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ha = Math.acos(cosHA) / rad; // Hour angle in degrees
|
||||||
|
|
||||||
|
// Sunrise and sunset times in minutes from midnight UTC
|
||||||
|
const sunriseMinutes = 720 - 4 * (lon + ha) - eqTime;
|
||||||
|
const sunsetMinutes = 720 - 4 * (lon - ha) - eqTime;
|
||||||
|
|
||||||
|
// Convert to local Date objects
|
||||||
|
const sunriseDate = new Date(date);
|
||||||
|
sunriseDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
sunriseDate.setUTCMinutes(sunriseMinutes);
|
||||||
|
|
||||||
|
const sunsetDate = new Date(date);
|
||||||
|
sunsetDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
sunsetDate.setUTCMinutes(sunsetMinutes);
|
||||||
|
|
||||||
|
return { sunrise: sunriseDate, sunset: sunsetDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayOfYear(date) {
|
||||||
|
const start = new Date(date.getFullYear(), 0, 0);
|
||||||
|
const diff = date - start;
|
||||||
|
const oneDay = 1000 * 60 * 60 * 24;
|
||||||
|
return Math.floor(diff / oneDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkGrayline() {
|
||||||
|
if (!sunrise || !sunset) {
|
||||||
|
isGrayline = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = currentTime.getTime();
|
||||||
|
const windowMs = graylineWindow * 60 * 1000;
|
||||||
|
|
||||||
|
const nearSunrise = Math.abs(now - sunrise.getTime()) <= windowMs;
|
||||||
|
const nearSunset = Math.abs(now - sunset.getTime()) <= windowMs;
|
||||||
|
|
||||||
|
isGrayline = nearSunrise || nearSunset;
|
||||||
|
graylineType = nearSunrise ? 'sunrise' : (nearSunset ? 'sunset' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeToNextEvent() {
|
||||||
|
if (!sunrise || !sunset) {
|
||||||
|
timeToNextEvent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = currentTime.getTime();
|
||||||
|
let nextEvent = null;
|
||||||
|
let eventName = '';
|
||||||
|
|
||||||
|
if (now < sunrise.getTime()) {
|
||||||
|
nextEvent = sunrise;
|
||||||
|
eventName = 'Sunrise';
|
||||||
|
} else if (now < sunset.getTime()) {
|
||||||
|
nextEvent = sunset;
|
||||||
|
eventName = 'Sunset';
|
||||||
|
} else {
|
||||||
|
// After sunset, calculate tomorrow's sunrise
|
||||||
|
const tomorrow = new Date(currentTime);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const tomorrowTimes = getSunTimes(tomorrow, latitude, longitude);
|
||||||
|
nextEvent = tomorrowTimes.sunrise;
|
||||||
|
eventName = 'Sunrise';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextEvent) {
|
||||||
|
const diffMs = nextEvent.getTime() - now;
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
timeToNextEvent = `${eventName} in ${hours}h${minutes.toString().padStart(2, '0')}m`;
|
||||||
|
} else {
|
||||||
|
timeToNextEvent = `${eventName} in ${minutes}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date) {
|
||||||
|
if (!date) return '--:--';
|
||||||
|
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format frequency for display (MHz with appropriate decimals)
|
||||||
|
function formatFrequency(freqMHz) {
|
||||||
|
if (!freqMHz || freqMHz === 0) return '---';
|
||||||
|
if (freqMHz < 10) {
|
||||||
|
return freqMHz.toFixed(4);
|
||||||
|
} else if (freqMHz < 100) {
|
||||||
|
return freqMHz.toFixed(3);
|
||||||
|
} else {
|
||||||
|
return freqMHz.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get band from frequency
|
||||||
|
function getBand(freqMHz) {
|
||||||
|
if (!freqMHz || freqMHz === 0) return '';
|
||||||
|
if (freqMHz >= 1.8 && freqMHz <= 2.0) return '160M';
|
||||||
|
if (freqMHz >= 3.5 && freqMHz <= 4.0) return '80M';
|
||||||
|
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60M';
|
||||||
|
if (freqMHz >= 7.0 && freqMHz <= 7.3) return '40M';
|
||||||
|
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30M';
|
||||||
|
if (freqMHz >= 14.0 && freqMHz <= 14.35) return '20M';
|
||||||
|
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17M';
|
||||||
|
if (freqMHz >= 21.0 && freqMHz <= 21.45) return '15M';
|
||||||
|
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12M';
|
||||||
|
if (freqMHz >= 28.0 && freqMHz <= 29.7) return '10M';
|
||||||
|
if (freqMHz >= 50.0 && freqMHz <= 54.0) return '6M';
|
||||||
|
if (freqMHz >= 144.0 && freqMHz <= 148.0) return '2M';
|
||||||
|
if (freqMHz >= 430.0 && freqMHz <= 440.0) return '70CM';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather alerts
|
||||||
|
$: windSpeed = weather?.wind_speed || 0;
|
||||||
|
$: windGust = weather?.wind_gust || 0;
|
||||||
|
$: hasWindWarning = windSpeed >= windWarningThreshold;
|
||||||
|
$: hasGustWarning = windGust >= gustWarningThreshold;
|
||||||
|
$: hasAnyWarning = hasWindWarning || hasGustWarning;
|
||||||
|
|
||||||
|
// Band colors
|
||||||
|
function getBandColor(band) {
|
||||||
|
const colors = {
|
||||||
|
'160M': '#9c27b0',
|
||||||
|
'80M': '#673ab7',
|
||||||
|
'60M': '#3f51b5',
|
||||||
|
'40M': '#2196f3',
|
||||||
|
'30M': '#00bcd4',
|
||||||
|
'20M': '#009688',
|
||||||
|
'17M': '#4caf50',
|
||||||
|
'15M': '#8bc34a',
|
||||||
|
'12M': '#cddc39',
|
||||||
|
'10M': '#ffeb3b',
|
||||||
|
'6M': '#ff9800',
|
||||||
|
'2M': '#ff5722',
|
||||||
|
'70CM': '#f44336'
|
||||||
|
};
|
||||||
|
return colors[band] || '#4fc3f7';
|
||||||
|
}
|
||||||
|
|
||||||
|
$: currentBand = getBand(frequency);
|
||||||
|
$: bandColor = getBandColor(currentBand);
|
||||||
|
|
||||||
|
// Determine what to show for FlexRadio - MODIFIÉ
|
||||||
|
$: showFrequency = radioOn && frequency > 0;
|
||||||
|
$: showRadioOnNoSlice = radioOn && frequency === 0 && activeSlices === 0;
|
||||||
|
$: showRadioOnWithSliceNoFreq = radioOn && frequency === 0 && activeSlices > 0;
|
||||||
|
$: showNotConnected = !connected;
|
||||||
|
$: showConnectedNoRadio = connected && !radioOn;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="status-banner" class:has-warning={hasAnyWarning}>
|
||||||
|
<!-- FlexRadio Section -->
|
||||||
|
<div class="flex-section">
|
||||||
|
<div class="flex-icon" class:connected={connected} class:disconnected={!connected}>
|
||||||
|
📻
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showFrequency}
|
||||||
|
<!-- Radio is on and has active slice with frequency -->
|
||||||
|
<div class="frequency-display">
|
||||||
|
<span class="frequency" style="--band-color: {bandColor}">
|
||||||
|
{formatFrequency(frequency)}
|
||||||
|
</span>
|
||||||
|
<span class="unit">MHz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if currentBand}
|
||||||
|
<span class="band-badge" style="background-color: {bandColor}">
|
||||||
|
{currentBand}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode}
|
||||||
|
<span class="mode-badge">
|
||||||
|
{mode}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- AFFICHAGE TX - SEULEMENT SI TX EST VRAI -->
|
||||||
|
{#if txEnabled}
|
||||||
|
<span class="tx-indicator">
|
||||||
|
TX
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if showRadioOnWithSliceNoFreq}
|
||||||
|
<!-- Radio is on with slice but frequency is 0 (maybe slice just created) -->
|
||||||
|
<div class="radio-status">
|
||||||
|
<span class="radio-on-indicator">●</span>
|
||||||
|
<span class="radio-status-text">Slice active, waiting for frequency...</span>
|
||||||
|
{#if model}
|
||||||
|
<span class="model-badge">{model}</span>
|
||||||
|
{/if}
|
||||||
|
{#if callsign}
|
||||||
|
<span class="callsign-badge">{callsign}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if showRadioOnNoSlice}
|
||||||
|
<!-- Radio is on but no active slice -->
|
||||||
|
<div class="radio-status">
|
||||||
|
<span class="radio-on-indicator">●</span>
|
||||||
|
<span class="radio-status-text">{radioInfo || 'Radio is on'}</span>
|
||||||
|
{#if model}
|
||||||
|
<span class="model-badge">{model}</span>
|
||||||
|
{/if}
|
||||||
|
{#if callsign}
|
||||||
|
<span class="callsign-badge">{callsign}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if showConnectedNoRadio}
|
||||||
|
<!-- TCP connected but radio not responding -->
|
||||||
|
<div class="radio-status">
|
||||||
|
<span class="radio-off-indicator">○</span>
|
||||||
|
<span class="radio-status-text">TCP connected, radio off</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if showNotConnected}
|
||||||
|
<!-- Not connected at all -->
|
||||||
|
<span class="no-signal">FlexRadio not connected</span>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- Default/unknown state -->
|
||||||
|
<span class="no-signal">Checking FlexRadio...</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
<!-- Grayline Section -->
|
||||||
|
<div class="grayline-section">
|
||||||
|
{#if latitude && longitude}
|
||||||
|
<div class="sun-times">
|
||||||
|
<span class="sun-item" title="Sunrise">
|
||||||
|
<svg class="sun-icon sunrise-icon" width="18" height="18" viewBox="0 0 24 24">
|
||||||
|
<!-- Horizon line -->
|
||||||
|
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
|
||||||
|
<!-- Sun (half visible) -->
|
||||||
|
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#fbbf24"/>
|
||||||
|
<!-- Rays -->
|
||||||
|
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Up arrow -->
|
||||||
|
<path d="M12 14l-3 3M12 14l3 3M12 14v5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{formatTime(sunrise)}
|
||||||
|
</span>
|
||||||
|
<span class="sun-item" title="Sunset">
|
||||||
|
<svg class="sun-icon sunset-icon" width="18" height="18" viewBox="0 0 24 24">
|
||||||
|
<!-- Horizon line -->
|
||||||
|
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
|
||||||
|
<!-- Sun (half visible, setting) -->
|
||||||
|
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#f97316"/>
|
||||||
|
<!-- Rays (dimmer) -->
|
||||||
|
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#f97316" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<!-- Down arrow -->
|
||||||
|
<path d="M12 22l-3-3M12 22l3-3M12 22v-5" stroke="#ef4444" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
{formatTime(sunset)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isGrayline}
|
||||||
|
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
|
||||||
|
✨ Grayline
|
||||||
|
</span>
|
||||||
|
{:else if timeToNextEvent}
|
||||||
|
<span class="next-event">
|
||||||
|
{timeToNextEvent}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="no-location">📍 Position not set</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
<!-- Weather Alerts Section -->
|
||||||
|
<div class="weather-section">
|
||||||
|
{#if hasWindWarning}
|
||||||
|
<div class="alert wind-alert">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
<span class="alert-text">
|
||||||
|
Vent: <strong>{windSpeed.toFixed(0)} km/h</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasGustWarning}
|
||||||
|
<div class="alert gust-alert">
|
||||||
|
<span class="alert-icon">🌪️</span>
|
||||||
|
<span class="alert-text">
|
||||||
|
Rafales: <strong>{windGust.toFixed(0)} km/h</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !hasAnyWarning}
|
||||||
|
<div class="status-ok">
|
||||||
|
<span class="ok-icon">✓</span>
|
||||||
|
<span class="ok-text">Weather OK</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
border-bottom: 1px solid rgba(79, 195, 247, 0.15);
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.has-warning {
|
||||||
|
background: linear-gradient(135deg, #1f1a12 0%, #29201b 50%, #2d1b0c 100%);
|
||||||
|
border-bottom-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FlexRadio Section */
|
||||||
|
.flex-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-icon.connected {
|
||||||
|
opacity: 1;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(79, 195, 247, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-icon.disconnected {
|
||||||
|
opacity: 0.4;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
font-family: 'Roboto Mono', 'Consolas', monospace;
|
||||||
|
color: var(--band-color, #4fc3f7);
|
||||||
|
text-shadow: 0 0 15px var(--band-color, rgba(79, 195, 247, 0.5));
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #000;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(156, 39, 176, 0.3);
|
||||||
|
border: 1px solid rgba(156, 39, 176, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ce93d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-indicator {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(244, 67, 54, 0.3);
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f44336;
|
||||||
|
text-shadow: 0 0 10px rgba(244, 67, 54, 0.8);
|
||||||
|
animation: txPulse 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes txPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.slice-waiting {
|
||||||
|
color: #fbbf24; /* Jaune pour "en attente" */
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio status indicators */
|
||||||
|
.radio-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-on-indicator {
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 16px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-off-indicator {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-status-text {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-badge {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(79, 195, 247, 0.2);
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callsign-badge {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-signal {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separator */
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 30px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grayline Section */
|
||||||
|
.grayline-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-times {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-item {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunrise-icon {
|
||||||
|
color: rgba(251, 191, 36, 0.6);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunset-icon {
|
||||||
|
color: rgba(249, 115, 22, 0.6);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grayline-badge {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
animation: graylinePulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grayline-badge.sunrise {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 183, 77, 0.3) 0%, rgba(255, 138, 101, 0.3) 100%);
|
||||||
|
border: 1px solid rgba(255, 183, 77, 0.6);
|
||||||
|
color: #ffcc80;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 183, 77, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grayline-badge.sunset {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 138, 101, 0.3) 0%, rgba(239, 83, 80, 0.3) 100%);
|
||||||
|
border: 1px solid rgba(255, 138, 101, 0.6);
|
||||||
|
color: #ffab91;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 138, 101, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes graylinePulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.85; transform: scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-event {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-location {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Weather Section */
|
||||||
|
.weather-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
animation: alertPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-alert {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gust-alert {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes alertPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-text strong {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gust-alert .alert-text strong {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok-icon {
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(34, 197, 94, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.status-banner {
|
||||||
|
padding: 8px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-section,
|
||||||
|
.grayline-section,
|
||||||
|
.weather-section {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,255 +3,438 @@
|
|||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
$: operate = status?.operate || false;
|
$: powerForward = status?.power_forward || 0;
|
||||||
$: activeAntenna = status?.active_antenna || 0;
|
$: swr = status?.swr || 1.0;
|
||||||
$: tuningStatus = status?.tuning_status || 'READY';
|
$: tuningStatus = status?.tuning_status || 'READY';
|
||||||
$: frequencyA = status?.frequency_a || 0;
|
$: frequencyA = status?.frequency_a || 0;
|
||||||
$: frequencyB = status?.frequency_b || 0;
|
$: frequencyB = status?.frequency_b || 0;
|
||||||
$: c1 = status?.c1 || 0;
|
$: bypass = status?.bypass || false;
|
||||||
$: l = status?.l || 0;
|
$: state = status?.state || 0;
|
||||||
$: c2 = status?.c2 || 0;
|
$: relayC1 = status?.c1 || 0;
|
||||||
|
$: relayL = status?.l || 0;
|
||||||
|
$: relayC2 = status?.c2 || 0;
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
|
|
||||||
let tuning = false;
|
// Color functions
|
||||||
|
$: swrColor = swr < 1.5 ? '#4caf50' : swr < 2.0 ? '#ffc107' : swr < 3.0 ? '#ff9800' : '#f44336';
|
||||||
|
$: powerPercent = Math.min((powerForward / 2000) * 100, 100);
|
||||||
|
|
||||||
async function toggleOperate() {
|
async function autoTune() {
|
||||||
try {
|
try {
|
||||||
await api.tuner.operate(!operate);
|
await api.tuner.autoTune();
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle operate:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTune() {
|
|
||||||
tuning = true;
|
|
||||||
try {
|
|
||||||
await api.tuner.tune();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to tune:', err);
|
console.error('Failed to tune:', err);
|
||||||
alert('Tuning failed');
|
// Removed alert popup - check console for errors
|
||||||
} finally {
|
|
||||||
tuning = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAntenna(ant) {
|
async function setBypass(value) {
|
||||||
try {
|
try {
|
||||||
await api.tuner.antenna(ant);
|
await api.tuner.setBypass(value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set antenna:', err);
|
console.error('Failed to set bypass:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setOperate(value) {
|
||||||
|
try {
|
||||||
|
await api.tuner.setOperate(value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set operate:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tuner-card card">
|
<div class="card">
|
||||||
<h2>
|
<div class="card-header">
|
||||||
TGXL
|
<h2>Tuner Genius XL</h2>
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
<div class="header-right">
|
||||||
</h2>
|
<span class="tuning-badge" class:tuning={tuningStatus === 'TUNING'}>{tuningStatus}</span>
|
||||||
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
<div class="power-status">
|
</div>
|
||||||
<div class="label">Power 0.0w</div>
|
|
||||||
<div class="status-badge">1500</div>
|
|
||||||
<div class="status-badge">1650</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tuning-controls">
|
<div class="metrics">
|
||||||
<div class="tuning-row">
|
<!-- Power Display + SWR Side by Side -->
|
||||||
<div class="tuning-label">TG XL SWR 1.00 use</div>
|
<div class="power-swr-row">
|
||||||
|
<div class="power-section">
|
||||||
|
<div class="power-header">
|
||||||
|
<span class="power-label-inline">Power</span>
|
||||||
|
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||||
|
</div>
|
||||||
|
<div class="power-bar-container">
|
||||||
|
<div class="power-bar-bg">
|
||||||
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SWR Circle Compact -->
|
||||||
|
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||||
|
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||||
|
<div class="swr-label-compact">SWR</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="antenna-buttons">
|
<!-- Tuning Capacitors -->
|
||||||
|
<div class="capacitors">
|
||||||
|
<div class="cap-item">
|
||||||
|
<div class="cap-value">{relayC1}</div>
|
||||||
|
<div class="cap-label">C1</div>
|
||||||
|
</div>
|
||||||
|
<div class="cap-item">
|
||||||
|
<div class="cap-value">{relayL}</div>
|
||||||
|
<div class="cap-label">L</div>
|
||||||
|
</div>
|
||||||
|
<div class="cap-item">
|
||||||
|
<div class="cap-value">{relayC2}</div>
|
||||||
|
<div class="cap-label">C2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequencies -->
|
||||||
|
<div class="freq-display">
|
||||||
|
<div class="freq-item">
|
||||||
|
<div class="freq-label">Freq A</div>
|
||||||
|
<div class="freq-value">{(frequencyA / 1000).toFixed(3)}<span class="freq-unit">MHz</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="freq-item">
|
||||||
|
<div class="freq-label">Freq B</div>
|
||||||
|
<div class="freq-value">{(frequencyB / 1000).toFixed(3)}<span class="freq-unit">MHz</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="controls">
|
||||||
<button
|
<button
|
||||||
class="antenna-btn"
|
class="control-btn operate"
|
||||||
class:active={activeAntenna === 0}
|
class:active={state === 1}
|
||||||
on:click={() => setAntenna(0)}
|
on:click={() => setOperate(state === 1 ? 0 : 1)}
|
||||||
>
|
>
|
||||||
C1
|
{state === 1 ? 'OPERATE' : 'STANDBY'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="antenna-btn"
|
class="control-btn bypass"
|
||||||
class:active={activeAntenna === 1}
|
class:active={bypass}
|
||||||
on:click={() => setAntenna(1)}
|
on:click={() => setBypass(bypass ? 0 : 1)}
|
||||||
>
|
>
|
||||||
L
|
BYPASS
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 2}
|
|
||||||
on:click={() => setAntenna(2)}
|
|
||||||
>
|
|
||||||
C2
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tuning-values">
|
<button class="tune-btn" on:click={autoTune}>
|
||||||
<div class="value-box">
|
<span class="tune-icon">⚡</span>
|
||||||
<div class="value">{c1}</div>
|
AUTO TUNE
|
||||||
<div class="label">C1</div>
|
|
||||||
</div>
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{l}</div>
|
|
||||||
<div class="label">L</div>
|
|
||||||
</div>
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{c2}</div>
|
|
||||||
<div class="label">C2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Tuning Status</div>
|
|
||||||
<div class="status-badge" class:tuning={tuningStatus === 'TUNING'}>
|
|
||||||
{tuningStatus}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="frequency-row">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Frequency A</div>
|
|
||||||
<div class="value-display">{(frequencyA / 1000).toFixed(3)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Frequency B</div>
|
|
||||||
<div class="value-display">{(frequencyB / 1000).toFixed(3)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
class:btn-primary={!operate}
|
|
||||||
class:btn-danger={operate}
|
|
||||||
on:click={toggleOperate}
|
|
||||||
>
|
|
||||||
{operate ? 'STANDBY' : 'OPERATE'}
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" disabled>BYPASS</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-danger tune-btn"
|
|
||||||
disabled={tuning || !operate}
|
|
||||||
on:click={startTune}
|
|
||||||
>
|
|
||||||
{tuning ? 'TUNING...' : 'TUNE'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tuner-card {
|
.card {
|
||||||
min-width: 350px;
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-status {
|
.tuning-badge {
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background: var(--bg-secondary);
|
border-radius: 12px;
|
||||||
border: 1px solid var(--border-color);
|
font-size: 11px;
|
||||||
border-radius: 4px;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.tuning {
|
.tuning-badge.tuning {
|
||||||
background: var(--accent-green);
|
background: rgba(255, 152, 0, 0.2);
|
||||||
color: white;
|
color: #ff9800;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tuning-controls {
|
@keyframes pulse {
|
||||||
margin-bottom: 16px;
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tuning-label {
|
.status-dot {
|
||||||
font-size: 12px;
|
width: 8px;
|
||||||
margin-bottom: 8px;
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 8px #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.antenna-buttons {
|
.status-dot.disconnected {
|
||||||
|
background: #f44336;
|
||||||
|
box-shadow: 0 0 8px #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
flex-direction: column;
|
||||||
margin: 12px 0;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.antenna-btn {
|
/* Power Display */
|
||||||
flex: 1;
|
/* Power + SWR Row */
|
||||||
padding: 12px;
|
.power-swr-row {
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-btn.active {
|
|
||||||
background: var(--accent-blue);
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-values {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-box {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-box .value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequency-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 16px;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
.power-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.power-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons button {
|
.power-label-inline {
|
||||||
flex: 1;
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-value-inline {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-fill {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-circle-compact {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||||
|
border: 4px solid var(--swr-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 0 25px var(--swr-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-value-compact {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--swr-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swr-label-compact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* SWR Circle */
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Capacitors */
|
||||||
|
.capacitors {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frequencies */
|
||||||
|
.freq-display {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-item {
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-unit {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tune-btn {
|
.tune-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tune-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tune-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tune-icon {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
562
web/src/components/Ultrabeam.svelte
Normal file
562
web/src/components/Ultrabeam.svelte
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
|
export let status;
|
||||||
|
export let flexradio = null;
|
||||||
|
|
||||||
|
$: connected = status?.connected || false;
|
||||||
|
$: frequency = status?.frequency || 0;
|
||||||
|
$: band = status?.band || 0;
|
||||||
|
$: direction = status?.direction || 0;
|
||||||
|
$: motorsMoving = status?.motors_moving || 0;
|
||||||
|
$: progressTotal = status?.progress_total || 0;
|
||||||
|
$: progressCurrent = status?.progress_current || 0;
|
||||||
|
$: elementLengths = status?.element_lengths || [];
|
||||||
|
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
||||||
|
|
||||||
|
// FlexRadio interlock
|
||||||
|
$: interlockConnected = flexradio?.connected || false;
|
||||||
|
$: interlockState = flexradio?.interlock_state || null;
|
||||||
|
$: interlockColor = getInterlockColor(interlockState);
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
$: if (flexradio) {
|
||||||
|
console.log('FlexRadio data:', {
|
||||||
|
connected: flexradio.connected,
|
||||||
|
interlock_state: flexradio.interlock_state,
|
||||||
|
interlockConnected,
|
||||||
|
interlockState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInterlockColor(state) {
|
||||||
|
switch(state) {
|
||||||
|
case 'READY': return '#4caf50';
|
||||||
|
case 'NOT_READY': return '#f44336';
|
||||||
|
case 'PTT_REQUESTED': return '#ffc107';
|
||||||
|
case 'TRANSMITTING': return '#ff9800';
|
||||||
|
default: return 'rgba(255, 255, 255, 0.3)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band names mapping - VL2.3 covers 6M to 40M only
|
||||||
|
// Band 0=6M, 1=10M, 2=12M, 3=15M, 4=17M, 5=20M, 6=30M, 7=40M
|
||||||
|
const bandNames = [
|
||||||
|
'6M', '10M', '12M', '15M', '17M', '20M', '30M', '40M'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Detect band from frequency
|
||||||
|
$: detectedBand = detectBandFromFrequency(frequency, band);
|
||||||
|
|
||||||
|
function detectBandFromFrequency(freq, bandIndex) {
|
||||||
|
// If band index is valid (0-7), use it directly
|
||||||
|
if (bandIndex >= 0 && bandIndex <= 7) {
|
||||||
|
return bandNames[bandIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise detect from frequency (in kHz)
|
||||||
|
if (freq >= 7000 && freq <= 7300) return '40M';
|
||||||
|
if (freq >= 10100 && freq <= 10150) return '30M';
|
||||||
|
if (freq >= 14000 && freq <= 14350) return '20M';
|
||||||
|
if (freq >= 18068 && freq <= 18168) return '17M';
|
||||||
|
if (freq >= 21000 && freq <= 21450) return '15M';
|
||||||
|
if (freq >= 24890 && freq <= 24990) return '12M';
|
||||||
|
if (freq >= 28000 && freq <= 29700) return '10M';
|
||||||
|
if (freq >= 50000 && freq <= 54000) return '6M';
|
||||||
|
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction names
|
||||||
|
const directionNames = ['Normal', '180°', 'Bi-Dir'];
|
||||||
|
|
||||||
|
// Auto-track threshold options
|
||||||
|
const thresholdOptions = [
|
||||||
|
{ value: 25, label: '25 kHz' },
|
||||||
|
{ value: 50, label: '50 kHz' },
|
||||||
|
{ value: 100, label: '100 kHz' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Auto-track state
|
||||||
|
let autoTrackEnabled = true; // Default enabled
|
||||||
|
let autoTrackThreshold = 25; // Default 25 kHz
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let targetDirection = 0;
|
||||||
|
|
||||||
|
// Auto-update targetDirection when status changes
|
||||||
|
$: targetDirection = direction;
|
||||||
|
|
||||||
|
// Element names based on band (corrected order: 0=6M ... 10=160M)
|
||||||
|
$: elementNames = getElementNames(band);
|
||||||
|
|
||||||
|
function getElementNames(band) {
|
||||||
|
// 30M (band 6) and 40M (band 7): Reflector (inverted), Radiator (inverted)
|
||||||
|
if (band === 6 || band === 7) {
|
||||||
|
return ['Radiator (30/40M)', 'Reflector (30/40M)', null];
|
||||||
|
}
|
||||||
|
// 6M to 20M (bands 0-5): Reflector, Radiator, Director 1
|
||||||
|
if (band >= 0 && band <= 5) {
|
||||||
|
return ['Reflector', 'Radiator', 'Director 1'];
|
||||||
|
}
|
||||||
|
// Default
|
||||||
|
return ['Element 1', 'Element 2', 'Element 3'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element calibration state
|
||||||
|
let calibrationMode = false;
|
||||||
|
let selectedElement = 0;
|
||||||
|
let elementAdjustment = 0;
|
||||||
|
|
||||||
|
async function setDirection() {
|
||||||
|
if (frequency === 0) {
|
||||||
|
return; // Silently skip if no frequency
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Send command to antenna with current frequency and new direction
|
||||||
|
await api.ultrabeam.setFrequency(frequency, targetDirection);
|
||||||
|
// Also save direction preference for auto-track
|
||||||
|
await api.ultrabeam.setDirection(targetDirection);
|
||||||
|
} catch (err) {
|
||||||
|
// Log error but don't alert - code 30 (busy) is normal
|
||||||
|
console.log('Direction change sent (may show code 30 if busy):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAutoTrack() {
|
||||||
|
try {
|
||||||
|
await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update auto-track:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retract() {
|
||||||
|
if (!confirm('Retract all antenna elements?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.ultrabeam.retract();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to retract:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adjustElement() {
|
||||||
|
try {
|
||||||
|
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
||||||
|
// TODO: Add API call when backend supports it
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
elementAdjustment = 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to adjust element:', err);
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
$: progressPercent = progressTotal > 0 ? (progressCurrent / 60) * 100 : 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>Ultrabeam VL2.3</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics">
|
||||||
|
<!-- Current Status -->
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Frequency</div>
|
||||||
|
<div class="status-value freq">{(frequency / 1000).toFixed(3)} MHz</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Band</div>
|
||||||
|
<div class="status-value band">{detectedBand}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Direction</div>
|
||||||
|
<div class="status-value direction">{directionNames[direction]}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-Track Control -->
|
||||||
|
<div class="control-section compact">
|
||||||
|
<h3>Auto Tracking</h3>
|
||||||
|
<div class="auto-track-controls">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
|
||||||
|
<span>Enable Auto-Track from Tuner</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="threshold-group">
|
||||||
|
<label for="threshold-select">Threshold:</label>
|
||||||
|
<select id="threshold-select" bind:value={autoTrackThreshold} on:change={updateAutoTrack}>
|
||||||
|
{#each thresholdOptions as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Direction buttons on separate line -->
|
||||||
|
<div class="direction-buttons">
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={targetDirection === 0}
|
||||||
|
on:click={() => { targetDirection = 0; setDirection(); }}
|
||||||
|
>
|
||||||
|
Normal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={targetDirection === 1}
|
||||||
|
on:click={() => { targetDirection = 1; setDirection(); }}
|
||||||
|
>
|
||||||
|
180°
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dir-btn"
|
||||||
|
class:active={targetDirection === 2}
|
||||||
|
on:click={() => { targetDirection = 2; setDirection(); }}
|
||||||
|
>
|
||||||
|
Bi-Dir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Motor Progress -->
|
||||||
|
{#if motorsMoving > 0}
|
||||||
|
<div class="progress-section">
|
||||||
|
<h3>Motors Moving...</h3>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">{progressCurrent} / 60 ({progressPercent.toFixed(0)}%)</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Element Lengths Display - HIDDEN: Command 9 returns status instead -->
|
||||||
|
<!--
|
||||||
|
<div class="elements-section">
|
||||||
|
<h3>Element Lengths (mm)</h3>
|
||||||
|
<div class="elements-grid">
|
||||||
|
{#each elementLengths.slice(0, 3) as length, i}
|
||||||
|
{#if length > 0 && elementNames[i]}
|
||||||
|
<div class="element-item">
|
||||||
|
<div class="element-label">{elementNames[i]}</div>
|
||||||
|
<div class="element-value">{length} mm</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Calibration Mode - HIDDEN: Command 9 doesn't work -->
|
||||||
|
<!--
|
||||||
|
<div class="calibration-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Calibration</h3>
|
||||||
|
<button
|
||||||
|
class="btn-toggle"
|
||||||
|
class:active={calibrationMode}
|
||||||
|
on:click={() => calibrationMode = !calibrationMode}
|
||||||
|
>
|
||||||
|
{calibrationMode ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if calibrationMode}
|
||||||
|
<div class="calibration-controls">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="element-select">Element</label>
|
||||||
|
<select id="element-select" bind:value={selectedElement}>
|
||||||
|
{#each elementLengths.slice(0, 3) as length, i}
|
||||||
|
{#if length > 0 && elementNames[i]}
|
||||||
|
<option value={i}>{elementNames[i]} ({length}mm)</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="adjustment">Adjustment (mm)</label>
|
||||||
|
<input
|
||||||
|
id="adjustment"
|
||||||
|
type="number"
|
||||||
|
bind:value={elementAdjustment}
|
||||||
|
step="1"
|
||||||
|
placeholder="±10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-caution" on:click={adjustElement}>
|
||||||
|
<span class="icon">⚙️</span>
|
||||||
|
Apply Adjustment
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="warning-text">
|
||||||
|
⚠️ Calibration changes are saved after 12 seconds. Do not turn off during this time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-danger" on:click={retract}>
|
||||||
|
<span class="icon">↓</span>
|
||||||
|
Retract Elements
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #666;
|
||||||
|
box-shadow: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Grid */
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4fc3f7;
|
||||||
|
text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.freq {
|
||||||
|
color: #66bb6a;
|
||||||
|
font-size: 22px;
|
||||||
|
text-shadow: 0 0 10px rgba(102, 187, 106, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.band {
|
||||||
|
color: #ffa726;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 167, 38, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value.direction {
|
||||||
|
color: #ab47bc;
|
||||||
|
text-shadow: 0 0 10px rgba(171, 71, 188, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Section */
|
||||||
|
.control-section {
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section.compact {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-track-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-btn {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(79, 195, 247, 0.08);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-btn:hover {
|
||||||
|
border-color: rgba(79, 195, 247, 0.6);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
background: rgba(79, 195, 247, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dir-btn.active {
|
||||||
|
border-color: #4fc3f7;
|
||||||
|
color: #4fc3f7;
|
||||||
|
background: rgba(79, 195, 247, 0.2);
|
||||||
|
box-shadow: 0 0 20px rgba(79, 195, 247, 0.4);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Section */
|
||||||
|
.progress-section {
|
||||||
|
background: rgba(79, 195, 247, 0.1);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4fc3f7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4fc3f7, #03a9f4);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
$: relays = status?.relays || [];
|
$: relays = status?.relays || [];
|
||||||
|
$: connected = status?.connected || false;
|
||||||
|
|
||||||
const relayNames = {
|
const relayNames = {
|
||||||
1: 'Power Supply',
|
1: 'Power Supply',
|
||||||
@@ -51,102 +52,254 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="webswitch-card card">
|
<div class="card">
|
||||||
<h2>
|
<div class="card-header">
|
||||||
1216RH
|
<h2>WebSwitch</h2>
|
||||||
<span class="status-indicator" class:status-online={relays.length > 0} class:status-offline={relays.length === 0}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="relays">
|
|
||||||
{#each [1, 2, 3, 4, 5] as relayNum}
|
|
||||||
{@const relay = relays.find(r => r.number === relayNum)}
|
|
||||||
{@const isOn = relay?.state || false}
|
|
||||||
<div class="relay-row">
|
|
||||||
<span class="relay-name">{relayNames[relayNum]}</span>
|
|
||||||
<button
|
|
||||||
class="relay-toggle"
|
|
||||||
class:active={isOn}
|
|
||||||
disabled={loading[relayNum]}
|
|
||||||
on:click={() => toggleRelay(relayNum)}
|
|
||||||
>
|
|
||||||
<div class="toggle-icon"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="metrics">
|
||||||
<button class="btn btn-primary" on:click={allOn}>ALL ON</button>
|
<div class="relays">
|
||||||
<button class="btn btn-danger" on:click={allOff}>ALL OFF</button>
|
{#each [1, 2, 3, 4, 5] as relayNum}
|
||||||
|
{@const relay = relays.find(r => r.number === relayNum)}
|
||||||
|
{@const isOn = relay?.state || false}
|
||||||
|
<div class="relay-card" class:relay-on={isOn}>
|
||||||
|
<div class="relay-info">
|
||||||
|
<div class="relay-details">
|
||||||
|
<div class="relay-name">{relayNames[relayNum]}</div>
|
||||||
|
<div class="relay-status">{isOn ? 'ON' : 'OFF'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="relay-toggle"
|
||||||
|
class:active={isOn}
|
||||||
|
class:loading={loading[relayNum]}
|
||||||
|
disabled={loading[relayNum]}
|
||||||
|
on:click={() => toggleRelay(relayNum)}
|
||||||
|
>
|
||||||
|
<div class="toggle-track">
|
||||||
|
<div class="toggle-thumb"></div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="control-btn all-on" on:click={allOn}>
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
ALL ON
|
||||||
|
</button>
|
||||||
|
<button class="control-btn all-off" on:click={allOff}>
|
||||||
|
<span class="btn-icon">⏻</span>
|
||||||
|
ALL OFF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.webswitch-card {
|
.card {
|
||||||
min-width: 280px;
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.card-header {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relays {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(79, 195, 247, 0.05);
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-name {
|
h2 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-toggle {
|
.status-dot {
|
||||||
width: 60px;
|
width: 8px;
|
||||||
height: 32px;
|
height: 8px;
|
||||||
background: #555;
|
|
||||||
border-radius: 16px;
|
|
||||||
position: relative;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-toggle.active {
|
|
||||||
background: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: absolute;
|
background: #4caf50;
|
||||||
top: 4px;
|
box-shadow: 0 0 8px #4caf50;
|
||||||
left: 4px;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-toggle.active .toggle-icon {
|
.status-dot.disconnected {
|
||||||
transform: translateX(28px);
|
background: #f44336;
|
||||||
|
box-shadow: 0 0 8px #f44336;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.metrics {
|
||||||
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls button {
|
/* Relays */
|
||||||
flex: 1;
|
.relays {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-card.relay-on {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
border-color: rgba(76, 175, 80, 0.3);
|
||||||
|
box-shadow: 0 0 15px rgba(76, 175, 80, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-card.relay-on .relay-status {
|
||||||
|
color: #4caf50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.relay-toggle {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-track {
|
||||||
|
width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-toggle:hover .toggle-track {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-toggle.active .toggle-track {
|
||||||
|
background: linear-gradient(135deg, #4caf50, #66bb6a);
|
||||||
|
border-color: #4caf50;
|
||||||
|
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-toggle.active .toggle-thumb {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-toggle:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-on {
|
||||||
|
background: linear-gradient(135deg, #4caf50, #66bb6a);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-on:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-off {
|
||||||
|
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-off:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -47,23 +47,28 @@ export const api = {
|
|||||||
|
|
||||||
// Tuner
|
// Tuner
|
||||||
tuner: {
|
tuner: {
|
||||||
operate: (operate) => request('/tuner/operate', {
|
setOperate: (value) => request('/tuner/operate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ operate }),
|
body: JSON.stringify({ value }),
|
||||||
}),
|
}),
|
||||||
tune: () => request('/tuner/tune', { method: 'POST' }),
|
setBypass: (value) => request('/tuner/bypass', {
|
||||||
antenna: (antenna) => request('/tuner/antenna', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ antenna }),
|
body: JSON.stringify({ value }),
|
||||||
}),
|
}),
|
||||||
|
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Antenna Genius
|
// Antenna Genius
|
||||||
antenna: {
|
antenna: {
|
||||||
set: (radio, antenna) => request('/antenna/set', {
|
selectAntenna: (port, antenna) => request('/antenna/select', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ radio, antenna }),
|
body: JSON.stringify({ port, antenna }),
|
||||||
}),
|
}),
|
||||||
|
deselectAntenna: (port, antenna) => request('/antenna/deselect', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ port, antenna }),
|
||||||
|
}),
|
||||||
|
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Power Genius
|
// Power Genius
|
||||||
@@ -72,5 +77,37 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mode }),
|
body: JSON.stringify({ mode }),
|
||||||
}),
|
}),
|
||||||
|
setOperate: (value) => request('/power/operate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rotator Genius
|
||||||
|
rotator: {
|
||||||
|
setHeading: (heading) => request('/rotator/heading', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ heading }),
|
||||||
|
}),
|
||||||
|
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
||||||
|
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||||
|
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ultrabeam
|
||||||
|
ultrabeam: {
|
||||||
|
setFrequency: (frequency, direction) => request('/ultrabeam/frequency', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ frequency, direction }),
|
||||||
|
}),
|
||||||
|
retract: () => request('/ultrabeam/retract', { method: 'POST' }),
|
||||||
|
setAutoTrack: (enabled, threshold) => request('/ultrabeam/autotrack', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ enabled, threshold }),
|
||||||
|
}),
|
||||||
|
setDirection: (direction) => request('/ultrabeam/direction', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ direction }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -28,6 +28,7 @@ class WebSocketService {
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
if (message.type === 'update') {
|
if (message.type === 'update') {
|
||||||
|
console.log('System status updated:', message.data);
|
||||||
systemStatus.set(message.data);
|
systemStatus.set(message.data);
|
||||||
lastUpdate.set(new Date(message.timestamp));
|
lastUpdate.set(new Date(message.timestamp));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user