Compare commits
30 Commits
bceac40518
...
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 |
@@ -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"`
|
||||||
@@ -49,8 +64,12 @@ func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
|
|||||||
return &DeviceManager{
|
return &DeviceManager{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
updateInterval: 1 * time.Second, // Update status every second
|
updateInterval: 200 * time.Millisecond, // Update status every second
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
freqThreshold: 25000, // 25 kHz default
|
||||||
|
autoTrackEnabled: true, // Enabled by default
|
||||||
|
ultrabeamDirection: 0, // Normal direction by default
|
||||||
|
freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,27 +86,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
|
||||||
<<<<<<< HEAD
|
|
||||||
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
dm.rotatorGenius = rotatorgenius.New(
|
dm.rotatorGenius = rotatorgenius.New(
|
||||||
dm.config.Devices.RotatorGenius.Host,
|
dm.config.Devices.RotatorGenius.Host,
|
||||||
dm.config.Devices.RotatorGenius.Port,
|
dm.config.Devices.RotatorGenius.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize Ultrabeam
|
||||||
|
log.Printf("Initializing Ultrabeam: host=%s port=%d", dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port)
|
||||||
|
dm.ultrabeam = ultrabeam.New(
|
||||||
|
dm.config.Devices.Ultrabeam.Host,
|
||||||
|
dm.config.Devices.Ultrabeam.Port,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize FlexRadio if enabled
|
||||||
|
if dm.config.Devices.FlexRadio.Enabled {
|
||||||
|
log.Printf("Initializing FlexRadio: host=%s port=%d", dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port)
|
||||||
|
dm.flexRadio = flexradio.New(
|
||||||
|
dm.config.Devices.FlexRadio.Host,
|
||||||
|
dm.config.Devices.FlexRadio.Port,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -98,7 +160,6 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
dm.config.Location.Longitude,
|
dm.config.Location.Longitude,
|
||||||
)
|
)
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Start device polling in background (non-blocking)
|
// Start device polling in background (non-blocking)
|
||||||
go func() {
|
go func() {
|
||||||
if err := dm.powerGenius.Start(); err != nil {
|
if err := dm.powerGenius.Start(); err != nil {
|
||||||
@@ -126,12 +187,26 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Println("RotatorGenius goroutine launched")
|
log.Println("RotatorGenius goroutine launched")
|
||||||
=======
|
|
||||||
// Start PowerGenius continuous polling
|
log.Println("About to launch Ultrabeam goroutine...")
|
||||||
if err := dm.powerGenius.Start(); err != nil {
|
go func() {
|
||||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
log.Println("Starting Ultrabeam polling goroutine...")
|
||||||
|
if err := dm.ultrabeam.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start Ultrabeam polling: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("Ultrabeam goroutine launched")
|
||||||
|
|
||||||
|
// Start FlexRadio if enabled
|
||||||
|
if dm.flexRadio != nil {
|
||||||
|
log.Println("Starting FlexRadio connection...")
|
||||||
|
go func() {
|
||||||
|
if err := dm.flexRadio.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start FlexRadio: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("FlexRadio goroutine launched")
|
||||||
}
|
}
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
log.Println("Device manager initialized")
|
log.Println("Device manager initialized")
|
||||||
return nil
|
return nil
|
||||||
@@ -143,6 +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)
|
||||||
@@ -160,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() {
|
||||||
@@ -196,7 +342,6 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
log.Printf("Power Genius error: %v", err)
|
log.Printf("Power Genius error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Tuner Genius
|
// Tuner Genius
|
||||||
if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
||||||
status.TunerGenius = tgStatus
|
status.TunerGenius = tgStatus
|
||||||
@@ -217,28 +362,84 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("Rotator Genius error: %v", err)
|
log.Printf("Rotator Genius error: %v", err)
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
// // Tuner Genius
|
|
||||||
// if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
|
||||||
// status.TunerGenius = tgStatus
|
|
||||||
// } else {
|
|
||||||
// log.Printf("Tuner Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Antenna Genius
|
// Ultrabeam
|
||||||
// if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||||
// status.AntennaGenius = agStatus
|
status.Ultrabeam = ubStatus
|
||||||
// } else {
|
|
||||||
// log.Printf("Antenna Genius error: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Rotator Genius
|
// Sync direction with Ultrabeam if user hasn't explicitly set one
|
||||||
// if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
// This prevents auto-track from using wrong direction before user changes it
|
||||||
// status.RotatorGenius = rgStatus
|
if !dm.ultrabeamDirectionSet {
|
||||||
// } else {
|
dm.ultrabeamDirection = ubStatus.Direction
|
||||||
// log.Printf("Rotator Genius error: %v", err)
|
}
|
||||||
// }
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
} 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)
|
// Solar Data (fetched every 15 minutes, cached)
|
||||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||||
@@ -263,6 +464,8 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
if dm.hub != nil {
|
if dm.hub != nil {
|
||||||
dm.hub.BroadcastStatusUpdate(status)
|
dm.hub.BroadcastStatusUpdate(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dm *DeviceManager) GetStatus() *SystemStatus {
|
func (dm *DeviceManager) GetStatus() *SystemStatus {
|
||||||
@@ -298,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,41 +49,32 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
||||||
|
|
||||||
// Rotator endpoints
|
// Rotator endpoints
|
||||||
<<<<<<< HEAD
|
|
||||||
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
||||||
=======
|
|
||||||
mux.HandleFunc("/api/rotator/move", s.handleRotatorMove)
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW)
|
mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW)
|
||||||
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW)
|
||||||
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
||||||
|
|
||||||
|
// Ultrabeam endpoints
|
||||||
|
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/autotrack", s.handleUltrabeamAutoTrack)
|
||||||
|
mux.HandleFunc("/api/ultrabeam/direction", s.handleUltrabeamDirection)
|
||||||
|
|
||||||
// Tuner endpoints
|
// Tuner endpoints
|
||||||
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
||||||
<<<<<<< HEAD
|
|
||||||
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||||
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
||||||
|
|
||||||
// Antenna Genius endpoints
|
// Antenna Genius endpoints
|
||||||
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
||||||
|
mux.HandleFunc("/api/antenna/deselect", s.handleAntennaDeselect)
|
||||||
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
||||||
|
|
||||||
// Power Genius endpoints
|
// Power Genius endpoints
|
||||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
||||||
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||||
=======
|
|
||||||
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune)
|
|
||||||
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna)
|
|
||||||
|
|
||||||
// Antenna Genius endpoints
|
// Note: Static files are now served from embedded FS in main.go
|
||||||
mux.HandleFunc("/api/antenna/set", s.handleAntennaSet)
|
|
||||||
|
|
||||||
// Power Genius endpoints
|
|
||||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
// Static files (will be frontend)
|
|
||||||
mux.Handle("/", http.FileServer(http.Dir("./web/dist")))
|
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -196,23 +187,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rotator handlers
|
// Rotator handlers
|
||||||
<<<<<<< HEAD
|
|
||||||
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
||||||
=======
|
|
||||||
func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
<<<<<<< HEAD
|
|
||||||
Heading int `json:"heading"`
|
Heading int `json:"heading"`
|
||||||
=======
|
|
||||||
Rotator int `json:"rotator"`
|
|
||||||
Azimuth int `json:"azimuth"`
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -220,11 +202,7 @@ func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
||||||
=======
|
|
||||||
if err := s.deviceManager.RotatorGenius().MoveToAzimuth(req.Rotator, req.Azimuth); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,17 +216,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
||||||
=======
|
|
||||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
|
||||||
if err != nil || rotator < 1 || rotator > 2 {
|
|
||||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCW(rotator); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,17 +230,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
||||||
=======
|
|
||||||
rotator, err := strconv.Atoi(r.URL.Query().Get("rotator"))
|
|
||||||
if err != nil || rotator < 1 || rotator > 2 {
|
|
||||||
http.Error(w, "Invalid rotator number", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.deviceManager.RotatorGenius().RotateCCW(rotator); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -302,11 +260,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
<<<<<<< HEAD
|
|
||||||
Value int `json:"value"`
|
Value int `json:"value"`
|
||||||
=======
|
|
||||||
Operate bool `json:"operate"`
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -314,7 +268,6 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -339,9 +292,6 @@ func (s *Server) handleTunerBypass(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
||||||
=======
|
|
||||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Operate); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -363,22 +313,15 @@ func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Antenna Genius handlers
|
// Antenna Genius handlers
|
||||||
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
||||||
=======
|
|
||||||
func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
<<<<<<< HEAD
|
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
Antenna int `json:"antenna"`
|
Antenna int `json:"antenna"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +330,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
||||||
=======
|
|
||||||
if err := s.deviceManager.TunerGenius().ActivateAntenna(req.Antenna); err != nil {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -399,22 +338,14 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
|
||||||
=======
|
|
||||||
// Antenna Genius handlers
|
|
||||||
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
|
||||||
=======
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Radio int `json:"radio"`
|
Port int `json:"port"`
|
||||||
Antenna int `json:"antenna"`
|
Antenna int `json:"antenna"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,8 +354,24 @@ func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.deviceManager.AntennaGenius().SetRadioAntenna(req.Radio, req.Antenna); err != nil {
|
log.Printf("Deselecting antenna %d from port %d", req.Antenna, req.Port)
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
if err := s.deviceManager.AntennaGenius().DeselectAntenna(req.Port, req.Antenna); err != nil {
|
||||||
|
log.Printf("Failed to deselect antenna: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Successfully deselected antenna %d from port %d", req.Antenna, req.Port)
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -456,7 +403,6 @@ func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
@@ -480,8 +426,90 @@ func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
// Ultrabeam handlers
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
func (s *Server) handleUltrabeamFrequency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Frequency int `json:"frequency"` // KHz
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save direction for auto-track to use
|
||||||
|
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||||
|
|
||||||
|
if err := s.deviceManager.Ultrabeam().SetFrequency(req.Frequency, req.Direction); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.deviceManager.Ultrabeam().Retract(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamAutoTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Threshold int `json:"threshold"` // kHz
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.deviceManager.SetAutoTrack(req.Enabled, req.Threshold*1000) // Convert kHz to Hz
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just save the direction preference for auto-track to use
|
||||||
|
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type DevicesConfig struct {
|
|||||||
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
|
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
|
||||||
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
||||||
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
||||||
|
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
|
||||||
|
FlexRadio FlexRadioConfig `yaml:"flexradio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSwitchConfig struct {
|
type WebSwitchConfig struct {
|
||||||
@@ -51,6 +53,18 @@ type RotatorGeniusConfig struct {
|
|||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UltrabeamConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlexRadioConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
InterlockName string `yaml:"interlock_name"`
|
||||||
|
}
|
||||||
|
|
||||||
type WeatherConfig struct {
|
type WeatherConfig struct {
|
||||||
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
||||||
LightningEnabled bool `yaml:"lightning_enabled"`
|
LightningEnabled bool `yaml:"lightning_enabled"`
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package antennagenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
<<<<<<< HEAD
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -53,43 +52,17 @@ type Antenna struct {
|
|||||||
RX string `json:"rx"`
|
RX string `json:"rx"`
|
||||||
InBand string `json:"in_band"`
|
InBand string `json:"in_band"`
|
||||||
Hotkey int `json:"hotkey"`
|
Hotkey int `json:"hotkey"`
|
||||||
=======
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
host string
|
|
||||||
port int
|
|
||||||
conn net.Conn
|
|
||||||
}
|
|
||||||
|
|
||||||
type Status struct {
|
|
||||||
Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index)
|
|
||||||
Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index)
|
|
||||||
Connected bool `json:"connected"`
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
<<<<<<< HEAD
|
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
=======
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
<<<<<<< HEAD
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
defer c.connMu.Unlock()
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
@@ -97,26 +70,20 @@ func (c *Client) Connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect: %w", err)
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
<<<<<<< HEAD
|
|
||||||
c.reader = bufio.NewReader(c.conn)
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
|
||||||
// Read and discard banner
|
// Read and discard banner
|
||||||
_, _ = c.reader.ReadString('\n')
|
_, _ = c.reader.ReadString('\n')
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
<<<<<<< HEAD
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
defer c.connMu.Unlock()
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
@@ -124,15 +91,12 @@ func (c *Client) Close() error {
|
|||||||
close(c.stopChan)
|
close(c.stopChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
if c.running {
|
if c.running {
|
||||||
return nil
|
return nil
|
||||||
@@ -214,21 +178,22 @@ func (c *Client) pollLoop() {
|
|||||||
|
|
||||||
func (c *Client) initialize() error {
|
func (c *Client) initialize() error {
|
||||||
// Get antenna list
|
// Get antenna list
|
||||||
|
log.Println("AntennaGenius: Getting antenna list...")
|
||||||
antennas, err := c.getAntennaList()
|
antennas, err := c.getAntennaList()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get antenna list: %w", err)
|
return fmt.Errorf("failed to get antenna list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("AntennaGenius: Found %d antennas", len(antennas))
|
||||||
|
for i, ant := range antennas {
|
||||||
|
log.Printf("AntennaGenius: Antenna %d: number=%d, name=%s", i, ant.Number, ant.Name)
|
||||||
|
}
|
||||||
|
|
||||||
c.antennasMu.Lock()
|
c.antennasMu.Lock()
|
||||||
c.antennas = antennas
|
c.antennas = antennas
|
||||||
c.antennasMu.Unlock()
|
c.antennasMu.Unlock()
|
||||||
|
|
||||||
// Subscribe to port updates
|
// Initialize status BEFORE subscribing so parsePortStatus can update it
|
||||||
if err := c.subscribeToPortUpdates(); err != nil {
|
|
||||||
return fmt.Errorf("failed to subscribe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize status
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.lastStatus = &Status{
|
c.lastStatus = &Status{
|
||||||
PortA: &PortStatus{},
|
PortA: &PortStatus{},
|
||||||
@@ -238,6 +203,23 @@ func (c *Client) initialize() error {
|
|||||||
}
|
}
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
log.Println("AntennaGenius: Status initialized, now subscribing to port updates...")
|
||||||
|
|
||||||
|
// Subscribe to port updates (this will parse and update port status)
|
||||||
|
if err := c.subscribeToPortUpdates(); err != nil {
|
||||||
|
return fmt.Errorf("failed to subscribe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request initial status for both ports
|
||||||
|
log.Println("AntennaGenius: Requesting additional port status...")
|
||||||
|
_, _ = c.sendCommand("port get 1") // Port A
|
||||||
|
_, _ = c.sendCommand("port get 2") // Port B
|
||||||
|
|
||||||
|
c.statusMu.RLock()
|
||||||
|
log.Printf("AntennaGenius: Initialization complete - PortA.RxAnt=%d, PortB.RxAnt=%d",
|
||||||
|
c.lastStatus.PortA.RxAnt, c.lastStatus.PortB.RxAnt)
|
||||||
|
c.statusMu.RUnlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,46 +281,10 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
|||||||
|
|
||||||
func (c *Client) getAntennaList() ([]Antenna, error) {
|
func (c *Client) getAntennaList() ([]Antenna, error) {
|
||||||
resp, err := c.sendCommand("antenna list")
|
resp, err := c.sendCommand("antenna list")
|
||||||
=======
|
|
||||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
|
||||||
if c.conn == nil {
|
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next command ID from global counter
|
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
|
||||||
|
|
||||||
// Format command with ID: C<id>|<command>
|
|
||||||
fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd)
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
_, err := c.conn.Write([]byte(fullCmd))
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to send command: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read response
|
|
||||||
reader := bufio.NewReader(c.conn)
|
|
||||||
response, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
c.conn = nil
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(response), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetStatus() (*Status, error) {
|
|
||||||
resp, err := c.sendCommand("status")
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
var antennas []Antenna
|
var antennas []Antenna
|
||||||
|
|
||||||
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
||||||
@@ -405,58 +351,11 @@ func (c *Client) parseAntennaLine(line string) Antenna {
|
|||||||
|
|
||||||
func (c *Client) subscribeToPortUpdates() error {
|
func (c *Client) subscribeToPortUpdates() error {
|
||||||
resp, err := c.sendCommand("sub port all")
|
resp, err := c.sendCommand("sub port all")
|
||||||
=======
|
|
||||||
return c.parseStatus(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) parseStatus(resp string) (*Status, error) {
|
|
||||||
status := &Status{
|
|
||||||
Connected: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse response format from 4O3A API
|
|
||||||
// Expected format will vary - this is a basic parser
|
|
||||||
pairs := strings.Fields(resp)
|
|
||||||
|
|
||||||
for _, pair := range pairs {
|
|
||||||
parts := strings.SplitN(pair, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := parts[0]
|
|
||||||
value := parts[1]
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "radio1", "r1":
|
|
||||||
status.Radio1Antenna, _ = strconv.Atoi(value)
|
|
||||||
case "radio2", "r2":
|
|
||||||
status.Radio2Antenna, _ = strconv.Atoi(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRadioAntenna sets which antenna a radio should use
|
|
||||||
// radio: 1 or 2
|
|
||||||
// antenna: 0-7 (antenna index)
|
|
||||||
func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
|
||||||
if radio < 1 || radio > 2 {
|
|
||||||
return fmt.Errorf("radio must be 1 or 2")
|
|
||||||
}
|
|
||||||
if antenna < 0 || antenna > 7 {
|
|
||||||
return fmt.Errorf("antenna must be between 0 and 7")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("set radio%d=%d", radio, antenna)
|
|
||||||
resp, err := c.sendCommand(cmd)
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("AntennaGenius: Failed to subscribe: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Parse initial port status from subscription response
|
// Parse initial port status from subscription response
|
||||||
// The response may contain S0|port messages with current status
|
// The response may contain S0|port messages with current status
|
||||||
lines := strings.Split(resp, "\n")
|
lines := strings.Split(resp, "\n")
|
||||||
@@ -465,18 +364,12 @@ func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
|||||||
if strings.HasPrefix(line, "S0|port") {
|
if strings.HasPrefix(line, "S0|port") {
|
||||||
c.parsePortStatus(line)
|
c.parsePortStatus(line)
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
// Check response for success
|
|
||||||
if !strings.Contains(strings.ToLower(resp), "ok") && resp != "" {
|
|
||||||
// If response doesn't contain "ok" but isn't empty, assume success
|
|
||||||
// (some devices may return the new state instead of "ok")
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Println("AntennaGenius: Subscription complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func (c *Client) parsePortStatus(line string) {
|
func (c *Client) parsePortStatus(line string) {
|
||||||
// Format: S0|port <id> auto=<0|1> source=<src> band=<n> freq=<f> nickname=<name> rxant=<n> txant=<n> inband=<n> tx=<0|1> inhibit=<n>
|
// Format: S0|port <id> auto=<0|1> source=<src> band=<n> freq=<f> nickname=<name> rxant=<n> txant=<n> inband=<n> tx=<0|1> inhibit=<n>
|
||||||
|
|
||||||
@@ -547,7 +440,14 @@ func (c *Client) GetStatus() (*Status, error) {
|
|||||||
return &Status{Connected: false}, nil
|
return &Status{Connected: false}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.lastStatus, nil
|
// Check if device is actually alive
|
||||||
|
// If no antennas and all values are default, device is probably off
|
||||||
|
status := *c.lastStatus
|
||||||
|
if len(status.Antennas) == 0 || (status.PortA != nil && status.PortA.Source == "" && status.PortB != nil && status.PortB.Source == "") {
|
||||||
|
status.Connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAntenna sets the antenna for a specific port
|
// SetAntenna sets the antenna for a specific port
|
||||||
@@ -557,25 +457,22 @@ func (c *Client) SetAntenna(port, antenna int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeselectAntenna deselects an antenna from a port (sets rxant=00)
|
||||||
|
// Command format: "C1|port set <port> rxant=00"
|
||||||
|
func (c *Client) DeselectAntenna(port, antenna int) error {
|
||||||
|
cmd := fmt.Sprintf("port set %d rxant=00", port)
|
||||||
|
log.Printf("AntennaGenius: Sending deselect command: %s", cmd)
|
||||||
|
resp, err := c.sendCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AntennaGenius: Deselect failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("AntennaGenius: Deselect response: %s", resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Reboot reboots the device
|
// Reboot reboots the device
|
||||||
func (c *Client) Reboot() error {
|
func (c *Client) Reboot() error {
|
||||||
_, err := c.sendCommand("reboot")
|
_, err := c.sendCommand("reboot")
|
||||||
return err
|
return err
|
||||||
=======
|
|
||||||
// GetRadioAntenna gets which antenna a radio is currently using
|
|
||||||
func (c *Client) GetRadioAntenna(radio int) (int, error) {
|
|
||||||
if radio < 1 || radio > 2 {
|
|
||||||
return -1, fmt.Errorf("radio must be 1 or 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := c.GetStatus()
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if radio == 1 {
|
|
||||||
return status.Radio1Antenna, nil
|
|
||||||
}
|
|
||||||
return status.Radio2Antenna, nil
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
||||||
|
)
|
||||||
@@ -3,10 +3,7 @@ package powergenius
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
"log"
|
"log"
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -26,6 +23,14 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
running bool
|
running bool
|
||||||
|
|
||||||
|
// Connection health tracking
|
||||||
|
lastAliveTime time.Time
|
||||||
|
|
||||||
|
// Auto fan management
|
||||||
|
autoFanEnabled bool
|
||||||
|
lastFanMode string // Remember last manual mode
|
||||||
|
autoFanActive bool // Track if auto-fan is currently active (in Broadcast mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -45,10 +50,10 @@ type Status struct {
|
|||||||
BandB string `json:"band_b"`
|
BandB string `json:"band_b"`
|
||||||
FaultPresent bool `json:"fault_present"`
|
FaultPresent bool `json:"fault_present"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
// Peak hold for display (internal)
|
||||||
Meffa string `json:"meffa"`
|
displayPower float64
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
peakTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
@@ -56,6 +61,8 @@ func New(host string, port int) *Client {
|
|||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
|
autoFanEnabled: false, // Auto fan DISABLED - manual control only
|
||||||
|
lastFanMode: "Contest",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,24 +103,17 @@ func (c *Client) Close() error {
|
|||||||
|
|
||||||
// Start begins continuous polling of the device
|
// Start begins continuous polling of the device
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if c.running {
|
if c.running {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
// Initialize connection tracking
|
||||||
|
c.lastAliveTime = time.Now()
|
||||||
|
|
||||||
// Try to connect, but don't fail if it doesn't work
|
// Try to connect, but don't fail if it doesn't work
|
||||||
// The poll loop will keep trying
|
// The poll loop will keep trying
|
||||||
_ = c.Connect()
|
_ = c.Connect()
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
c.running = true
|
c.running = true
|
||||||
go c.pollLoop()
|
go c.pollLoop()
|
||||||
|
|
||||||
@@ -128,7 +128,6 @@ func (c *Client) pollLoop() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
<<<<<<< HEAD
|
|
||||||
// Try to reconnect if not connected
|
// Try to reconnect if not connected
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
@@ -152,12 +151,6 @@ func (c *Client) pollLoop() {
|
|||||||
status, err := c.queryStatus()
|
status, err := c.queryStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Connection lost, close and retry next tick
|
// Connection lost, close and retry next tick
|
||||||
=======
|
|
||||||
status, err := c.queryStatus()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("PowerGenius query error: %v", err)
|
|
||||||
// Try to reconnect
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
c.conn.Close()
|
c.conn.Close()
|
||||||
@@ -165,7 +158,6 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Mark as disconnected and reset all values
|
// Mark as disconnected and reset all values
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.lastStatus = &Status{
|
c.lastStatus = &Status{
|
||||||
@@ -178,21 +170,92 @@ func (c *Client) pollLoop() {
|
|||||||
// Mark as connected
|
// Mark as connected
|
||||||
status.Connected = true
|
status.Connected = true
|
||||||
|
|
||||||
=======
|
// Check if device is actually alive (not just TCP connected)
|
||||||
if err := c.Connect(); err != nil {
|
// If voltage is 0 and temperature is 0, device might be temporarily idle
|
||||||
log.Printf("PowerGenius reconnect failed: %v", err)
|
// Use a 3-second timeout before marking as disconnected (helps with morse code pauses)
|
||||||
|
if status.Voltage == 0 && status.Temperature == 0 {
|
||||||
|
// Check if we've seen valid data recently (within 3 seconds)
|
||||||
|
if time.Since(c.lastAliveTime) > 3*time.Second {
|
||||||
|
status.Connected = false
|
||||||
|
}
|
||||||
|
// else: Keep Connected = true (device is probably just idle between morse letters)
|
||||||
|
} else {
|
||||||
|
// Valid data received, update lastAliveTime
|
||||||
|
c.lastAliveTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak hold logic - keep highest power for 1 second
|
||||||
|
now := time.Now()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
// If new power is higher, update peak
|
||||||
|
if status.PowerForward > c.lastStatus.displayPower {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
} else {
|
||||||
|
// Check if peak has expired (1 second)
|
||||||
|
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||||
|
// Keep old peak
|
||||||
|
status.displayPower = c.lastStatus.displayPower
|
||||||
|
status.peakTime = c.lastStatus.peakTime
|
||||||
|
} else {
|
||||||
|
// Peak expired, use current value
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override PowerForward with display power for frontend
|
||||||
|
status.PowerForward = status.displayPower
|
||||||
|
|
||||||
|
// Auto fan management based on temperature
|
||||||
|
// Do this BEFORE merging to use the fresh temperature value
|
||||||
|
if c.autoFanEnabled {
|
||||||
|
// Use the temperature from the current status message
|
||||||
|
// If it's 0, use the last known temperature
|
||||||
|
temp := status.Temperature
|
||||||
|
if temp == 0 && c.lastStatus != nil {
|
||||||
|
temp = c.lastStatus.Temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMode := strings.ToUpper(status.FanMode)
|
||||||
|
if currentMode == "" && c.lastStatus != nil {
|
||||||
|
currentMode = strings.ToUpper(c.lastStatus.FanMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only act on valid temperature readings
|
||||||
|
if temp > 5.0 { // Ignore invalid/startup readings below 5°C
|
||||||
|
// If temp >= 60°C, switch to Broadcast
|
||||||
|
if temp >= 60.0 && currentMode != "BROADCAST" {
|
||||||
|
if !c.autoFanActive {
|
||||||
|
log.Printf("PowerGenius: Temperature %.1f°C >= 60°C, switching fan to Broadcast mode", temp)
|
||||||
|
c.autoFanActive = true
|
||||||
|
}
|
||||||
|
if err := c.setFanModeInternal("BROADCAST"); err != nil {
|
||||||
|
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If temp <= 55°C, switch back to Contest
|
||||||
|
if temp <= 55.0 && currentMode == "BROADCAST" {
|
||||||
|
if c.autoFanActive {
|
||||||
|
log.Printf("PowerGenius: Temperature %.1f°C <= 55°C, switching fan back to Contest mode", temp)
|
||||||
|
c.autoFanActive = false
|
||||||
|
}
|
||||||
|
if err := c.setFanModeInternal("CONTEST"); err != nil {
|
||||||
|
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
// Merge with existing status (spontaneous messages may only update some fields)
|
// Merge with existing status (spontaneous messages may only update some fields)
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
// Keep existing values for fields not in the new status
|
// Keep existing values for fields not in the new status
|
||||||
if status.PowerForward == 0 && c.lastStatus.PowerForward != 0 {
|
|
||||||
status.PowerForward = c.lastStatus.PowerForward
|
|
||||||
}
|
|
||||||
if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
|
if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
|
||||||
status.Temperature = c.lastStatus.Temperature
|
status.Temperature = c.lastStatus.Temperature
|
||||||
}
|
}
|
||||||
@@ -361,11 +424,6 @@ func (c *Client) parseStatus(resp string) (*Status, error) {
|
|||||||
}
|
}
|
||||||
case "vac":
|
case "vac":
|
||||||
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
case "meffa":
|
|
||||||
status.Meffa = value
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
case "vdd":
|
case "vdd":
|
||||||
status.VDD, _ = strconv.ParseFloat(value, 64)
|
status.VDD, _ = strconv.ParseFloat(value, 64)
|
||||||
case "id":
|
case "id":
|
||||||
@@ -430,15 +488,25 @@ func (c *Client) SetFanMode(mode string) error {
|
|||||||
"BROADCAST": true,
|
"BROADCAST": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validModes[mode] {
|
// Normalize mode to title case for comparison
|
||||||
|
modeUpper := strings.ToUpper(mode)
|
||||||
|
if !validModes[modeUpper] {
|
||||||
return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode)
|
return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember last manual mode (if not triggered by auto-fan)
|
||||||
|
// We store it in title case: "Standard", "Contest", "Broadcast"
|
||||||
|
c.lastFanMode = strings.Title(strings.ToLower(mode))
|
||||||
|
|
||||||
|
return c.setFanModeInternal(modeUpper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFanModeInternal sets fan mode without updating lastFanMode (for auto-fan)
|
||||||
|
func (c *Client) setFanModeInternal(mode string) error {
|
||||||
cmd := fmt.Sprintf("setup fanmode=%s", mode)
|
cmd := fmt.Sprintf("setup fanmode=%s", mode)
|
||||||
_, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
// SetOperate sets the operate mode
|
// SetOperate sets the operate mode
|
||||||
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
||||||
@@ -451,5 +519,3 @@ func (c *Client) SetOperate(value int) error {
|
|||||||
_, err := c.sendCommand(cmd)
|
_, err := c.sendCommand(cmd)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -26,6 +24,7 @@ type Client struct {
|
|||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Heading int `json:"heading"`
|
Heading int `json:"heading"`
|
||||||
|
TargetHeading int `json:"target_heading"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,18 +150,7 @@ func (c *Client) sendCommand(cmd string) error {
|
|||||||
return fmt.Errorf("not connected")
|
return fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
_, err := c.conn.Write([]byte(cmd))
|
_, err := c.conn.Write([]byte(cmd))
|
||||||
=======
|
|
||||||
// Get next command ID from global counter
|
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
|
||||||
|
|
||||||
// Format command with ID: C<id>|<command>
|
|
||||||
fullCmd := fmt.Sprintf("C%d%s", cmdID, cmd)
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
_, err := c.conn.Write([]byte(fullCmd))
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
c.reader = nil
|
c.reader = nil
|
||||||
@@ -224,6 +212,11 @@ func (c *Client) parseStatus(response string) *Status {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
status.Heading = heading
|
status.Heading = heading
|
||||||
}
|
}
|
||||||
|
targetStr := response[19:22]
|
||||||
|
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||||
|
if err == nil {
|
||||||
|
status.TargetHeading = targetHeading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
<<<<<<< HEAD
|
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -23,11 +22,6 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
running bool
|
running bool
|
||||||
=======
|
|
||||||
host string
|
|
||||||
port int
|
|
||||||
conn net.Conn
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -54,18 +48,17 @@ type Status struct {
|
|||||||
RelayC2 int `json:"c2"`
|
RelayC2 int `json:"c2"`
|
||||||
TuningStatus string `json:"tuning_status"`
|
TuningStatus string `json:"tuning_status"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
|
|
||||||
|
// Peak hold for display (internal)
|
||||||
|
displayPower float64
|
||||||
|
peakTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
<<<<<<< HEAD
|
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
=======
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +103,6 @@ func (c *Client) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Try to connect, but don't fail if it doesn't work
|
// Try to connect, but don't fail if it doesn't work
|
||||||
// The poll loop will keep trying
|
// The poll loop will keep trying
|
||||||
_ = c.Connect()
|
_ = c.Connect()
|
||||||
@@ -171,6 +163,39 @@ func (c *Client) pollLoop() {
|
|||||||
// Mark as connected
|
// Mark as connected
|
||||||
status.Connected = true
|
status.Connected = true
|
||||||
|
|
||||||
|
// Check if device is actually alive
|
||||||
|
// If all frequencies are 0, device is probably off
|
||||||
|
if status.FreqA == 0 && status.FreqB == 0 && status.PowerForward == 0 {
|
||||||
|
status.Connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak hold logic - keep highest power for 1 second
|
||||||
|
now := time.Now()
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
// If new power is higher, update peak
|
||||||
|
if status.PowerForward > c.lastStatus.displayPower {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
} else {
|
||||||
|
// Check if peak has expired (1 second)
|
||||||
|
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||||
|
// Keep old peak
|
||||||
|
status.displayPower = c.lastStatus.displayPower
|
||||||
|
status.peakTime = c.lastStatus.peakTime
|
||||||
|
} else {
|
||||||
|
// Peak expired, use current value
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status.displayPower = status.PowerForward
|
||||||
|
status.peakTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override PowerForward with display power for frontend
|
||||||
|
status.PowerForward = status.displayPower
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.lastStatus = status
|
c.lastStatus = status
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
@@ -221,8 +246,6 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
|||||||
return "", fmt.Errorf("not connected")
|
return "", fmt.Errorf("not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
// Get next command ID from global counter
|
// Get next command ID from global counter
|
||||||
cmdID := GetGlobalCommandID().GetNextID()
|
cmdID := GetGlobalCommandID().GetNextID()
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -142,12 +142,8 @@ func (c *Client) GetStatus() (*Status, error) {
|
|||||||
|
|
||||||
// Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
|
// Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
|
||||||
status := &Status{
|
status := &Status{
|
||||||
<<<<<<< HEAD
|
|
||||||
Relays: make([]RelayState, 0, 5),
|
Relays: make([]RelayState, 0, 5),
|
||||||
Connected: true,
|
Connected: true,
|
||||||
=======
|
|
||||||
Relays: make([]RelayState, 0, 5),
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<<<<<<< HEAD
|
|
||||||
<title>ShackMaster - F4BPO Shack</title>
|
<title>ShackMaster - F4BPO Shack</title>
|
||||||
=======
|
|
||||||
<title>ShackMaster - XV9Q Shack</title>
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -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,8 +102,8 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -106,17 +114,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ✅ NOUVEAU : Bandeau de statut avec fréquence FlexRadio et alertes météo -->
|
||||||
|
<StatusBanner
|
||||||
|
flexradio={status?.flexradio}
|
||||||
|
weather={status?.weather}
|
||||||
|
{latitude}
|
||||||
|
{longitude}
|
||||||
|
windWarningThreshold={30}
|
||||||
|
gustWarningThreshold={50}
|
||||||
|
/>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<WebSwitch status={status?.webswitch} />
|
<WebSwitch status={status?.webswitch} />
|
||||||
<PowerGenius status={status?.power_genius} />
|
<PowerGenius status={status?.power_genius} />
|
||||||
<TunerGenius status={status?.tuner_genius} />
|
<TunerGenius status={status?.tuner_genius} flexradio={status?.flexradio} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<AntennaGenius status={status?.antenna_genius} />
|
<AntennaGenius status={status?.antenna_genius} />
|
||||||
<RotatorGenius status={status?.rotator_genius} />
|
<Ultrabeam status={status?.ultrabeam} flexradio={status?.flexradio} />
|
||||||
|
<RotatorGenius status={status?.rotator_genius} ultrabeam={status?.ultrabeam} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -130,12 +149,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
padding: 16px 24px;
|
padding: 8px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
@@ -176,13 +196,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.solar-item {
|
.solar-item {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solar-item .value {
|
.solar-item .value {
|
||||||
color: var(--accent-teal);
|
font-weight: 700;
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solar-item:nth-child(1) .value { /* SFI */
|
||||||
|
color: #ffa726;
|
||||||
|
text-shadow: 0 0 8px rgba(255, 167, 38, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solar-item:nth-child(2) .value { /* Spots */
|
||||||
|
color: #66bb6a;
|
||||||
|
text-shadow: 0 0 8px rgba(102, 187, 106, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solar-item:nth-child(3) .value { /* A */
|
||||||
|
color: #42a5f5;
|
||||||
|
text-shadow: 0 0 8px rgba(66, 165, 245, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solar-item:nth-child(4) .value { /* K */
|
||||||
|
color: #ef5350;
|
||||||
|
text-shadow: 0 0 8px rgba(239, 83, 80, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solar-item:nth-child(5) .value { /* G */
|
||||||
|
color: #ab47bc;
|
||||||
|
text-shadow: 0 0 8px rgba(171, 71, 188, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
@@ -213,6 +261,7 @@
|
|||||||
.date {
|
.date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
padding-top: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
129
web/src/app.css
129
web/src/app.css
@@ -1,5 +1,4 @@
|
|||||||
:root {
|
:root {
|
||||||
<<<<<<< HEAD
|
|
||||||
/* Modern dark theme inspired by FlexDXCluster */
|
/* Modern dark theme inspired by FlexDXCluster */
|
||||||
--bg-primary: #0a1628;
|
--bg-primary: #0a1628;
|
||||||
--bg-secondary: #1a2332;
|
--bg-secondary: #1a2332;
|
||||||
@@ -436,132 +435,4 @@ select:focus {
|
|||||||
order: 3;
|
order: 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
--bg-primary: #1a1a1a;
|
|
||||||
--bg-secondary: #2a2a2a;
|
|
||||||
--bg-card: #333333;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #b0b0b0;
|
|
||||||
--accent-teal: #00bcd4;
|
|
||||||
--accent-green: #4caf50;
|
|
||||||
--accent-red: #f44336;
|
|
||||||
--accent-blue: #2196f3;
|
|
||||||
--border-color: #444444;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background-color: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select {
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
display: inline-block;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: var(--accent-green);
|
|
||||||
box-shadow: 0 0 8px var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #45a049;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--accent-red);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #da190b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-display {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
@@ -21,11 +20,37 @@
|
|||||||
|
|
||||||
async function selectAntenna(port, antennaNum) {
|
async function selectAntenna(port, antennaNum) {
|
||||||
try {
|
try {
|
||||||
await api.antenna.selectAntenna(port, antennaNum, antennaNum);
|
// Check if antenna is already selected on this port
|
||||||
} catch (err) {
|
const isAlreadySelected = (port === 1 && portA.rx_ant === antennaNum) ||
|
||||||
console.error('Failed to select antenna:', err);
|
(port === 2 && portB.rx_ant === antennaNum);
|
||||||
alert('Failed to select antenna');
|
|
||||||
|
if (isAlreadySelected) {
|
||||||
|
// Deselect: set rxant to 00
|
||||||
|
console.log(`Deselecting antenna ${antennaNum} from port ${port}`);
|
||||||
|
await api.antenna.deselectAntenna(port, antennaNum);
|
||||||
|
} else {
|
||||||
|
// Select normally
|
||||||
|
console.log(`Selecting antenna ${antennaNum} on port ${port}`);
|
||||||
|
await api.antenna.selectAntenna(port, antennaNum);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to 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() {
|
async function reboot() {
|
||||||
@@ -34,28 +59,14 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.antenna.reboot();
|
await api.antenna.reboot();
|
||||||
|
console.log('Antenna Genius reboot command sent');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to reboot:', err);
|
console.error('Failed to reboot:', err);
|
||||||
alert('Failed to reboot');
|
// No popup, just log
|
||||||
=======
|
|
||||||
|
|
||||||
export let status;
|
|
||||||
|
|
||||||
$: radio1Antenna = status?.radio1_antenna || 0;
|
|
||||||
$: radio2Antenna = status?.radio2_antenna || 0;
|
|
||||||
$: connected = status?.connected || false;
|
|
||||||
|
|
||||||
async function setRadioAntenna(radio, antenna) {
|
|
||||||
try {
|
|
||||||
await api.antenna.set(radio, antenna);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set antenna:', err);
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Antenna Genius</h2>
|
<h2>Antenna Genius</h2>
|
||||||
@@ -88,29 +99,30 @@
|
|||||||
{#each antennas as antenna}
|
{#each antennas as antenna}
|
||||||
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
||||||
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
||||||
{@const isPortARx = !portA.tx && (portA.rx_ant === antenna.number || portA.tx_ant === antenna.number)}
|
{@const isPortARx = !portA.tx && portA.rx_ant === antenna.number}
|
||||||
{@const isPortBRx = !portB.tx && (portB.rx_ant === antenna.number || portB.tx_ant === antenna.number)}
|
{@const isPortBRx = !portB.tx && portB.rx_ant === antenna.number}
|
||||||
{@const isTx = isPortATx || isPortBTx}
|
{@const isTx = isPortATx || isPortBTx}
|
||||||
{@const isActive = isPortARx || isPortBRx}
|
{@const isActiveA = isPortARx || isPortATx}
|
||||||
|
{@const isActiveB = isPortBRx || isPortBTx}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="antenna-card"
|
class="antenna-card"
|
||||||
class:tx={isTx}
|
class:tx={isTx}
|
||||||
class:active-a={isPortARx}
|
class:active-a={isActiveA}
|
||||||
class:active-b={isPortBRx}
|
class:active-b={isActiveB}
|
||||||
>
|
>
|
||||||
<div class="antenna-name">{antenna.name}</div>
|
<div class="antenna-name">{antenna.name}</div>
|
||||||
<div class="antenna-ports">
|
<div class="antenna-ports">
|
||||||
<button
|
<button
|
||||||
class="port-btn"
|
class="port-btn"
|
||||||
class:active={portA.tx_ant === antenna.number || portA.rx_ant === antenna.number}
|
class:active={isActiveA}
|
||||||
on:click={() => selectAntenna(1, antenna.number)}
|
on:click={() => selectAntenna(1, antenna.number)}
|
||||||
>
|
>
|
||||||
A
|
A
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="port-btn"
|
class="port-btn"
|
||||||
class:active={portB.tx_ant === antenna.number || portB.rx_ant === antenna.number}
|
class:active={isActiveB}
|
||||||
on:click={() => selectAntenna(2, antenna.number)}
|
on:click={() => selectAntenna(2, antenna.number)}
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
@@ -125,53 +137,10 @@
|
|||||||
<span class="reboot-icon">🔄</span>
|
<span class="reboot-icon">🔄</span>
|
||||||
REBOOT
|
REBOOT
|
||||||
</button>
|
</button>
|
||||||
=======
|
|
||||||
<div class="antenna-card card">
|
|
||||||
<h2>
|
|
||||||
AG 8X2
|
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="radio-section">
|
|
||||||
<div class="radio-label">Radio 1 / Radio 2</div>
|
|
||||||
|
|
||||||
<div class="radio-grid">
|
|
||||||
<div class="radio-column">
|
|
||||||
<div class="radio-title">Radio 1</div>
|
|
||||||
<div class="antenna-slots">
|
|
||||||
{#each Array(4) as _, i}
|
|
||||||
<button
|
|
||||||
class="slot"
|
|
||||||
class:active={radio1Antenna === i}
|
|
||||||
on:click={() => setRadioAntenna(1, i)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="radio-column">
|
|
||||||
<div class="radio-title">Radio 2</div>
|
|
||||||
<div class="antenna-slots">
|
|
||||||
{#each Array(4) as _, i}
|
|
||||||
<button
|
|
||||||
class="slot"
|
|
||||||
class:active={radio2Antenna === i}
|
|
||||||
on:click={() => setRadioAntenna(2, i)}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
<<<<<<< HEAD
|
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
border: 1px solid #2d3748;
|
border: 1px solid #2d3748;
|
||||||
@@ -278,12 +247,6 @@
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.antenna-card.tx {
|
|
||||||
background: rgba(244, 67, 54, 0.2);
|
|
||||||
border-color: #f44336;
|
|
||||||
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-card.active-a {
|
.antenna-card.active-a {
|
||||||
background: rgba(76, 175, 80, 0.2);
|
background: rgba(76, 175, 80, 0.2);
|
||||||
border-color: #4caf50;
|
border-color: #4caf50;
|
||||||
@@ -296,6 +259,13 @@
|
|||||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TX must come AFTER active-a/active-b to override */
|
||||||
|
.antenna-card.tx {
|
||||||
|
background: rgba(244, 67, 54, 0.2) !important;
|
||||||
|
border-color: #f44336 !important;
|
||||||
|
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.antenna-name {
|
.antenna-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -365,72 +335,5 @@
|
|||||||
|
|
||||||
.reboot-icon {
|
.reboot-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
=======
|
|
||||||
.antenna-card {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-column {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-slots {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot {
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot:hover {
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot.active {
|
|
||||||
background: var(--accent-blue);
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
color: white;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
<<<<<<< HEAD
|
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
@@ -7,6 +6,7 @@
|
|||||||
$: powerForward = status?.power_forward || 0;
|
$: powerForward = status?.power_forward || 0;
|
||||||
$: powerReflected = status?.power_reflected || 0;
|
$: powerReflected = status?.power_reflected || 0;
|
||||||
$: swr = status?.swr || 1.0;
|
$: swr = status?.swr || 1.0;
|
||||||
|
|
||||||
$: voltage = status?.voltage || 0;
|
$: voltage = status?.voltage || 0;
|
||||||
$: vdd = status?.vdd || 0;
|
$: vdd = status?.vdd || 0;
|
||||||
$: current = status?.current || 0;
|
$: current = status?.current || 0;
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
await api.power.setFanMode(mode);
|
await api.power.setFanMode(mode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set fan mode:', err);
|
console.error('Failed to set fan mode:', err);
|
||||||
alert('Failed to set fan mode');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
await api.power.setOperate(operateValue);
|
await api.power.setOperate(operateValue);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to toggle operate:', err);
|
console.error('Failed to toggle operate:', err);
|
||||||
alert('Failed to toggle operate mode');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -63,54 +63,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<!-- Power Display - Big and Bold -->
|
<!-- Power Display + SWR Side by Side -->
|
||||||
<div class="power-display">
|
<div class="power-swr-row">
|
||||||
<div class="power-main">
|
<div class="power-section">
|
||||||
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
|
<div class="power-header">
|
||||||
<div class="power-label">Forward Power</div>
|
<span class="power-label-inline">Power</span>
|
||||||
|
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="power-bar">
|
<div class="power-bar-container">
|
||||||
|
<div class="power-bar-bg">
|
||||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
<div class="power-bar-glow"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="power-scale">
|
|
||||||
<span>0</span>
|
|
||||||
<span>1000</span>
|
|
||||||
<span>2000</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SWR Circle Indicator -->
|
<!-- SWR Circle Compact -->
|
||||||
<div class="swr-container">
|
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||||
<div class="swr-circle" style="--swr-color: {swrColor}">
|
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||||
<div class="swr-value">{swr.toFixed(2)}</div>
|
<div class="swr-label-compact">SWR</div>
|
||||||
<div class="swr-label">SWR</div>
|
|
||||||
</div>
|
|
||||||
<div class="swr-status">
|
|
||||||
{#if swr < 1.5}
|
|
||||||
<span class="status-text good">Excellent</span>
|
|
||||||
{:else if swr < 2.0}
|
|
||||||
<span class="status-text ok">Good</span>
|
|
||||||
{:else if swr < 3.0}
|
|
||||||
<span class="status-text warning">Caution</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status-text danger">High!</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temperature Gauges -->
|
<!-- Temperature Gauges -->
|
||||||
<div class="temp-group">
|
<div class="temp-group">
|
||||||
<div class="temp-item">
|
<div class="temp-item">
|
||||||
<div class="temp-value" style="color: {tempColor}">{temperature.toFixed(0)}°</div>
|
<div class="temp-value" style="color: {tempColor}">{temperature.toFixed(1)}°</div>
|
||||||
<div class="temp-label">PA Temp</div>
|
<div class="temp-label">PA Temp</div>
|
||||||
<div class="temp-mini-bar">
|
<div class="temp-mini-bar">
|
||||||
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="temp-item">
|
<div class="temp-item">
|
||||||
<div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(0)}°</div>
|
<div class="temp-value" style="color: {tempColor}">{harmonicLoadTemp.toFixed(1)}°</div>
|
||||||
<div class="temp-label">HL Temp</div>
|
<div class="temp-label">HL Temp</div>
|
||||||
<div class="temp-mini-bar">
|
<div class="temp-mini-bar">
|
||||||
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
||||||
@@ -148,8 +133,8 @@
|
|||||||
|
|
||||||
<!-- Fan Control -->
|
<!-- Fan Control -->
|
||||||
<div class="fan-control">
|
<div class="fan-control">
|
||||||
<label class="control-label">Fan Mode</label>
|
<label for="fan-mode-select" class="control-label">Fan Mode</label>
|
||||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||||
<option value="STANDARD">Standard</option>
|
<option value="STANDARD">Standard</option>
|
||||||
<option value="CONTEST">Contest</option>
|
<option value="CONTEST">Contest</option>
|
||||||
<option value="BROADCAST">Broadcast</option>
|
<option value="BROADCAST">Broadcast</option>
|
||||||
@@ -234,280 +219,100 @@
|
|||||||
|
|
||||||
.metrics {
|
.metrics {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
=======
|
|
||||||
export let status;
|
|
||||||
|
|
||||||
$: powerForward = status?.power_forward || 0;
|
|
||||||
$: powerReflected = status?.power_reflected || 0;
|
|
||||||
$: swr = status?.swr || 1.0;
|
|
||||||
$: voltage = status?.voltage || 0;
|
|
||||||
$: vdd = status?.vdd || 0;
|
|
||||||
$: current = status?.current || 0;
|
|
||||||
$: peakCurrent = status?.peak_current || 0;
|
|
||||||
$: temperature = status?.temperature || 0;
|
|
||||||
$: harmonicLoadTemp = status?.harmonic_load_temp || 0;
|
|
||||||
$: fanMode = status?.fan_mode || 'CONTEST';
|
|
||||||
$: state = status?.state || 'IDLE';
|
|
||||||
$: bandA = status?.band_a || '0';
|
|
||||||
$: bandB = status?.band_b || '0';
|
|
||||||
$: connected = status?.connected || false;
|
|
||||||
$: displayState = state.replace('TRANSMIT_A', 'TRANSMIT').replace('TRANSMIT_B', 'TRANSMIT');
|
|
||||||
$: meffa = status?.meffa || 'STANDBY';
|
|
||||||
|
|
||||||
async function setFanMode(mode) {
|
|
||||||
try {
|
|
||||||
await api.power.setFanMode(mode);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set fan mode:', err);
|
|
||||||
alert('Failed to set fan mode');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="powergenius-card card">
|
|
||||||
<h2>
|
|
||||||
PGXL
|
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="status-label" class:normal={state === 'IDLE'} class:warning={state.includes('TRANSMIT')}>
|
|
||||||
{displayState}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metrics">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">FWD PWR (W)</div>
|
|
||||||
<div class="value">{powerForward.toFixed(1)}</div>
|
|
||||||
<div class="bar">
|
|
||||||
<div class="bar-fill" style="width: {Math.min(100, (powerForward / 1500) * 100)}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="scale">
|
|
||||||
<span>0</span>
|
|
||||||
<span>1000</span>
|
|
||||||
<span>2000</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">PG XL SWR 1:1.00 use</div>
|
|
||||||
<div class="value">{swr.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Temp / HL Temp</div>
|
|
||||||
<div class="value">{temperature.toFixed(0)}°C / {harmonicLoadTemp.toFixed(1)}°C</div>
|
|
||||||
<div class="bar">
|
|
||||||
<div class="bar-fill" style="width: {(temperature / 80) * 100}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="scale">
|
|
||||||
<span>25</span>
|
|
||||||
<span>55</span>
|
|
||||||
<span>80</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="metric-row">
|
|
||||||
<div class="metric small">
|
|
||||||
<div class="label">VAC</div>
|
|
||||||
<div class="value">{voltage.toFixed(0)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric small">
|
|
||||||
<div class="label">VDD</div>
|
|
||||||
<div class="value">{vdd.toFixed(1)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric small">
|
|
||||||
<div class="label">ID peak</div>
|
|
||||||
<div class="value">{peakCurrent.toFixed(1)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fan-speed">
|
|
||||||
<div class="label">Fan Speed</div>
|
|
||||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
|
||||||
<option value="STANDARD">STANDARD</option>
|
|
||||||
<option value="CONTEST">CONTEST</option>
|
|
||||||
<option value="BROADCAST">BROADCAST</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="band-info">
|
|
||||||
<div class="label">Band A</div>
|
|
||||||
<div class="value">{bandA}</div>
|
|
||||||
</div>
|
|
||||||
<div class="band-info">
|
|
||||||
<div class="label">Band B</div>
|
|
||||||
<div class="value">{bandB}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.powergenius-card {
|
|
||||||
min-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label.normal {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label.warning {
|
|
||||||
background: var(--accent-red);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
/* Power Display */
|
/* Power Display */
|
||||||
.power-display {
|
/* Power + SWR Row */
|
||||||
|
.power-swr-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-main {
|
.power-section {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value {
|
.power-header {
|
||||||
font-size: 48px;
|
display: flex;
|
||||||
font-weight: 200;
|
justify-content: space-between;
|
||||||
color: var(--accent-cyan);
|
align-items: center;
|
||||||
line-height: 1;
|
margin-bottom: 10px;
|
||||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value .unit {
|
.power-label-inline {
|
||||||
font-size: 24px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar {
|
.power-value-inline {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 8px;
|
}
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
.power-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-fill {
|
.power-bar-fill {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||||
border-radius: 4px;
|
border-radius: 14px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-glow {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-scale {
|
.swr-circle-compact {
|
||||||
display: flex;
|
width: 90px;
|
||||||
justify-content: space-between;
|
height: 90px;
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SWR Circle */
|
|
||||||
.swr-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swr-circle {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||||
border: 3px solid var(--swr-color);
|
border: 4px solid var(--swr-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 0 20px var(--swr-color);
|
box-shadow: 0 0 25px var(--swr-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-value {
|
.swr-value-compact {
|
||||||
font-size: 24px;
|
font-size: 28px;
|
||||||
font-weight: 300;
|
font-weight: 700;
|
||||||
color: var(--swr-color);
|
color: var(--swr-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-label {
|
.swr-label-compact {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-status {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text.good { color: #4caf50; }
|
|
||||||
.status-text.ok { color: #ffc107; }
|
|
||||||
.status-text.warning { color: #ff9800; }
|
|
||||||
.status-text.danger { color: #f44336; }
|
|
||||||
|
|
||||||
/* Temperature */
|
/* Temperature */
|
||||||
.temp-group {
|
.temp-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -572,7 +377,7 @@ async function setFanMode(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.param-value {
|
.param-value {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -589,65 +394,10 @@ async function setFanMode(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.band-item {
|
.band-item {
|
||||||
=======
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric.small {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
background: #555;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-red));
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scale {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fan-speed select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.band-info {
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
.band-label {
|
.band-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -694,6 +444,4 @@ async function setFanMode(mode) {
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
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>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@
|
|||||||
await api.tuner.autoTune();
|
await api.tuner.autoTune();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to tune:', err);
|
console.error('Failed to tune:', err);
|
||||||
alert('Failed to start tuning');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
await api.tuner.setBypass(value);
|
await api.tuner.setBypass(value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set bypass:', err);
|
console.error('Failed to set bypass:', err);
|
||||||
alert('Failed to set bypass');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,54 +42,11 @@
|
|||||||
await api.tuner.setOperate(value);
|
await api.tuner.setOperate(value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set operate:', err);
|
console.error('Failed to set operate:', err);
|
||||||
alert('Failed to set operate');
|
// Removed alert popup - check console for errors
|
||||||
=======
|
|
||||||
|
|
||||||
export let status;
|
|
||||||
|
|
||||||
$: operate = status?.operate || false;
|
|
||||||
$: activeAntenna = status?.active_antenna || 0;
|
|
||||||
$: tuningStatus = status?.tuning_status || 'READY';
|
|
||||||
$: frequencyA = status?.frequency_a || 0;
|
|
||||||
$: frequencyB = status?.frequency_b || 0;
|
|
||||||
$: c1 = status?.c1 || 0;
|
|
||||||
$: l = status?.l || 0;
|
|
||||||
$: c2 = status?.c2 || 0;
|
|
||||||
$: connected = status?.connected || false;
|
|
||||||
|
|
||||||
let tuning = false;
|
|
||||||
|
|
||||||
async function toggleOperate() {
|
|
||||||
try {
|
|
||||||
await api.tuner.operate(!operate);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle operate:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTune() {
|
|
||||||
tuning = true;
|
|
||||||
try {
|
|
||||||
await api.tuner.tune();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to tune:', err);
|
|
||||||
alert('Tuning failed');
|
|
||||||
} finally {
|
|
||||||
tuning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAntenna(ant) {
|
|
||||||
try {
|
|
||||||
await api.tuner.antenna(ant);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to set antenna:', err);
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Tuner Genius XL</h2>
|
<h2>Tuner Genius XL</h2>
|
||||||
@@ -101,40 +57,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<!-- Power Display -->
|
<!-- Power Display + SWR Side by Side -->
|
||||||
<div class="power-display">
|
<div class="power-swr-row">
|
||||||
<div class="power-main">
|
<div class="power-section">
|
||||||
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
|
<div class="power-header">
|
||||||
<div class="power-label">Forward Power</div>
|
<span class="power-label-inline">Power</span>
|
||||||
|
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="power-bar">
|
<div class="power-bar-container">
|
||||||
|
<div class="power-bar-bg">
|
||||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
<div class="power-bar-glow"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="power-scale">
|
|
||||||
<span>0</span>
|
|
||||||
<span>1000</span>
|
|
||||||
<span>2000</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SWR Circle -->
|
<!-- SWR Circle Compact -->
|
||||||
<div class="swr-container">
|
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||||
<div class="swr-circle" style="--swr-color: {swrColor}">
|
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||||
<div class="swr-value">{swr.toFixed(2)}</div>
|
<div class="swr-label-compact">SWR</div>
|
||||||
<div class="swr-label">SWR</div>
|
|
||||||
</div>
|
|
||||||
<div class="swr-status">
|
|
||||||
{#if swr < 1.5}
|
|
||||||
<span class="status-text good">Excellent</span>
|
|
||||||
{:else if swr < 2.0}
|
|
||||||
<span class="status-text ok">Good</span>
|
|
||||||
{:else if swr < 3.0}
|
|
||||||
<span class="status-text warning">Caution</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status-text danger">High!</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -263,128 +204,106 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Power Display */
|
/* Power Display */
|
||||||
.power-display {
|
/* Power + SWR Row */
|
||||||
|
.power-swr-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-main {
|
.power-section {
|
||||||
text-align: center;
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value {
|
.power-header {
|
||||||
font-size: 48px;
|
display: flex;
|
||||||
font-weight: 200;
|
justify-content: space-between;
|
||||||
color: var(--accent-cyan);
|
align-items: center;
|
||||||
line-height: 1;
|
margin-bottom: 10px;
|
||||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-value .unit {
|
.power-label-inline {
|
||||||
font-size: 24px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.5px;
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar {
|
.power-value-inline {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.power-bar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 8px;
|
}
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
.power-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-fill {
|
.power-bar-fill {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||||
border-radius: 4px;
|
border-radius: 14px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar-glow {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-scale {
|
.swr-circle-compact {
|
||||||
display: flex;
|
width: 90px;
|
||||||
justify-content: space-between;
|
height: 90px;
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SWR Circle */
|
|
||||||
.swr-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swr-circle {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||||
border: 3px solid var(--swr-color);
|
border: 4px solid var(--swr-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 0 20px var(--swr-color);
|
box-shadow: 0 0 25px var(--swr-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-value {
|
.swr-value-compact {
|
||||||
font-size: 24px;
|
font-size: 28px;
|
||||||
font-weight: 300;
|
font-weight: 700;
|
||||||
color: var(--swr-color);
|
color: var(--swr-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-label {
|
.swr-label-compact {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swr-status {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
/* SWR Circle */
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text.good { color: #4caf50; }
|
|
||||||
.status-text.ok { color: #ffc107; }
|
|
||||||
.status-text.warning { color: #ff9800; }
|
|
||||||
.status-text.danger { color: #f44336; }
|
|
||||||
|
|
||||||
/* Capacitors */
|
/* Capacitors */
|
||||||
.capacitors {
|
.capacitors {
|
||||||
@@ -405,7 +324,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cap-value {
|
.cap-value {
|
||||||
font-size: 32px;
|
font-size: 20px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||||
@@ -484,218 +403,10 @@
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
color: #000;
|
color: #000;
|
||||||
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||||
=======
|
|
||||||
<div class="tuner-card card">
|
|
||||||
<h2>
|
|
||||||
TGXL
|
|
||||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="power-status">
|
|
||||||
<div class="label">Power 0.0w</div>
|
|
||||||
<div class="status-badge">1500</div>
|
|
||||||
<div class="status-badge">1650</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tuning-controls">
|
|
||||||
<div class="tuning-row">
|
|
||||||
<div class="tuning-label">TG XL SWR 1.00 use</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="antenna-buttons">
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 0}
|
|
||||||
on:click={() => setAntenna(0)}
|
|
||||||
>
|
|
||||||
C1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 1}
|
|
||||||
on:click={() => setAntenna(1)}
|
|
||||||
>
|
|
||||||
L
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="antenna-btn"
|
|
||||||
class:active={activeAntenna === 2}
|
|
||||||
on:click={() => setAntenna(2)}
|
|
||||||
>
|
|
||||||
C2
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tuning-values">
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{c1}</div>
|
|
||||||
<div class="label">C1</div>
|
|
||||||
</div>
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{l}</div>
|
|
||||||
<div class="label">L</div>
|
|
||||||
</div>
|
|
||||||
<div class="value-box">
|
|
||||||
<div class="value">{c2}</div>
|
|
||||||
<div class="label">C2</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-row">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Tuning Status</div>
|
|
||||||
<div class="status-badge" class:tuning={tuningStatus === 'TUNING'}>
|
|
||||||
{tuningStatus}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="frequency-row">
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Frequency A</div>
|
|
||||||
<div class="value-display">{(frequencyA / 1000).toFixed(3)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<div class="label">Frequency B</div>
|
|
||||||
<div class="value-display">{(frequencyB / 1000).toFixed(3)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
class:btn-primary={!operate}
|
|
||||||
class:btn-danger={operate}
|
|
||||||
on:click={toggleOperate}
|
|
||||||
>
|
|
||||||
{operate ? 'STANDBY' : 'OPERATE'}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" disabled>BYPASS</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-danger tune-btn"
|
|
||||||
disabled={tuning || !operate}
|
|
||||||
on:click={startTune}
|
|
||||||
>
|
|
||||||
{tuning ? 'TUNING...' : 'TUNE'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tuner-card {
|
|
||||||
min-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.power-status {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.tuning {
|
|
||||||
background: var(--accent-green);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-controls {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-label {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.antenna-btn.active {
|
|
||||||
background: var(--accent-blue);
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuning-values {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-box {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-box .value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.frequency-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons button {
|
|
||||||
flex: 1;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tune-btn {
|
.tune-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
<<<<<<< HEAD
|
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -724,8 +435,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tune-icon {
|
.tune-icon {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
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,10 +4,7 @@
|
|||||||
export let status;
|
export let status;
|
||||||
|
|
||||||
$: relays = status?.relays || [];
|
$: relays = status?.relays || [];
|
||||||
<<<<<<< HEAD
|
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
|
||||||
const relayNames = {
|
const relayNames = {
|
||||||
1: 'Power Supply',
|
1: 'Power Supply',
|
||||||
@@ -55,7 +52,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>WebSwitch</h2>
|
<h2>WebSwitch</h2>
|
||||||
@@ -99,40 +95,10 @@
|
|||||||
ALL OFF
|
ALL OFF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
=======
|
|
||||||
<div class="webswitch-card card">
|
|
||||||
<h2>
|
|
||||||
1216RH
|
|
||||||
<span class="status-indicator" class:status-online={relays.length > 0} class:status-offline={relays.length === 0}></span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="relays">
|
|
||||||
{#each [1, 2, 3, 4, 5] as relayNum}
|
|
||||||
{@const relay = relays.find(r => r.number === relayNum)}
|
|
||||||
{@const isOn = relay?.state || false}
|
|
||||||
<div class="relay-row">
|
|
||||||
<span class="relay-name">{relayNames[relayNum]}</span>
|
|
||||||
<button
|
|
||||||
class="relay-toggle"
|
|
||||||
class:active={isOn}
|
|
||||||
disabled={loading[relayNum]}
|
|
||||||
on:click={() => toggleRelay(relayNum)}
|
|
||||||
>
|
|
||||||
<div class="toggle-icon"></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button class="btn btn-primary" on:click={allOn}>ALL ON</button>
|
|
||||||
<button class="btn btn-danger" on:click={allOff}>ALL OFF</button>
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
<<<<<<< HEAD
|
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||||
border: 1px solid #2d3748;
|
border: 1px solid #2d3748;
|
||||||
@@ -335,73 +301,5 @@
|
|||||||
|
|
||||||
.all-off:hover {
|
.all-off:hover {
|
||||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||||
=======
|
|
||||||
.webswitch-card {
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relays {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-name {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-toggle {
|
|
||||||
width: 60px;
|
|
||||||
height: 32px;
|
|
||||||
background: #555;
|
|
||||||
border-radius: 16px;
|
|
||||||
position: relative;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-toggle.active {
|
|
||||||
background: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
left: 4px;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relay-toggle.active .toggle-icon {
|
|
||||||
transform: translateX(28px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button {
|
|
||||||
flex: 1;
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -47,7 +47,6 @@ export const api = {
|
|||||||
|
|
||||||
// Tuner
|
// Tuner
|
||||||
tuner: {
|
tuner: {
|
||||||
<<<<<<< HEAD
|
|
||||||
setOperate: (value) => request('/tuner/operate', {
|
setOperate: (value) => request('/tuner/operate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
@@ -57,33 +56,19 @@ export const api = {
|
|||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
}),
|
}),
|
||||||
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
||||||
=======
|
|
||||||
operate: (operate) => request('/tuner/operate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ operate }),
|
|
||||||
}),
|
|
||||||
tune: () => request('/tuner/tune', { method: 'POST' }),
|
|
||||||
antenna: (antenna) => request('/tuner/antenna', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ antenna }),
|
|
||||||
}),
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Antenna Genius
|
// Antenna Genius
|
||||||
antenna: {
|
antenna: {
|
||||||
<<<<<<< HEAD
|
|
||||||
selectAntenna: (port, antenna) => request('/antenna/select', {
|
selectAntenna: (port, antenna) => request('/antenna/select', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ port, antenna }),
|
body: JSON.stringify({ port, antenna }),
|
||||||
}),
|
}),
|
||||||
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
deselectAntenna: (port, antenna) => request('/antenna/deselect', {
|
||||||
=======
|
|
||||||
set: (radio, antenna) => request('/antenna/set', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ radio, antenna }),
|
body: JSON.stringify({ port, antenna }),
|
||||||
}),
|
}),
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Power Genius
|
// Power Genius
|
||||||
@@ -92,7 +77,6 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mode }),
|
body: JSON.stringify({ mode }),
|
||||||
}),
|
}),
|
||||||
<<<<<<< HEAD
|
|
||||||
setOperate: (value) => request('/power/operate', {
|
setOperate: (value) => request('/power/operate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ value }),
|
body: JSON.stringify({ value }),
|
||||||
@@ -108,7 +92,22 @@ export const api = {
|
|||||||
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
||||||
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||||
stop: () => request('/rotator/stop', { method: 'POST' }),
|
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||||
=======
|
},
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
|
// Ultrabeam
|
||||||
|
ultrabeam: {
|
||||||
|
setFrequency: (frequency, direction) => request('/ultrabeam/frequency', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ frequency, direction }),
|
||||||
|
}),
|
||||||
|
retract: () => request('/ultrabeam/retract', { method: 'POST' }),
|
||||||
|
setAutoTrack: (enabled, threshold) => request('/ultrabeam/autotrack', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ enabled, threshold }),
|
||||||
|
}),
|
||||||
|
setDirection: (direction) => request('/ultrabeam/direction', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ direction }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -28,10 +28,7 @@ class WebSocketService {
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
if (message.type === 'update') {
|
if (message.type === 'update') {
|
||||||
<<<<<<< HEAD
|
|
||||||
console.log('System status updated:', message.data);
|
console.log('System status updated:', message.data);
|
||||||
=======
|
|
||||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
|
||||||
systemStatus.set(message.data);
|
systemStatus.set(message.data);
|
||||||
lastUpdate.set(new Date(message.timestamp));
|
lastUpdate.set(new Date(message.timestamp));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user