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
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -13,6 +15,9 @@ import (
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
log.Println("Starting ShackMaster server...")
|
||||
|
||||
@@ -39,10 +44,17 @@ func main() {
|
||||
log.Fatalf("Failed to start device manager: %v", err)
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
// Create HTTP server with embedded files
|
||||
server := api.NewServer(deviceManager, hub, cfg)
|
||||
mux := server.SetupRoutes()
|
||||
|
||||
// Serve embedded static files
|
||||
distFS, err := fs.Sub(webFS, "web/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to access embedded files: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(distFS)))
|
||||
|
||||
// Setup HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
httpServer := &http.Server{
|
||||
|
||||
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:
|
||||
webswitch:
|
||||
host: "10.10.10.119"
|
||||
host: "10.10.10.100"
|
||||
|
||||
power_genius:
|
||||
host: "10.10.10.128"
|
||||
port: 9008
|
||||
host: "10.10.10.110"
|
||||
port: 4001
|
||||
|
||||
tuner_genius:
|
||||
host: "10.10.10.129"
|
||||
port: 9010
|
||||
host: "10.10.10.111"
|
||||
port: 4001
|
||||
|
||||
antenna_genius:
|
||||
host: "10.10.10.130"
|
||||
port: 9007
|
||||
host: "10.10.10.112"
|
||||
port: 4001
|
||||
|
||||
rotator_genius:
|
||||
host: "10.10.10.121"
|
||||
port: 9006
|
||||
host: "10.10.10.113"
|
||||
port: 4533
|
||||
|
||||
ultrabeam:
|
||||
host: "10.10.10.124"
|
||||
port: 4210
|
||||
|
||||
flexradio:
|
||||
enabled: true
|
||||
host: "10.10.10.120"
|
||||
port: 4992
|
||||
interlock_name: "Ultrabeam"
|
||||
|
||||
weather:
|
||||
openweathermap_api_key: "YOUR_API_KEY_HERE"
|
||||
lightning_enabled: true
|
||||
openweathermap_api_key: ""
|
||||
lightning_enabled: false
|
||||
|
||||
location:
|
||||
latitude: 46.2833
|
||||
longitude: 6.2333
|
||||
latitude: 46.2814
|
||||
longitude: 6.2389
|
||||
callsign: "F4BPO"
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/flexradio"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/ultrabeam"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/solar"
|
||||
"git.rouggy.com/rouggy/ShackMaster/internal/services/weather"
|
||||
@@ -23,6 +25,8 @@ type DeviceManager struct {
|
||||
tunerGenius *tunergenius.Client
|
||||
antennaGenius *antennagenius.Client
|
||||
rotatorGenius *rotatorgenius.Client
|
||||
ultrabeam *ultrabeam.Client
|
||||
flexRadio *flexradio.Client
|
||||
solarClient *solar.Client
|
||||
weatherClient *weather.Client
|
||||
|
||||
@@ -32,6 +36,15 @@ type DeviceManager struct {
|
||||
|
||||
updateInterval time.Duration
|
||||
stopChan chan struct{}
|
||||
|
||||
// Auto frequency tracking
|
||||
freqThreshold int // Threshold for triggering update (Hz)
|
||||
autoTrackEnabled bool
|
||||
ultrabeamDirection int // User-selected direction (0=normal, 1=180, 2=bi-dir)
|
||||
ultrabeamDirectionSet bool // True if user has explicitly set a direction
|
||||
lastFreqUpdateTime time.Time // Last time we sent frequency update
|
||||
freqUpdateCooldown time.Duration // Minimum time between updates
|
||||
|
||||
}
|
||||
|
||||
type SystemStatus struct {
|
||||
@@ -40,6 +53,8 @@ type SystemStatus struct {
|
||||
TunerGenius *tunergenius.Status `json:"tuner_genius"`
|
||||
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
||||
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
||||
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
|
||||
FlexRadio *flexradio.Status `json:"flexradio,omitempty"`
|
||||
Solar *solar.SolarData `json:"solar"`
|
||||
Weather *weather.WeatherData `json:"weather"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -49,8 +64,12 @@ func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
|
||||
return &DeviceManager{
|
||||
config: cfg,
|
||||
hub: hub,
|
||||
updateInterval: 1 * time.Second, // Update status every second
|
||||
updateInterval: 200 * time.Millisecond, // Update status every second
|
||||
stopChan: make(chan struct{}),
|
||||
freqThreshold: 25000, // 25 kHz default
|
||||
autoTrackEnabled: true, // Enabled by default
|
||||
ultrabeamDirection: 0, // Normal direction by default
|
||||
freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,27 +86,70 @@ func (dm *DeviceManager) Initialize() error {
|
||||
)
|
||||
|
||||
// Initialize Tuner Genius
|
||||
log.Printf("Initializing TunerGenius: host=%s port=%d", dm.config.Devices.TunerGenius.Host, dm.config.Devices.TunerGenius.Port)
|
||||
dm.tunerGenius = tunergenius.New(
|
||||
dm.config.Devices.TunerGenius.Host,
|
||||
dm.config.Devices.TunerGenius.Port,
|
||||
)
|
||||
|
||||
// Initialize Antenna Genius
|
||||
log.Printf("Initializing AntennaGenius: host=%s port=%d", dm.config.Devices.AntennaGenius.Host, dm.config.Devices.AntennaGenius.Port)
|
||||
dm.antennaGenius = antennagenius.New(
|
||||
dm.config.Devices.AntennaGenius.Host,
|
||||
dm.config.Devices.AntennaGenius.Port,
|
||||
)
|
||||
|
||||
// Initialize Rotator Genius
|
||||
<<<<<<< HEAD
|
||||
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
dm.rotatorGenius = rotatorgenius.New(
|
||||
dm.config.Devices.RotatorGenius.Host,
|
||||
dm.config.Devices.RotatorGenius.Port,
|
||||
)
|
||||
|
||||
// Initialize Ultrabeam
|
||||
log.Printf("Initializing Ultrabeam: host=%s port=%d", dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port)
|
||||
dm.ultrabeam = ultrabeam.New(
|
||||
dm.config.Devices.Ultrabeam.Host,
|
||||
dm.config.Devices.Ultrabeam.Port,
|
||||
)
|
||||
|
||||
// Initialize FlexRadio if enabled
|
||||
if dm.config.Devices.FlexRadio.Enabled {
|
||||
log.Printf("Initializing FlexRadio: host=%s port=%d", dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port)
|
||||
dm.flexRadio = flexradio.New(
|
||||
dm.config.Devices.FlexRadio.Host,
|
||||
dm.config.Devices.FlexRadio.Port,
|
||||
)
|
||||
|
||||
dm.flexRadio.SetReconnectInterval(3 * time.Second) // Retry every 3 seconds
|
||||
dm.flexRadio.SetMaxReconnectDelay(30 * time.Second) // Max 30 second delay
|
||||
|
||||
// Set callback for immediate frequency changes (no waiting for update cycle)
|
||||
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
|
||||
dm.handleFrequencyChange(freqMHz)
|
||||
})
|
||||
|
||||
// Set callback to check if transmit is allowed (based on Ultrabeam motors)
|
||||
dm.flexRadio.SetTransmitCheckCallback(func() bool {
|
||||
// Get current Ultrabeam status
|
||||
ubStatus, err := dm.ultrabeam.GetStatus()
|
||||
if err != nil || ubStatus == nil {
|
||||
// If we cannot get status, allow transmit (fail-safe)
|
||||
return true
|
||||
}
|
||||
|
||||
// Block transmit if motors are moving
|
||||
motorsMoving := ubStatus.MotorsMoving != 0
|
||||
if motorsMoving {
|
||||
log.Printf("FlexRadio PTT check: Motors moving (bitmask=%d) - BLOCKING", ubStatus.MotorsMoving)
|
||||
} else {
|
||||
log.Printf("FlexRadio PTT check: Motors stopped - ALLOWING")
|
||||
}
|
||||
|
||||
return !motorsMoving
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Solar data client
|
||||
dm.solarClient = solar.New()
|
||||
|
||||
@@ -98,7 +160,6 @@ func (dm *DeviceManager) Initialize() error {
|
||||
dm.config.Location.Longitude,
|
||||
)
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Start device polling in background (non-blocking)
|
||||
go func() {
|
||||
if err := dm.powerGenius.Start(); err != nil {
|
||||
@@ -126,12 +187,26 @@ func (dm *DeviceManager) Initialize() error {
|
||||
}
|
||||
}()
|
||||
log.Println("RotatorGenius goroutine launched")
|
||||
=======
|
||||
// Start PowerGenius continuous polling
|
||||
if err := dm.powerGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
||||
|
||||
log.Println("About to launch Ultrabeam goroutine...")
|
||||
go func() {
|
||||
log.Println("Starting Ultrabeam polling goroutine...")
|
||||
if err := dm.ultrabeam.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start Ultrabeam polling: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("Ultrabeam goroutine launched")
|
||||
|
||||
// Start FlexRadio if enabled
|
||||
if dm.flexRadio != nil {
|
||||
log.Println("Starting FlexRadio connection...")
|
||||
go func() {
|
||||
if err := dm.flexRadio.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start FlexRadio: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("FlexRadio goroutine launched")
|
||||
}
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
|
||||
log.Println("Device manager initialized")
|
||||
return nil
|
||||
@@ -143,6 +218,74 @@ func (dm *DeviceManager) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleFrequencyChange is called immediately when FlexRadio frequency changes
|
||||
// This provides instant auto-track response instead of waiting for updateStatus cycle
|
||||
func (dm *DeviceManager) handleFrequencyChange(freqMHz float64) {
|
||||
// Check if ultrabeam is initialized
|
||||
// Check if auto-track is enabled
|
||||
if !dm.autoTrackEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
if dm.ultrabeam == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check cooldown first
|
||||
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
||||
return
|
||||
}
|
||||
|
||||
// Use cached status instead of calling GetStatus (which can block)
|
||||
dm.statusMu.RLock()
|
||||
hasStatus := dm.lastStatus != nil
|
||||
var ubStatus *ultrabeam.Status
|
||||
if hasStatus {
|
||||
ubStatus = dm.lastStatus.Ultrabeam
|
||||
}
|
||||
dm.statusMu.RUnlock()
|
||||
|
||||
if ubStatus == nil || !ubStatus.Connected {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't update if motors are already moving
|
||||
if ubStatus.MotorsMoving != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
freqKhz := int(freqMHz * 1000)
|
||||
ultrabeamFreqKhz := ubStatus.Frequency
|
||||
|
||||
// Only track if in Ultrabeam range (7-54 MHz)
|
||||
if freqKhz < 7000 || freqKhz > 54000 {
|
||||
return
|
||||
}
|
||||
|
||||
freqDiff := freqKhz - ultrabeamFreqKhz
|
||||
if freqDiff < 0 {
|
||||
freqDiff = -freqDiff
|
||||
}
|
||||
|
||||
freqDiffHz := freqDiff * 1000
|
||||
|
||||
if freqDiffHz >= dm.freqThreshold {
|
||||
directionToUse := dm.ultrabeamDirection
|
||||
if !dm.ultrabeamDirectionSet && ubStatus.Direction != 0 {
|
||||
directionToUse = ubStatus.Direction
|
||||
}
|
||||
|
||||
log.Printf("Auto-track (immediate): Updating to %d kHz (diff=%d kHz)", freqKhz, freqDiff)
|
||||
|
||||
if err := dm.ultrabeam.SetFrequency(freqKhz, directionToUse); err != nil {
|
||||
log.Printf("Auto-track (immediate): Failed: %v", err)
|
||||
} else {
|
||||
dm.lastFreqUpdateTime = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) Stop() {
|
||||
log.Println("Stopping device manager...")
|
||||
close(dm.stopChan)
|
||||
@@ -160,6 +303,9 @@ func (dm *DeviceManager) Stop() {
|
||||
if dm.rotatorGenius != nil {
|
||||
dm.rotatorGenius.Close()
|
||||
}
|
||||
if dm.ultrabeam != nil {
|
||||
dm.ultrabeam.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) monitorDevices() {
|
||||
@@ -196,7 +342,6 @@ func (dm *DeviceManager) updateStatus() {
|
||||
log.Printf("Power Genius error: %v", err)
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Tuner Genius
|
||||
if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil {
|
||||
status.TunerGenius = tgStatus
|
||||
@@ -217,28 +362,84 @@ func (dm *DeviceManager) updateStatus() {
|
||||
} else {
|
||||
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
|
||||
// if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
||||
// status.AntennaGenius = agStatus
|
||||
// } else {
|
||||
// log.Printf("Antenna Genius error: %v", err)
|
||||
// }
|
||||
// Ultrabeam
|
||||
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||
status.Ultrabeam = ubStatus
|
||||
|
||||
// // Rotator Genius
|
||||
// if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
||||
// status.RotatorGenius = rgStatus
|
||||
// } else {
|
||||
// log.Printf("Rotator Genius error: %v", err)
|
||||
// }
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
// Sync direction with Ultrabeam if user hasn't explicitly set one
|
||||
// This prevents auto-track from using wrong direction before user changes it
|
||||
if !dm.ultrabeamDirectionSet {
|
||||
dm.ultrabeamDirection = ubStatus.Direction
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Printf("Ultrabeam error: %v", err)
|
||||
}
|
||||
|
||||
// FlexRadio (use direct cache access to avoid mutex contention)
|
||||
if dm.flexRadio != nil {
|
||||
// Access lastStatus directly from FlexRadio's internal cache
|
||||
// The messageLoop updates this in real-time, no need to block on GetStatus
|
||||
frStatus, err := dm.flexRadio.GetStatus()
|
||||
if err == nil && frStatus != nil {
|
||||
status.FlexRadio = frStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Auto frequency tracking: Update Ultrabeam when radio frequency differs from Ultrabeam
|
||||
if dm.autoTrackEnabled {
|
||||
// TunerGenius tracking (FlexRadio uses immediate callback)
|
||||
var radioFreqKhz int
|
||||
var radioSource string
|
||||
|
||||
if status.TunerGenius != nil && status.TunerGenius.Connected {
|
||||
// Fallback to TunerGenius frequency (already in kHz)
|
||||
radioFreqKhz = int(status.TunerGenius.FreqA)
|
||||
radioSource = "TunerGenius"
|
||||
}
|
||||
|
||||
if radioFreqKhz > 0 && status.Ultrabeam != nil && status.Ultrabeam.Connected {
|
||||
ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz
|
||||
|
||||
// Only do auto-track if frequency is in Ultrabeam range (40M-6M: 7000-54000 kHz)
|
||||
// This prevents retraction when slice is closed (FreqA becomes 0) or on out-of-range bands
|
||||
if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 {
|
||||
freqDiff := radioFreqKhz - ultrabeamFreqKhz
|
||||
if freqDiff < 0 {
|
||||
freqDiff = -freqDiff
|
||||
}
|
||||
|
||||
// Convert diff to Hz for comparison with threshold (which is in Hz)
|
||||
freqDiffHz := freqDiff * 1000
|
||||
|
||||
// Don't send command if motors are already moving
|
||||
if status.Ultrabeam.MotorsMoving == 0 {
|
||||
if freqDiffHz >= dm.freqThreshold {
|
||||
// Use user's explicitly set direction, or fallback to current Ultrabeam direction
|
||||
directionToUse := dm.ultrabeamDirection
|
||||
if !dm.ultrabeamDirectionSet && status.Ultrabeam.Direction != 0 {
|
||||
directionToUse = status.Ultrabeam.Direction
|
||||
}
|
||||
|
||||
// Check cooldown to prevent rapid fire commands
|
||||
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||
if timeSinceLastUpdate > dm.freqUpdateCooldown {
|
||||
log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse)
|
||||
|
||||
// Send to Ultrabeam with saved or current direction
|
||||
if err := dm.ultrabeam.SetFrequency(radioFreqKhz, directionToUse); err != nil {
|
||||
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
|
||||
} else {
|
||||
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
|
||||
dm.lastFreqUpdateTime = time.Now() // Update cooldown timer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If out of range, simply skip auto-track but continue with status broadcast
|
||||
}
|
||||
|
||||
// Solar Data (fetched every 15 minutes, cached)
|
||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||
@@ -263,6 +464,8 @@ func (dm *DeviceManager) updateStatus() {
|
||||
if dm.hub != nil {
|
||||
dm.hub.BroadcastStatusUpdate(status)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) GetStatus() *SystemStatus {
|
||||
@@ -298,3 +501,18 @@ func (dm *DeviceManager) AntennaGenius() *antennagenius.Client {
|
||||
func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client {
|
||||
return dm.rotatorGenius
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client {
|
||||
return dm.ultrabeam
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) SetAutoTrack(enabled bool, thresholdHz int) {
|
||||
dm.autoTrackEnabled = enabled
|
||||
dm.freqThreshold = thresholdHz
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) SetUltrabeamDirection(direction int) {
|
||||
dm.ultrabeamDirection = direction
|
||||
dm.ultrabeamDirectionSet = true // Mark that user has explicitly set direction
|
||||
log.Printf("Ultrabeam direction set to: %d (user choice)", direction)
|
||||
}
|
||||
|
||||
@@ -49,41 +49,32 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
||||
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
||||
|
||||
// Rotator endpoints
|
||||
<<<<<<< HEAD
|
||||
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/ccw", s.handleRotatorCCW)
|
||||
mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop)
|
||||
|
||||
// Ultrabeam endpoints
|
||||
mux.HandleFunc("/api/ultrabeam/frequency", s.handleUltrabeamFrequency)
|
||||
mux.HandleFunc("/api/ultrabeam/retract", s.handleUltrabeamRetract)
|
||||
mux.HandleFunc("/api/ultrabeam/autotrack", s.handleUltrabeamAutoTrack)
|
||||
mux.HandleFunc("/api/ultrabeam/direction", s.handleUltrabeamDirection)
|
||||
|
||||
// Tuner endpoints
|
||||
mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate)
|
||||
<<<<<<< HEAD
|
||||
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
||||
|
||||
// Antenna Genius endpoints
|
||||
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
||||
mux.HandleFunc("/api/antenna/deselect", s.handleAntennaDeselect)
|
||||
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
||||
|
||||
// Power Genius endpoints
|
||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
||||
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||
=======
|
||||
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune)
|
||||
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna)
|
||||
|
||||
// Antenna Genius endpoints
|
||||
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")))
|
||||
// Note: Static files are now served from embedded FS in main.go
|
||||
|
||||
return mux
|
||||
}
|
||||
@@ -196,23 +187,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Rotator handlers
|
||||
<<<<<<< HEAD
|
||||
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 {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
<<<<<<< HEAD
|
||||
Heading int `json:"heading"`
|
||||
=======
|
||||
Rotator int `json:"rotator"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -238,17 +216,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -262,17 +230,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -302,11 +260,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
<<<<<<< HEAD
|
||||
Value int `json:"value"`
|
||||
=======
|
||||
Operate bool `json:"operate"`
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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().SetOperate(req.Operate); err != nil {
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -363,22 +313,15 @@ func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Antenna Genius handlers
|
||||
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 {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
<<<<<<< HEAD
|
||||
Port int `json:"port"`
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
Antenna int `json:"antenna"`
|
||||
}
|
||||
|
||||
@@ -387,11 +330,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -399,22 +338,14 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
||||
=======
|
||||
// Antenna Genius handlers
|
||||
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
||||
=======
|
||||
var req struct {
|
||||
Radio int `json:"radio"`
|
||||
Port int `json:"port"`
|
||||
Antenna int `json:"antenna"`
|
||||
}
|
||||
|
||||
@@ -423,8 +354,24 @@ func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.AntennaGenius().SetRadioAntenna(req.Radio, req.Antenna); err != nil {
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
log.Printf("Deselecting antenna %d from port %d", req.Antenna, req.Port)
|
||||
if err := s.deviceManager.AntennaGenius().DeselectAntenna(req.Port, req.Antenna); err != nil {
|
||||
log.Printf("Failed to deselect antenna: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("Successfully deselected antenna %d from port %d", req.Antenna, req.Port)
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.AntennaGenius().Reboot(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -456,7 +403,6 @@ func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
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"})
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
// Ultrabeam handlers
|
||||
func (s *Server) handleUltrabeamFrequency(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Save direction for auto-track to use
|
||||
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||
|
||||
if err := s.deviceManager.Ultrabeam().SetFrequency(req.Frequency, req.Direction); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.Ultrabeam().Retract(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamAutoTrack(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Threshold int `json:"threshold"` // kHz
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.deviceManager.SetAutoTrack(req.Enabled, req.Threshold*1000) // Convert kHz to Hz
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Just save the direction preference for auto-track to use
|
||||
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
|
||||
@@ -25,6 +25,8 @@ type DevicesConfig struct {
|
||||
TunerGenius TunerGeniusConfig `yaml:"tuner_genius"`
|
||||
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
||||
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
||||
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
|
||||
FlexRadio FlexRadioConfig `yaml:"flexradio"`
|
||||
}
|
||||
|
||||
type WebSwitchConfig struct {
|
||||
@@ -51,6 +53,18 @@ type RotatorGeniusConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type UltrabeamConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type FlexRadioConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
InterlockName string `yaml:"interlock_name"`
|
||||
}
|
||||
|
||||
type WeatherConfig struct {
|
||||
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
||||
LightningEnabled bool `yaml:"lightning_enabled"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package antennagenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
<<<<<<< HEAD
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
@@ -53,43 +52,17 @@ type Antenna struct {
|
||||
RX string `json:"rx"`
|
||||
InBand string `json:"in_band"`
|
||||
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 {
|
||||
return &Client{
|
||||
<<<<<<< HEAD
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
=======
|
||||
host: host,
|
||||
port: port,
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
<<<<<<< HEAD
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
@@ -97,26 +70,20 @@ func (c *Client) Connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
<<<<<<< HEAD
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
// Read and discard banner
|
||||
_, _ = c.reader.ReadString('\n')
|
||||
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
<<<<<<< HEAD
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
@@ -124,15 +91,12 @@ func (c *Client) Close() error {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
@@ -214,21 +178,22 @@ func (c *Client) pollLoop() {
|
||||
|
||||
func (c *Client) initialize() error {
|
||||
// Get antenna list
|
||||
log.Println("AntennaGenius: Getting antenna list...")
|
||||
antennas, err := c.getAntennaList()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get antenna list: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("AntennaGenius: Found %d antennas", len(antennas))
|
||||
for i, ant := range antennas {
|
||||
log.Printf("AntennaGenius: Antenna %d: number=%d, name=%s", i, ant.Number, ant.Name)
|
||||
}
|
||||
|
||||
c.antennasMu.Lock()
|
||||
c.antennas = antennas
|
||||
c.antennasMu.Unlock()
|
||||
|
||||
// Subscribe to port updates
|
||||
if err := c.subscribeToPortUpdates(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe: %w", err)
|
||||
}
|
||||
|
||||
// Initialize status
|
||||
// Initialize status BEFORE subscribing so parsePortStatus can update it
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
PortA: &PortStatus{},
|
||||
@@ -238,6 +203,23 @@ func (c *Client) initialize() error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -299,46 +281,10 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
|
||||
func (c *Client) getAntennaList() ([]Antenna, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
var antennas []Antenna
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
log.Printf("AntennaGenius: Failed to subscribe: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Parse initial port status from subscription response
|
||||
// The response may contain S0|port messages with current status
|
||||
lines := strings.Split(resp, "\n")
|
||||
@@ -465,18 +364,12 @@ func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
||||
if strings.HasPrefix(line, "S0|port") {
|
||||
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
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
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>
|
||||
|
||||
@@ -547,7 +440,14 @@ func (c *Client) GetStatus() (*Status, error) {
|
||||
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
|
||||
@@ -557,25 +457,22 @@ func (c *Client) SetAntenna(port, antenna int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeselectAntenna deselects an antenna from a port (sets rxant=00)
|
||||
// Command format: "C1|port set <port> rxant=00"
|
||||
func (c *Client) DeselectAntenna(port, antenna int) error {
|
||||
cmd := fmt.Sprintf("port set %d rxant=00", port)
|
||||
log.Printf("AntennaGenius: Sending deselect command: %s", cmd)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
log.Printf("AntennaGenius: Deselect failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("AntennaGenius: Deselect response: %s", resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reboot reboots the device
|
||||
func (c *Client) Reboot() error {
|
||||
_, err := c.sendCommand("reboot")
|
||||
return err
|
||||
=======
|
||||
// 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 (
|
||||
"bufio"
|
||||
"fmt"
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"log"
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
@@ -26,6 +23,14 @@ type Client struct {
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
|
||||
// Connection health tracking
|
||||
lastAliveTime time.Time
|
||||
|
||||
// Auto fan management
|
||||
autoFanEnabled bool
|
||||
lastFanMode string // Remember last manual mode
|
||||
autoFanActive bool // Track if auto-fan is currently active (in Broadcast mode)
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -45,10 +50,10 @@ type Status struct {
|
||||
BandB string `json:"band_b"`
|
||||
FaultPresent bool `json:"fault_present"`
|
||||
Connected bool `json:"connected"`
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
Meffa string `json:"meffa"`
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
|
||||
// Peak hold for display (internal)
|
||||
displayPower float64
|
||||
peakTime time.Time
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
@@ -56,6 +61,8 @@ func New(host string, port int) *Client {
|
||||
host: host,
|
||||
port: port,
|
||||
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
|
||||
func (c *Client) Start() error {
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
if err := c.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Initialize connection tracking
|
||||
c.lastAliveTime = time.Now()
|
||||
|
||||
// Try to connect, but don't fail if it doesn't work
|
||||
// The poll loop will keep trying
|
||||
_ = c.Connect()
|
||||
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
@@ -128,7 +128,6 @@ func (c *Client) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
<<<<<<< HEAD
|
||||
// Try to reconnect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
@@ -152,12 +151,6 @@ func (c *Client) pollLoop() {
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
// 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()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
@@ -165,7 +158,6 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
@@ -178,21 +170,92 @@ func (c *Client) pollLoop() {
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
=======
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("PowerGenius reconnect failed: %v", err)
|
||||
// Check if device is actually alive (not just TCP connected)
|
||||
// If voltage is 0 and temperature is 0, device might be temporarily idle
|
||||
// Use a 3-second timeout before marking as disconnected (helps with morse code pauses)
|
||||
if status.Voltage == 0 && status.Temperature == 0 {
|
||||
// Check if we've seen valid data recently (within 3 seconds)
|
||||
if time.Since(c.lastAliveTime) > 3*time.Second {
|
||||
status.Connected = false
|
||||
}
|
||||
// else: Keep Connected = true (device is probably just idle between morse letters)
|
||||
} else {
|
||||
// Valid data received, update lastAliveTime
|
||||
c.lastAliveTime = time.Now()
|
||||
}
|
||||
|
||||
// Peak hold logic - keep highest power for 1 second
|
||||
now := time.Now()
|
||||
if c.lastStatus != nil {
|
||||
// If new power is higher, update peak
|
||||
if status.PowerForward > c.lastStatus.displayPower {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
} else {
|
||||
// Check if peak has expired (1 second)
|
||||
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||
// Keep old peak
|
||||
status.displayPower = c.lastStatus.displayPower
|
||||
status.peakTime = c.lastStatus.peakTime
|
||||
} else {
|
||||
// Peak expired, use current value
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
|
||||
// Override PowerForward with display power for frontend
|
||||
status.PowerForward = status.displayPower
|
||||
|
||||
// Auto fan management based on temperature
|
||||
// Do this BEFORE merging to use the fresh temperature value
|
||||
if c.autoFanEnabled {
|
||||
// Use the temperature from the current status message
|
||||
// If it's 0, use the last known temperature
|
||||
temp := status.Temperature
|
||||
if temp == 0 && c.lastStatus != nil {
|
||||
temp = c.lastStatus.Temperature
|
||||
}
|
||||
|
||||
currentMode := strings.ToUpper(status.FanMode)
|
||||
if currentMode == "" && c.lastStatus != nil {
|
||||
currentMode = strings.ToUpper(c.lastStatus.FanMode)
|
||||
}
|
||||
|
||||
// Only act on valid temperature readings
|
||||
if temp > 5.0 { // Ignore invalid/startup readings below 5°C
|
||||
// If temp >= 60°C, switch to Broadcast
|
||||
if temp >= 60.0 && currentMode != "BROADCAST" {
|
||||
if !c.autoFanActive {
|
||||
log.Printf("PowerGenius: Temperature %.1f°C >= 60°C, switching fan to Broadcast mode", temp)
|
||||
c.autoFanActive = true
|
||||
}
|
||||
if err := c.setFanModeInternal("BROADCAST"); err != nil {
|
||||
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If temp <= 55°C, switch back to Contest
|
||||
if temp <= 55.0 && currentMode == "BROADCAST" {
|
||||
if c.autoFanActive {
|
||||
log.Printf("PowerGenius: Temperature %.1f°C <= 55°C, switching fan back to Contest mode", temp)
|
||||
c.autoFanActive = false
|
||||
}
|
||||
if err := c.setFanModeInternal("CONTEST"); err != nil {
|
||||
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
// Merge with existing status (spontaneous messages may only update some fields)
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
// Keep existing values for fields not in the new status
|
||||
if status.PowerForward == 0 && c.lastStatus.PowerForward != 0 {
|
||||
status.PowerForward = c.lastStatus.PowerForward
|
||||
}
|
||||
if status.Temperature == 0 && c.lastStatus.Temperature != 0 {
|
||||
status.Temperature = c.lastStatus.Temperature
|
||||
}
|
||||
@@ -361,11 +424,6 @@ func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
}
|
||||
case "vac":
|
||||
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
case "meffa":
|
||||
status.Meffa = value
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
case "vdd":
|
||||
status.VDD, _ = strconv.ParseFloat(value, 64)
|
||||
case "id":
|
||||
@@ -430,15 +488,25 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
"BROADCAST": true,
|
||||
}
|
||||
|
||||
if !validModes[mode] {
|
||||
// Normalize mode to title case for comparison
|
||||
modeUpper := strings.ToUpper(mode)
|
||||
if !validModes[modeUpper] {
|
||||
return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode)
|
||||
}
|
||||
|
||||
// Remember last manual mode (if not triggered by auto-fan)
|
||||
// We store it in title case: "Standard", "Contest", "Broadcast"
|
||||
c.lastFanMode = strings.Title(strings.ToLower(mode))
|
||||
|
||||
return c.setFanModeInternal(modeUpper)
|
||||
}
|
||||
|
||||
// setFanModeInternal sets fan mode without updating lastFanMode (for auto-fan)
|
||||
func (c *Client) setFanModeInternal(mode string) error {
|
||||
cmd := fmt.Sprintf("setup fanmode=%s", mode)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
// SetOperate sets the operate mode
|
||||
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
||||
@@ -451,5 +519,3 @@ func (c *Client) SetOperate(value int) error {
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -26,6 +24,7 @@ type Client struct {
|
||||
|
||||
type Status struct {
|
||||
Heading int `json:"heading"`
|
||||
TargetHeading int `json:"target_heading"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
@@ -151,18 +150,7 @@ func (c *Client) sendCommand(cmd string) error {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
_, 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 {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
@@ -224,6 +212,11 @@ func (c *Client) parseStatus(response string) *Status {
|
||||
if err == nil {
|
||||
status.Heading = heading
|
||||
}
|
||||
targetStr := response[19:22]
|
||||
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||
if err == nil {
|
||||
status.TargetHeading = targetHeading
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
<<<<<<< HEAD
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
@@ -23,11 +22,6 @@ type Client struct {
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
=======
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -54,18 +48,17 @@ type Status struct {
|
||||
RelayC2 int `json:"c2"`
|
||||
TuningStatus string `json:"tuning_status"`
|
||||
Connected bool `json:"connected"`
|
||||
|
||||
// Peak hold for display (internal)
|
||||
displayPower float64
|
||||
peakTime time.Time
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
<<<<<<< HEAD
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
=======
|
||||
host: host,
|
||||
port: port,
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +103,6 @@ func (c *Client) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Try to connect, but don't fail if it doesn't work
|
||||
// The poll loop will keep trying
|
||||
_ = c.Connect()
|
||||
@@ -171,6 +163,39 @@ func (c *Client) pollLoop() {
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Check if device is actually alive
|
||||
// If all frequencies are 0, device is probably off
|
||||
if status.FreqA == 0 && status.FreqB == 0 && status.PowerForward == 0 {
|
||||
status.Connected = false
|
||||
}
|
||||
|
||||
// Peak hold logic - keep highest power for 1 second
|
||||
now := time.Now()
|
||||
if c.lastStatus != nil {
|
||||
// If new power is higher, update peak
|
||||
if status.PowerForward > c.lastStatus.displayPower {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
} else {
|
||||
// Check if peak has expired (1 second)
|
||||
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||
// Keep old peak
|
||||
status.displayPower = c.lastStatus.displayPower
|
||||
status.peakTime = c.lastStatus.peakTime
|
||||
} else {
|
||||
// Peak expired, use current value
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
|
||||
// Override PowerForward with display power for frontend
|
||||
status.PowerForward = status.displayPower
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
@@ -221,8 +246,6 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
// Get next command ID from global counter
|
||||
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"
|
||||
status := &Status{
|
||||
<<<<<<< HEAD
|
||||
Relays: make([]RelayState, 0, 5),
|
||||
Connected: true,
|
||||
=======
|
||||
Relays: make([]RelayState, 0, 5),
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<<<<<<< HEAD
|
||||
<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.gstatic.com" crossorigin>
|
||||
<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 { wsService, connected, systemStatus } from './lib/websocket.js';
|
||||
import { api } from './lib/api.js';
|
||||
import StatusBanner from './components/StatusBanner.svelte';
|
||||
import WebSwitch from './components/WebSwitch.svelte';
|
||||
import PowerGenius from './components/PowerGenius.svelte';
|
||||
import TunerGenius from './components/TunerGenius.svelte';
|
||||
import AntennaGenius from './components/AntennaGenius.svelte';
|
||||
import RotatorGenius from './components/RotatorGenius.svelte';
|
||||
import Ultrabeam from './components/Ultrabeam.svelte';
|
||||
|
||||
let status = null;
|
||||
let isConnected = false;
|
||||
let currentTime = new Date();
|
||||
let callsign = 'F4BPO'; // Default
|
||||
let latitude = null;
|
||||
let longitude = null;
|
||||
|
||||
const unsubscribeStatus = systemStatus.subscribe(value => {
|
||||
status = value;
|
||||
@@ -39,6 +43,10 @@
|
||||
if (config.callsign) {
|
||||
callsign = config.callsign;
|
||||
}
|
||||
if (config.location) {
|
||||
latitude = config.location.latitude;
|
||||
longitude = config.location.longitude;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
}
|
||||
@@ -94,8 +102,8 @@
|
||||
|
||||
<div class="header-right">
|
||||
<div class="weather-info">
|
||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)}m/s</span>
|
||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)}m/s</span>
|
||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)} km/h</span>
|
||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)} km/h</span>
|
||||
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
|
||||
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)} °C</span>
|
||||
</div>
|
||||
@@ -106,17 +114,28 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ✅ NOUVEAU : Bandeau de statut avec fréquence FlexRadio et alertes météo -->
|
||||
<StatusBanner
|
||||
flexradio={status?.flexradio}
|
||||
weather={status?.weather}
|
||||
{latitude}
|
||||
{longitude}
|
||||
windWarningThreshold={30}
|
||||
gustWarningThreshold={50}
|
||||
/>
|
||||
|
||||
<main>
|
||||
<div class="dashboard-grid">
|
||||
<div class="row">
|
||||
<WebSwitch status={status?.webswitch} />
|
||||
<PowerGenius status={status?.power_genius} />
|
||||
<TunerGenius status={status?.tuner_genius} />
|
||||
<TunerGenius status={status?.tuner_genius} flexradio={status?.flexradio} />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<AntennaGenius status={status?.antenna_genius} />
|
||||
<RotatorGenius status={status?.rotator_genius} />
|
||||
<Ultrabeam status={status?.ultrabeam} flexradio={status?.flexradio} />
|
||||
<RotatorGenius status={status?.rotator_genius} ultrabeam={status?.ultrabeam} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -130,12 +149,13 @@
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -176,13 +196,41 @@
|
||||
}
|
||||
|
||||
.solar-item {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.solar-item .value {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.solar-item:nth-child(1) .value { /* SFI */
|
||||
color: #ffa726;
|
||||
text-shadow: 0 0 8px rgba(255, 167, 38, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(2) .value { /* Spots */
|
||||
color: #66bb6a;
|
||||
text-shadow: 0 0 8px rgba(102, 187, 106, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(3) .value { /* A */
|
||||
color: #42a5f5;
|
||||
text-shadow: 0 0 8px rgba(66, 165, 245, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(4) .value { /* K */
|
||||
color: #ef5350;
|
||||
text-shadow: 0 0 8px rgba(239, 83, 80, 0.5);
|
||||
}
|
||||
|
||||
.solar-item:nth-child(5) .value { /* G */
|
||||
color: #ab47bc;
|
||||
text-shadow: 0 0 8px rgba(171, 71, 188, 0.5);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -213,6 +261,7 @@
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
main {
|
||||
|
||||
129
web/src/app.css
129
web/src/app.css
@@ -1,5 +1,4 @@
|
||||
:root {
|
||||
<<<<<<< HEAD
|
||||
/* Modern dark theme inspired by FlexDXCluster */
|
||||
--bg-primary: #0a1628;
|
||||
--bg-secondary: #1a2332;
|
||||
@@ -436,132 +435,4 @@ select:focus {
|
||||
order: 3;
|
||||
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>
|
||||
import { api } from '../lib/api.js';
|
||||
<<<<<<< HEAD
|
||||
|
||||
export let status;
|
||||
|
||||
@@ -21,11 +20,37 @@
|
||||
|
||||
async function selectAntenna(port, antennaNum) {
|
||||
try {
|
||||
await api.antenna.selectAntenna(port, antennaNum, antennaNum);
|
||||
} catch (err) {
|
||||
console.error('Failed to select antenna:', err);
|
||||
alert('Failed to select antenna');
|
||||
// Check if antenna is already selected on this port
|
||||
const isAlreadySelected = (port === 1 && portA.rx_ant === antennaNum) ||
|
||||
(port === 2 && portB.rx_ant === antennaNum);
|
||||
|
||||
if (isAlreadySelected) {
|
||||
// Deselect: set rxant to 00
|
||||
console.log(`Deselecting antenna ${antennaNum} from port ${port}`);
|
||||
await api.antenna.deselectAntenna(port, antennaNum);
|
||||
} else {
|
||||
// Select normally
|
||||
console.log(`Selecting antenna ${antennaNum} on port ${port}`);
|
||||
await api.antenna.selectAntenna(port, antennaNum);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to 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() {
|
||||
@@ -34,28 +59,14 @@
|
||||
}
|
||||
try {
|
||||
await api.antenna.reboot();
|
||||
console.log('Antenna Genius reboot command sent');
|
||||
} catch (err) {
|
||||
console.error('Failed to reboot:', err);
|
||||
alert('Failed to reboot');
|
||||
=======
|
||||
|
||||
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
|
||||
// No popup, just log
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<<<<<<< HEAD
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Antenna Genius</h2>
|
||||
@@ -88,29 +99,30 @@
|
||||
{#each antennas as antenna}
|
||||
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
||||
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
||||
{@const isPortARx = !portA.tx && (portA.rx_ant === antenna.number || portA.tx_ant === antenna.number)}
|
||||
{@const isPortBRx = !portB.tx && (portB.rx_ant === antenna.number || portB.tx_ant === antenna.number)}
|
||||
{@const isPortARx = !portA.tx && portA.rx_ant === antenna.number}
|
||||
{@const isPortBRx = !portB.tx && portB.rx_ant === antenna.number}
|
||||
{@const isTx = isPortATx || isPortBTx}
|
||||
{@const isActive = isPortARx || isPortBRx}
|
||||
{@const isActiveA = isPortARx || isPortATx}
|
||||
{@const isActiveB = isPortBRx || isPortBTx}
|
||||
|
||||
<div
|
||||
class="antenna-card"
|
||||
class:tx={isTx}
|
||||
class:active-a={isPortARx}
|
||||
class:active-b={isPortBRx}
|
||||
class:active-a={isActiveA}
|
||||
class:active-b={isActiveB}
|
||||
>
|
||||
<div class="antenna-name">{antenna.name}</div>
|
||||
<div class="antenna-ports">
|
||||
<button
|
||||
class="port-btn"
|
||||
class:active={portA.tx_ant === antenna.number || portA.rx_ant === antenna.number}
|
||||
class:active={isActiveA}
|
||||
on:click={() => selectAntenna(1, antenna.number)}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<button
|
||||
class="port-btn"
|
||||
class:active={portB.tx_ant === antenna.number || portB.rx_ant === antenna.number}
|
||||
class:active={isActiveB}
|
||||
on:click={() => selectAntenna(2, antenna.number)}
|
||||
>
|
||||
B
|
||||
@@ -125,53 +137,10 @@
|
||||
<span class="reboot-icon">🔄</span>
|
||||
REBOOT
|
||||
</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>
|
||||
|
||||
<style>
|
||||
<<<<<<< HEAD
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
@@ -278,12 +247,6 @@
|
||||
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 {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-color: #4caf50;
|
||||
@@ -296,6 +259,13 @@
|
||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
/* TX must come AFTER active-a/active-b to override */
|
||||
.antenna-card.tx {
|
||||
background: rgba(244, 67, 54, 0.2) !important;
|
||||
border-color: #f44336 !important;
|
||||
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4) !important;
|
||||
}
|
||||
|
||||
.antenna-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -365,72 +335,5 @@
|
||||
|
||||
.reboot-icon {
|
||||
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>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script>
|
||||
<<<<<<< HEAD
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
export let status;
|
||||
@@ -7,6 +6,7 @@
|
||||
$: 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;
|
||||
@@ -31,7 +31,7 @@
|
||||
await api.power.setFanMode(mode);
|
||||
} catch (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);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle operate:', err);
|
||||
alert('Failed to toggle operate mode');
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -63,54 +63,39 @@
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<!-- Power Display - Big and Bold -->
|
||||
<div class="power-display">
|
||||
<div class="power-main">
|
||||
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
|
||||
<div class="power-label">Forward Power</div>
|
||||
<!-- Power Display + SWR Side by Side -->
|
||||
<div class="power-swr-row">
|
||||
<div class="power-section">
|
||||
<div class="power-header">
|
||||
<span class="power-label-inline">Power</span>
|
||||
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||
</div>
|
||||
<div class="power-bar">
|
||||
<div class="power-bar-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
<div class="power-bar-glow"></div>
|
||||
</div>
|
||||
<div class="power-scale">
|
||||
<span>0</span>
|
||||
<span>1000</span>
|
||||
<span>2000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SWR Circle Indicator -->
|
||||
<div class="swr-container">
|
||||
<div class="swr-circle" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value">{swr.toFixed(2)}</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}
|
||||
<!-- SWR Circle Compact -->
|
||||
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||
<div class="swr-label-compact">SWR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temperature Gauges -->
|
||||
<div class="temp-group">
|
||||
<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-mini-bar">
|
||||
<div class="temp-mini-fill" style="width: {(temperature / 80) * 100}%; background: {tempColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<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-mini-bar">
|
||||
<div class="temp-mini-fill" style="width: {(harmonicLoadTemp / 80) * 100}%; background: {tempColor}"></div>
|
||||
@@ -148,8 +133,8 @@
|
||||
|
||||
<!-- Fan Control -->
|
||||
<div class="fan-control">
|
||||
<label class="control-label">Fan Mode</label>
|
||||
<select value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||
<label for="fan-mode-select" class="control-label">Fan Mode</label>
|
||||
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||
<option value="STANDARD">Standard</option>
|
||||
<option value="CONTEST">Contest</option>
|
||||
<option value="BROADCAST">Broadcast</option>
|
||||
@@ -234,280 +219,100 @@
|
||||
|
||||
.metrics {
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Power Display */
|
||||
.power-display {
|
||||
/* Power + SWR Row */
|
||||
.power-swr-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.power-main {
|
||||
text-align: center;
|
||||
.power-section {
|
||||
flex: 1;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.power-value {
|
||||
font-size: 48px;
|
||||
font-weight: 200;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||
.power-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.power-value .unit {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.power-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
.power-label-inline {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.power-bar {
|
||||
.power-value-inline {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.power-bar-container {
|
||||
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;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||
border-radius: 4px;
|
||||
border-radius: 14px;
|
||||
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 {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.power-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
.swr-circle-compact {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: 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 {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
.swr-value-compact {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--swr-color);
|
||||
}
|
||||
|
||||
.swr-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
.swr-label-compact {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
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 */
|
||||
.temp-group {
|
||||
display: grid;
|
||||
@@ -572,7 +377,7 @@ async function setFanMode(mode) {
|
||||
}
|
||||
|
||||
.param-value {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
@@ -589,65 +394,10 @@ async function setFanMode(mode) {
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
<<<<<<< HEAD
|
||||
|
||||
.band-label {
|
||||
font-size: 11px;
|
||||
@@ -694,6 +444,4 @@ async function setFanMode(mode) {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
</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>
|
||||
import { api } from '../lib/api.js';
|
||||
<<<<<<< HEAD
|
||||
|
||||
export let status;
|
||||
|
||||
@@ -25,7 +24,7 @@
|
||||
await api.tuner.autoTune();
|
||||
} catch (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);
|
||||
} catch (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);
|
||||
} catch (err) {
|
||||
console.error('Failed to set operate:', err);
|
||||
alert('Failed to set operate');
|
||||
=======
|
||||
|
||||
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
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<<<<<<< HEAD
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Tuner Genius XL</h2>
|
||||
@@ -101,40 +57,25 @@
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<!-- Power Display -->
|
||||
<div class="power-display">
|
||||
<div class="power-main">
|
||||
<div class="power-value">{powerForward.toFixed(0)}<span class="unit">W</span></div>
|
||||
<div class="power-label">Forward Power</div>
|
||||
<!-- Power Display + SWR Side by Side -->
|
||||
<div class="power-swr-row">
|
||||
<div class="power-section">
|
||||
<div class="power-header">
|
||||
<span class="power-label-inline">Power</span>
|
||||
<span class="power-value-inline">{powerForward.toFixed(0)} W</span>
|
||||
</div>
|
||||
<div class="power-bar">
|
||||
<div class="power-bar-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
<div class="power-bar-glow"></div>
|
||||
</div>
|
||||
<div class="power-scale">
|
||||
<span>0</span>
|
||||
<span>1000</span>
|
||||
<span>2000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SWR Circle -->
|
||||
<div class="swr-container">
|
||||
<div class="swr-circle" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value">{swr.toFixed(2)}</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}
|
||||
<!-- SWR Circle Compact -->
|
||||
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||
<div class="swr-label-compact">SWR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,128 +204,106 @@
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Power Display */
|
||||
.power-display {
|
||||
/* Power + SWR Row */
|
||||
.power-swr-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.power-main {
|
||||
text-align: center;
|
||||
.power-section {
|
||||
flex: 1;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.power-value {
|
||||
font-size: 48px;
|
||||
font-weight: 200;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||
.power-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.power-value .unit {
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.power-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
.power-label-inline {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.power-bar {
|
||||
.power-value-inline {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.power-bar-container {
|
||||
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;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||
border-radius: 4px;
|
||||
border-radius: 14px;
|
||||
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 {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.power-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
.swr-circle-compact {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: 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 {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
.swr-value-compact {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--swr-color);
|
||||
}
|
||||
|
||||
.swr-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
.swr-label-compact {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
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;
|
||||
}
|
||||
/* SWR Circle */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.status-text.good { color: #4caf50; }
|
||||
.status-text.ok { color: #ffc107; }
|
||||
.status-text.warning { color: #ff9800; }
|
||||
.status-text.danger { color: #f44336; }
|
||||
|
||||
/* Capacitors */
|
||||
.capacitors {
|
||||
@@ -405,7 +324,7 @@
|
||||
}
|
||||
|
||||
.cap-value {
|
||||
font-size: 32px;
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||
@@ -484,218 +403,10 @@
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
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 {
|
||||
width: 100%;
|
||||
<<<<<<< HEAD
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
@@ -724,8 +435,6 @@
|
||||
}
|
||||
|
||||
.tune-icon {
|
||||
font-size: 18px;
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
font-size: 16px;
|
||||
}
|
||||
</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;
|
||||
|
||||
$: relays = status?.relays || [];
|
||||
<<<<<<< HEAD
|
||||
$: connected = status?.connected || false;
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
|
||||
const relayNames = {
|
||||
1: 'Power Supply',
|
||||
@@ -55,7 +52,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<<<<<<< HEAD
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>WebSwitch</h2>
|
||||
@@ -99,40 +95,10 @@
|
||||
ALL OFF
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
<<<<<<< HEAD
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
@@ -335,73 +301,5 @@
|
||||
|
||||
.all-off:hover {
|
||||
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>
|
||||
@@ -47,7 +47,6 @@ export const api = {
|
||||
|
||||
// Tuner
|
||||
tuner: {
|
||||
<<<<<<< HEAD
|
||||
setOperate: (value) => request('/tuner/operate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ value }),
|
||||
@@ -57,33 +56,19 @@ export const api = {
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
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: {
|
||||
<<<<<<< HEAD
|
||||
selectAntenna: (port, antenna) => request('/antenna/select', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ port, antenna }),
|
||||
}),
|
||||
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||
=======
|
||||
set: (radio, antenna) => request('/antenna/set', {
|
||||
deselectAntenna: (port, antenna) => request('/antenna/deselect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ radio, antenna }),
|
||||
body: JSON.stringify({ port, antenna }),
|
||||
}),
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Power Genius
|
||||
@@ -92,7 +77,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
<<<<<<< HEAD
|
||||
setOperate: (value) => request('/power/operate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ value }),
|
||||
@@ -108,7 +92,22 @@ export const api = {
|
||||
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
||||
rotateCCW: () => request('/rotator/ccw', { 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);
|
||||
|
||||
if (message.type === 'update') {
|
||||
<<<<<<< HEAD
|
||||
console.log('System status updated:', message.data);
|
||||
=======
|
||||
>>>>>>> 4ab192418e21065c68d59777493ea03b76c061e7
|
||||
systemStatus.set(message.data);
|
||||
lastUpdate.set(new Date(message.timestamp));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user