Compare commits
27 Commits
4ab192418e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 130efeee83 | |||
| 4eeec6bdf6 | |||
| de3fda2648 | |||
| c6ceeb103b | |||
| b8884d89e3 | |||
| 5332ab9dc1 | |||
| b8db847343 | |||
| 0cb83157de | |||
| 4f484b0091 | |||
| 6b5508802a | |||
| 51e08d9463 | |||
| 2bec98a080 | |||
| 431c17347d | |||
| 4f9e1e88eb | |||
| 414d802d37 | |||
| cd93f0ea67 | |||
| 3d06dd44d5 | |||
| 9837657dd9 | |||
| 46ee44c6c9 | |||
| bcf58b208b | |||
| 0ce18d87bc | |||
| f172678560 | |||
| 5fd81a641d | |||
| eee3f48569 | |||
| 8de9a0dd87 | |||
| bceac40518 | |||
| ac99f291a7 |
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
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,23 +86,67 @@ 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
|
||||
log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port)
|
||||
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,
|
||||
)
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -94,10 +157,53 @@ func (dm *DeviceManager) Initialize() error {
|
||||
dm.config.Location.Longitude,
|
||||
)
|
||||
|
||||
// Start PowerGenius continuous polling
|
||||
// Start device polling in background (non-blocking)
|
||||
go func() {
|
||||
if err := dm.powerGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start PowerGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := dm.tunerGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start TunerGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := dm.antennaGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start AntennaGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("About to launch RotatorGenius goroutine...")
|
||||
go func() {
|
||||
log.Println("Starting RotatorGenius polling goroutine...")
|
||||
if err := dm.rotatorGenius.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start RotatorGenius polling: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("RotatorGenius goroutine launched")
|
||||
|
||||
log.Println("About to launch Ultrabeam goroutine...")
|
||||
go func() {
|
||||
log.Println("Starting Ultrabeam polling goroutine...")
|
||||
if err := dm.ultrabeam.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start Ultrabeam polling: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("Ultrabeam goroutine launched")
|
||||
|
||||
// Start FlexRadio if enabled
|
||||
if dm.flexRadio != nil {
|
||||
log.Println("Starting FlexRadio connection...")
|
||||
go func() {
|
||||
if err := dm.flexRadio.Start(); err != nil {
|
||||
log.Printf("Warning: Failed to start FlexRadio: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Println("FlexRadio goroutine launched")
|
||||
}
|
||||
|
||||
log.Println("Device manager initialized")
|
||||
return nil
|
||||
@@ -109,6 +215,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)
|
||||
@@ -126,6 +300,9 @@ func (dm *DeviceManager) Stop() {
|
||||
if dm.rotatorGenius != nil {
|
||||
dm.rotatorGenius.Close()
|
||||
}
|
||||
if dm.ultrabeam != nil {
|
||||
dm.ultrabeam.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) monitorDevices() {
|
||||
@@ -162,26 +339,106 @@ func (dm *DeviceManager) updateStatus() {
|
||||
log.Printf("Power 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)
|
||||
// }
|
||||
// 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)
|
||||
// }
|
||||
// Antenna Genius
|
||||
if agStatus, err := dm.antennaGenius.GetStatus(); err == nil {
|
||||
status.AntennaGenius = agStatus
|
||||
} else {
|
||||
log.Printf("Antenna Genius error: %v", err)
|
||||
}
|
||||
|
||||
// // Rotator Genius
|
||||
// if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
||||
// status.RotatorGenius = rgStatus
|
||||
// } else {
|
||||
// log.Printf("Rotator Genius error: %v", err)
|
||||
// }
|
||||
// Rotator Genius
|
||||
if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil {
|
||||
status.RotatorGenius = rgStatus
|
||||
} else {
|
||||
log.Printf("Rotator Genius error: %v", err)
|
||||
}
|
||||
|
||||
// Ultrabeam
|
||||
if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil {
|
||||
status.Ultrabeam = ubStatus
|
||||
|
||||
// Sync direction with Ultrabeam if user hasn't explicitly set one
|
||||
// This prevents auto-track from using wrong direction before user changes it
|
||||
if !dm.ultrabeamDirectionSet {
|
||||
dm.ultrabeamDirection = ubStatus.Direction
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Printf("Ultrabeam error: %v", err)
|
||||
}
|
||||
|
||||
// FlexRadio (use direct cache access to avoid mutex contention)
|
||||
if dm.flexRadio != nil {
|
||||
// Access lastStatus directly from FlexRadio's internal cache
|
||||
// The messageLoop updates this in real-time, no need to block on GetStatus
|
||||
frStatus, err := dm.flexRadio.GetStatus()
|
||||
if err == nil && frStatus != nil {
|
||||
status.FlexRadio = frStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Auto frequency tracking: Update Ultrabeam when radio frequency differs from Ultrabeam
|
||||
if dm.autoTrackEnabled {
|
||||
// TunerGenius tracking (FlexRadio uses immediate callback)
|
||||
var radioFreqKhz int
|
||||
var radioSource string
|
||||
|
||||
if status.TunerGenius != nil && status.TunerGenius.Connected {
|
||||
// Fallback to TunerGenius frequency (already in kHz)
|
||||
radioFreqKhz = int(status.TunerGenius.FreqA)
|
||||
radioSource = "TunerGenius"
|
||||
}
|
||||
|
||||
if radioFreqKhz > 0 && status.Ultrabeam != nil && status.Ultrabeam.Connected {
|
||||
ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz
|
||||
|
||||
// Only do auto-track if frequency is in Ultrabeam range (40M-6M: 7000-54000 kHz)
|
||||
// This prevents retraction when slice is closed (FreqA becomes 0) or on out-of-range bands
|
||||
if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 {
|
||||
freqDiff := radioFreqKhz - ultrabeamFreqKhz
|
||||
if freqDiff < 0 {
|
||||
freqDiff = -freqDiff
|
||||
}
|
||||
|
||||
// Convert diff to Hz for comparison with threshold (which is in Hz)
|
||||
freqDiffHz := freqDiff * 1000
|
||||
|
||||
// Don't send command if motors are already moving
|
||||
if status.Ultrabeam.MotorsMoving == 0 {
|
||||
if freqDiffHz >= dm.freqThreshold {
|
||||
// Use user's explicitly set direction, or fallback to current Ultrabeam direction
|
||||
directionToUse := dm.ultrabeamDirection
|
||||
if !dm.ultrabeamDirectionSet && status.Ultrabeam.Direction != 0 {
|
||||
directionToUse = status.Ultrabeam.Direction
|
||||
}
|
||||
|
||||
// Check cooldown to prevent rapid fire commands
|
||||
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
||||
log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate)
|
||||
} else {
|
||||
log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse)
|
||||
|
||||
// Send to Ultrabeam with saved or current direction
|
||||
if err := dm.ultrabeam.SetFrequency(radioFreqKhz, directionToUse); err != nil {
|
||||
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
|
||||
} else {
|
||||
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
|
||||
dm.lastFreqUpdateTime = time.Now() // Update cooldown timer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If out of range, simply skip auto-track but continue with status broadcast
|
||||
}
|
||||
|
||||
// Solar Data (fetched every 15 minutes, cached)
|
||||
if solarData, err := dm.solarClient.GetSolarData(); err == nil {
|
||||
@@ -206,6 +463,8 @@ func (dm *DeviceManager) updateStatus() {
|
||||
if dm.hub != nil {
|
||||
dm.hub.BroadcastStatusUpdate(status)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (dm *DeviceManager) GetStatus() *SystemStatus {
|
||||
@@ -241,3 +500,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,24 +49,32 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
||||
mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff)
|
||||
|
||||
// Rotator endpoints
|
||||
mux.HandleFunc("/api/rotator/move", s.handleRotatorMove)
|
||||
mux.HandleFunc("/api/rotator/heading", s.handleRotatorHeading)
|
||||
mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW)
|
||||
mux.HandleFunc("/api/rotator/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)
|
||||
mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune)
|
||||
mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna)
|
||||
mux.HandleFunc("/api/tuner/bypass", s.handleTunerBypass)
|
||||
mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune)
|
||||
|
||||
// Antenna Genius endpoints
|
||||
mux.HandleFunc("/api/antenna/set", s.handleAntennaSet)
|
||||
mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect)
|
||||
mux.HandleFunc("/api/antenna/deselect", s.handleAntennaDeselect)
|
||||
mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot)
|
||||
|
||||
// Power Genius endpoints
|
||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
||||
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -179,15 +187,14 @@ func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Rotator handlers
|
||||
func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleRotatorHeading(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rotator int `json:"rotator"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
Heading int `json:"heading"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -195,7 +202,7 @@ func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.RotatorGenius().MoveToAzimuth(req.Rotator, req.Azimuth); err != nil {
|
||||
if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -209,13 +216,7 @@ func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := s.deviceManager.RotatorGenius().RotateCW(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -229,13 +230,7 @@ func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := s.deviceManager.RotatorGenius().RotateCCW(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -265,7 +260,7 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Operate bool `json:"operate"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -273,7 +268,30 @@ func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Operate); err != nil {
|
||||
if err := s.deviceManager.TunerGenius().SetOperate(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTunerBypass(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.TunerGenius().SetBypass(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -295,13 +313,15 @@ func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
// Antenna Genius handlers
|
||||
func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Port int `json:"port"`
|
||||
Antenna int `json:"antenna"`
|
||||
}
|
||||
|
||||
@@ -310,7 +330,7 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.TunerGenius().ActivateAntenna(req.Antenna); err != nil {
|
||||
if err := s.deviceManager.AntennaGenius().SetAntenna(req.Port, req.Antenna); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -318,15 +338,14 @@ func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// Antenna Genius handlers
|
||||
func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleAntennaDeselect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Radio int `json:"radio"`
|
||||
Port int `json:"port"`
|
||||
Antenna int `json:"antenna"`
|
||||
}
|
||||
|
||||
@@ -335,7 +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 {
|
||||
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
|
||||
}
|
||||
@@ -367,6 +403,113 @@ func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) {
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePowerOperate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.PowerGenius().SetOperate(req.Value); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// Ultrabeam handlers
|
||||
func (s *Server) handleUltrabeamFrequency(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Save direction for auto-track to use
|
||||
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||
|
||||
if err := s.deviceManager.Ultrabeam().SetFrequency(req.Frequency, req.Direction); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamRetract(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deviceManager.Ultrabeam().Retract(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamAutoTrack(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Threshold int `json:"threshold"` // kHz
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.deviceManager.SetAutoTrack(req.Enabled, req.Threshold*1000) // Convert kHz to Hz
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Just save the direction preference for auto-track to use
|
||||
s.deviceManager.SetUltrabeamDirection(req.Direction)
|
||||
|
||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
||||
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,157 +3,476 @@ package antennagenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connMu sync.Mutex
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
antennas []Antenna
|
||||
antennasMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index)
|
||||
Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index)
|
||||
PortA *PortStatus `json:"port_a"`
|
||||
PortB *PortStatus `json:"port_b"`
|
||||
Antennas []Antenna `json:"antennas"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
type PortStatus struct {
|
||||
Auto bool `json:"auto"`
|
||||
Source string `json:"source"`
|
||||
Band int `json:"band"`
|
||||
Frequency float64 `json:"frequency"`
|
||||
Nickname string `json:"nickname"`
|
||||
RxAnt int `json:"rx_ant"`
|
||||
TxAnt int `json:"tx_ant"`
|
||||
InBand int `json:"in_band"`
|
||||
TX bool `json:"tx"`
|
||||
Inhibit int `json:"inhibit"`
|
||||
}
|
||||
|
||||
type Antenna struct {
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
TX string `json:"tx"`
|
||||
RX string `json:"rx"`
|
||||
InBand string `json:"in_band"`
|
||||
Hotkey int `json:"hotkey"`
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
// Read and discard banner
|
||||
_, _ = c.reader.ReadString('\n')
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.stopChan != nil {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
if err := c.Connect(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
_ = c.Connect()
|
||||
|
||||
// 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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.parseStatus(resp)
|
||||
}
|
||||
|
||||
func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
status := &Status{
|
||||
Connected: true,
|
||||
}
|
||||
|
||||
// Parse response format from 4O3A API
|
||||
// Expected format will vary - this is a basic parser
|
||||
pairs := strings.Fields(resp)
|
||||
|
||||
for _, pair := range pairs {
|
||||
parts := strings.SplitN(pair, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[0]
|
||||
value := parts[1]
|
||||
|
||||
switch key {
|
||||
case "radio1", "r1":
|
||||
status.Radio1Antenna, _ = strconv.Atoi(value)
|
||||
case "radio2", "r2":
|
||||
status.Radio2Antenna, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SetRadioAntenna sets which antenna a radio should use
|
||||
// radio: 1 or 2
|
||||
// antenna: 0-7 (antenna index)
|
||||
func (c *Client) SetRadioAntenna(radio int, antenna int) error {
|
||||
if radio < 1 || radio > 2 {
|
||||
return fmt.Errorf("radio must be 1 or 2")
|
||||
}
|
||||
if antenna < 0 || antenna > 7 {
|
||||
return fmt.Errorf("antenna must be between 0 and 7")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("set radio%d=%d", radio, antenna)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check response for success
|
||||
if !strings.Contains(strings.ToLower(resp), "ok") && resp != "" {
|
||||
// If response doesn't contain "ok" but isn't empty, assume success
|
||||
// (some devices may return the new state instead of "ok")
|
||||
}
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
initialized := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
continue
|
||||
}
|
||||
initialized = false
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Initialize: get antenna list and subscribe
|
||||
if !initialized {
|
||||
if err := c.initialize(); err != nil {
|
||||
log.Printf("AntennaGenius init error: %v", err)
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
continue
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
status, err := c.GetStatus()
|
||||
// Read spontaneous messages from subscription
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil && c.reader != nil {
|
||||
c.conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond))
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return -1, err
|
||||
break
|
||||
}
|
||||
|
||||
if radio == 1 {
|
||||
return status.Radio1Antenna, nil
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "S") {
|
||||
c.parsePortStatus(line)
|
||||
}
|
||||
return status.Radio2Antenna, nil
|
||||
}
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) initialize() error {
|
||||
// Get antenna list
|
||||
log.Println("AntennaGenius: Getting antenna list...")
|
||||
antennas, err := c.getAntennaList()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get antenna list: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("AntennaGenius: Found %d antennas", len(antennas))
|
||||
for i, ant := range antennas {
|
||||
log.Printf("AntennaGenius: Antenna %d: number=%d, name=%s", i, ant.Number, ant.Name)
|
||||
}
|
||||
|
||||
c.antennasMu.Lock()
|
||||
c.antennas = antennas
|
||||
c.antennasMu.Unlock()
|
||||
|
||||
// Initialize status BEFORE subscribing so parsePortStatus can update it
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
PortA: &PortStatus{},
|
||||
PortB: &PortStatus{},
|
||||
Antennas: antennas,
|
||||
Connected: true,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
log.Println("AntennaGenius: Status initialized, now subscribing to port updates...")
|
||||
|
||||
// Subscribe to port updates (this will parse and update port status)
|
||||
if err := c.subscribeToPortUpdates(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe: %w", err)
|
||||
}
|
||||
|
||||
// Request initial status for both ports
|
||||
log.Println("AntennaGenius: Requesting additional port status...")
|
||||
_, _ = c.sendCommand("port get 1") // Port A
|
||||
_, _ = c.sendCommand("port get 2") // Port B
|
||||
|
||||
c.statusMu.RLock()
|
||||
log.Printf("AntennaGenius: Initialization complete - PortA.RxAnt=%d, PortB.RxAnt=%d",
|
||||
c.lastStatus.PortA.RxAnt, c.lastStatus.PortB.RxAnt)
|
||||
c.statusMu.RUnlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// AntennaGenius only accepts C1| for all commands
|
||||
fullCmd := fmt.Sprintf("C1|%s\n", cmd)
|
||||
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read all response lines using shared reader
|
||||
var response strings.Builder
|
||||
|
||||
// Set a read timeout to avoid blocking forever
|
||||
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
defer c.conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if response.Len() > 0 {
|
||||
// We got some data, return it
|
||||
break
|
||||
}
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
response.WriteString(line)
|
||||
|
||||
// Parse spontaneous status updates
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmedLine, "S0|") {
|
||||
c.connMu.Unlock()
|
||||
c.parsePortStatus(trimmedLine)
|
||||
c.connMu.Lock()
|
||||
}
|
||||
|
||||
// Check if this is the last line (empty line or timeout)
|
||||
if trimmedLine == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return response.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) getAntennaList() ([]Antenna, error) {
|
||||
resp, err := c.sendCommand("antenna list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var antennas []Antenna
|
||||
|
||||
// Response format: R<id>|0|antenna <num> name=<name> tx=<hex> rx=<hex> inband=<hex> hotkey=<num>
|
||||
lines := strings.Split(resp, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "antenna ") {
|
||||
continue
|
||||
}
|
||||
|
||||
antenna := c.parseAntennaLine(line)
|
||||
|
||||
// Skip unconfigured antennas (name = Antenna X with space)
|
||||
if strings.HasPrefix(antenna.Name, "Antenna ") {
|
||||
continue
|
||||
}
|
||||
|
||||
antennas = append(antennas, antenna)
|
||||
}
|
||||
|
||||
return antennas, nil
|
||||
}
|
||||
|
||||
func (c *Client) parseAntennaLine(line string) Antenna {
|
||||
antenna := Antenna{}
|
||||
|
||||
// Extract antenna number
|
||||
if idx := strings.Index(line, "antenna "); idx != -1 {
|
||||
rest := line[idx+8:]
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) > 0 {
|
||||
antenna.Number, _ = strconv.Atoi(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
pairs := strings.Fields(line)
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
|
||||
switch key {
|
||||
case "name":
|
||||
// Replace underscores with spaces
|
||||
antenna.Name = strings.ReplaceAll(value, "_", " ")
|
||||
case "tx":
|
||||
antenna.TX = value
|
||||
case "rx":
|
||||
antenna.RX = value
|
||||
case "inband":
|
||||
antenna.InBand = value
|
||||
case "hotkey":
|
||||
antenna.Hotkey, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
return antenna
|
||||
}
|
||||
|
||||
func (c *Client) subscribeToPortUpdates() error {
|
||||
resp, err := c.sendCommand("sub port all")
|
||||
if err != nil {
|
||||
log.Printf("AntennaGenius: Failed to subscribe: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse initial port status from subscription response
|
||||
// The response may contain S0|port messages with current status
|
||||
lines := strings.Split(resp, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "S0|port") {
|
||||
c.parsePortStatus(line)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("AntennaGenius: Subscription complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) parsePortStatus(line string) {
|
||||
// Format: S0|port <id> auto=<0|1> source=<src> band=<n> freq=<f> nickname=<name> rxant=<n> txant=<n> inband=<n> tx=<0|1> inhibit=<n>
|
||||
|
||||
var portID int
|
||||
portStatus := &PortStatus{}
|
||||
|
||||
// Extract port ID
|
||||
if idx := strings.Index(line, "port "); idx != -1 {
|
||||
rest := line[idx+5:]
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) > 0 {
|
||||
portID, _ = strconv.Atoi(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key=value pairs
|
||||
pairs := strings.Fields(line)
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
|
||||
switch key {
|
||||
case "auto":
|
||||
portStatus.Auto = value == "1"
|
||||
case "source":
|
||||
portStatus.Source = value
|
||||
case "band":
|
||||
portStatus.Band, _ = strconv.Atoi(value)
|
||||
case "freq":
|
||||
portStatus.Frequency, _ = strconv.ParseFloat(value, 64)
|
||||
case "nickname":
|
||||
portStatus.Nickname = value
|
||||
case "rxant":
|
||||
portStatus.RxAnt, _ = strconv.Atoi(value)
|
||||
case "txant":
|
||||
portStatus.TxAnt, _ = strconv.Atoi(value)
|
||||
case "inband":
|
||||
portStatus.InBand, _ = strconv.Atoi(value)
|
||||
case "tx":
|
||||
portStatus.TX = value == "1"
|
||||
case "inhibit":
|
||||
portStatus.Inhibit, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Update status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
if portID == 1 {
|
||||
c.lastStatus.PortA = portStatus
|
||||
} else if portID == 2 {
|
||||
c.lastStatus.PortB = portStatus
|
||||
}
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
// Check if device is actually alive
|
||||
// If no antennas and all values are default, device is probably off
|
||||
status := *c.lastStatus
|
||||
if len(status.Antennas) == 0 || (status.PortA != nil && status.PortA.Source == "" && status.PortB != nil && status.PortB.Source == "") {
|
||||
status.Connected = false
|
||||
}
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// SetAntenna sets the antenna for a specific port
|
||||
func (c *Client) SetAntenna(port, antenna int) error {
|
||||
cmd := fmt.Sprintf("port set %d rxant=%d", port, antenna)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeselectAntenna deselects an antenna from a port (sets rxant=00)
|
||||
// Command format: "C1|port set <port> rxant=00"
|
||||
func (c *Client) DeselectAntenna(port, antenna int) error {
|
||||
cmd := fmt.Sprintf("port set %d rxant=00", port)
|
||||
log.Printf("AntennaGenius: Sending deselect command: %s", cmd)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
log.Printf("AntennaGenius: Deselect failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("AntennaGenius: Deselect response: %s", resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reboot reboots the device
|
||||
func (c *Client) Reboot() error {
|
||||
_, err := c.sendCommand("reboot")
|
||||
return err
|
||||
}
|
||||
|
||||
328
internal/devices/flexradio/flexradio.go
Normal file
328
internal/devices/flexradio/flexradio.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package flexradio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connMu sync.Mutex // For connection management
|
||||
writeMu sync.Mutex // For writing to connection (separate from reads)
|
||||
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
|
||||
cmdSeq int
|
||||
cmdSeqMu sync.Mutex
|
||||
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
|
||||
// Callbacks
|
||||
onFrequencyChange func(freqMHz float64)
|
||||
checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
lastStatus: &Status{
|
||||
Connected: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
log.Println("FlexRadio: Connected successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update connected status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Connected = true
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
c.running = true
|
||||
|
||||
// Start message listener
|
||||
go c.messageLoop()
|
||||
|
||||
// Subscribe to slice updates for frequency tracking
|
||||
log.Println("FlexRadio: Subscribing to slice updates...")
|
||||
_, err := c.sendCommand("sub slice all")
|
||||
if err != nil {
|
||||
log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err)
|
||||
}
|
||||
|
||||
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.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Update connected status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Connected = false
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) getNextSeq() int {
|
||||
c.cmdSeqMu.Lock()
|
||||
defer c.cmdSeqMu.Unlock()
|
||||
c.cmdSeq++
|
||||
return c.cmdSeq
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
// Use writeMu instead of connMu to avoid blocking on messageLoop reads
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
c.connMu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
seq := c.getNextSeq()
|
||||
fullCmd := fmt.Sprintf("C%d|%s\n", seq, cmd)
|
||||
|
||||
log.Printf("FlexRadio TX: %s", strings.TrimSpace(fullCmd))
|
||||
|
||||
_, err := conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.connMu.Lock()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
c.connMu.Unlock()
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *Client) messageLoop() {
|
||||
log.Println("FlexRadio: Message loop started")
|
||||
|
||||
for c.running {
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil || c.reader == nil {
|
||||
c.connMu.Unlock()
|
||||
time.Sleep(1 * time.Second)
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("FlexRadio: Reconnect failed: %v", err)
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Set read deadline to allow periodic checks
|
||||
c.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
line, err := c.reader.ReadString('\n')
|
||||
c.connMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
// Timeout is expected, continue
|
||||
continue
|
||||
}
|
||||
log.Printf("FlexRadio: Read error: %v", err)
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Update connected status
|
||||
c.statusMu.Lock()
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Connected = false
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleMessage(line)
|
||||
}
|
||||
|
||||
log.Println("FlexRadio: Message loop stopped")
|
||||
}
|
||||
|
||||
func (c *Client) handleMessage(msg string) {
|
||||
// Response format: R<seq>|<status>|<data>
|
||||
if strings.HasPrefix(msg, "R") {
|
||||
c.handleResponse(msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Status format: S<handle>|<key>=<value> ...
|
||||
if strings.HasPrefix(msg, "S") {
|
||||
c.handleStatus(msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Version/handle format: V<version>|H<handle>
|
||||
if strings.HasPrefix(msg, "V") {
|
||||
log.Printf("FlexRadio: Version/Handle received: %s", msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Message format: M<handle>|<message>
|
||||
if strings.HasPrefix(msg, "M") {
|
||||
log.Printf("FlexRadio: Message: %s", msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleResponse(msg string) {
|
||||
// Format: R<seq>|<status>|<data>
|
||||
// Example: R21|0|000000F4
|
||||
parts := strings.SplitN(msg, "|", 3)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
status := parts[1]
|
||||
if status != "0" {
|
||||
log.Printf("FlexRadio: Command error: status=%s, message=%s", status, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleStatus(msg string) {
|
||||
// Format: S<handle>|<key>=<value> ...
|
||||
parts := strings.SplitN(msg, "|", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
data := parts[1]
|
||||
|
||||
// Parse key=value pairs
|
||||
pairs := strings.Fields(data)
|
||||
statusMap := make(map[string]string)
|
||||
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
statusMap[kv[0]] = kv[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Check for slice updates (frequency changes)
|
||||
if strings.Contains(msg, "slice") {
|
||||
if rfFreq, ok := statusMap["RF_frequency"]; ok {
|
||||
freq, err := strconv.ParseFloat(rfFreq, 64)
|
||||
if err == nil {
|
||||
c.statusMu.Lock()
|
||||
oldFreq := c.lastStatus.Frequency
|
||||
c.lastStatus.Frequency = freq
|
||||
c.statusMu.Unlock()
|
||||
|
||||
// Only log significant frequency changes (> 1 kHz)
|
||||
if oldFreq == 0 || (freq-oldFreq)*(freq-oldFreq) > 0.001 {
|
||||
log.Printf("FlexRadio: Frequency updated to %.6f MHz", freq)
|
||||
|
||||
// Trigger callback for immediate auto-track
|
||||
if c.onFrequencyChange != nil {
|
||||
go c.onFrequencyChange(freq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
// Create a copy
|
||||
status := *c.lastStatus
|
||||
|
||||
// DON'T lock connMu here - it causes 4-second blocking!
|
||||
// The messageLoop updates Connected status, and we trust the cached value
|
||||
|
||||
return &status, nil
|
||||
}
|
||||
21
internal/devices/flexradio/types.go
Normal file
21
internal/devices/flexradio/types.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package flexradio
|
||||
|
||||
// Status represents the FlexRadio status
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
InterlockID string `json:"interlock_id"`
|
||||
InterlockState string `json:"interlock_state"`
|
||||
Frequency float64 `json:"frequency"` // MHz
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InterlockState represents possible interlock states
|
||||
const (
|
||||
InterlockStateReady = "READY"
|
||||
InterlockStateNotReady = "NOT_READY"
|
||||
InterlockStatePTTRequested = "PTT_REQUESTED"
|
||||
InterlockStateTransmitting = "TRANSMITTING"
|
||||
InterlockStateUnkeyRequested = "UNKEY_REQUESTED"
|
||||
)
|
||||
@@ -23,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 {
|
||||
@@ -42,7 +50,10 @@ type Status struct {
|
||||
BandB string `json:"band_b"`
|
||||
FaultPresent bool `json:"fault_present"`
|
||||
Connected bool `json:"connected"`
|
||||
Meffa string `json:"meffa"`
|
||||
|
||||
// Peak hold for display (internal)
|
||||
displayPower float64
|
||||
peakTime time.Time
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
@@ -50,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +103,17 @@ func (c *Client) Close() error {
|
||||
|
||||
// Start begins continuous polling of the device
|
||||
func (c *Client) Start() error {
|
||||
if err := c.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize connection tracking
|
||||
c.lastAliveTime = time.Now()
|
||||
|
||||
// Try to connect, but don't fail if it doesn't work
|
||||
// The poll loop will keep trying
|
||||
_ = c.Connect()
|
||||
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
@@ -112,10 +128,29 @@ func (c *Client) pollLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Try to reconnect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
// Silent fail, will retry on next tick
|
||||
continue
|
||||
}
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
log.Printf("PowerGenius query error: %v", err)
|
||||
// Try to reconnect
|
||||
// Connection lost, close and retry next tick
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
@@ -123,19 +158,104 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
log.Printf("PowerGenius reconnect failed: %v", err)
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Check if device is actually alive (not just TCP connected)
|
||||
// If voltage is 0 and temperature is 0, device might be temporarily idle
|
||||
// Use a 3-second timeout before marking as disconnected (helps with morse code pauses)
|
||||
if status.Voltage == 0 && status.Temperature == 0 {
|
||||
// Check if we've seen valid data recently (within 3 seconds)
|
||||
if time.Since(c.lastAliveTime) > 3*time.Second {
|
||||
status.Connected = false
|
||||
}
|
||||
// else: Keep Connected = true (device is probably just idle between morse letters)
|
||||
} else {
|
||||
// Valid data received, update lastAliveTime
|
||||
c.lastAliveTime = time.Now()
|
||||
}
|
||||
|
||||
// Peak hold logic - keep highest power for 1 second
|
||||
now := time.Now()
|
||||
if c.lastStatus != nil {
|
||||
// If new power is higher, update peak
|
||||
if status.PowerForward > c.lastStatus.displayPower {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
} else {
|
||||
// Check if peak has expired (1 second)
|
||||
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||
// Keep old peak
|
||||
status.displayPower = c.lastStatus.displayPower
|
||||
status.peakTime = c.lastStatus.peakTime
|
||||
} else {
|
||||
// Peak expired, use current value
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
|
||||
// Override PowerForward with display power for frontend
|
||||
status.PowerForward = status.displayPower
|
||||
|
||||
// Auto fan management based on temperature
|
||||
// Do this BEFORE merging to use the fresh temperature value
|
||||
if c.autoFanEnabled {
|
||||
// Use the temperature from the current status message
|
||||
// If it's 0, use the last known temperature
|
||||
temp := status.Temperature
|
||||
if temp == 0 && c.lastStatus != nil {
|
||||
temp = c.lastStatus.Temperature
|
||||
}
|
||||
|
||||
currentMode := strings.ToUpper(status.FanMode)
|
||||
if currentMode == "" && c.lastStatus != nil {
|
||||
currentMode = strings.ToUpper(c.lastStatus.FanMode)
|
||||
}
|
||||
|
||||
// Only act on valid temperature readings
|
||||
if temp > 5.0 { // Ignore invalid/startup readings below 5°C
|
||||
// If temp >= 60°C, switch to Broadcast
|
||||
if temp >= 60.0 && currentMode != "BROADCAST" {
|
||||
if !c.autoFanActive {
|
||||
log.Printf("PowerGenius: Temperature %.1f°C >= 60°C, switching fan to Broadcast mode", temp)
|
||||
c.autoFanActive = true
|
||||
}
|
||||
if err := c.setFanModeInternal("BROADCAST"); err != nil {
|
||||
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If temp <= 55°C, switch back to Contest
|
||||
if temp <= 55.0 && currentMode == "BROADCAST" {
|
||||
if c.autoFanActive {
|
||||
log.Printf("PowerGenius: Temperature %.1f°C <= 55°C, switching fan back to Contest mode", temp)
|
||||
c.autoFanActive = false
|
||||
}
|
||||
if err := c.setFanModeInternal("CONTEST"); err != nil {
|
||||
log.Printf("PowerGenius: Failed to set fan mode: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing status (spontaneous messages may only update some fields)
|
||||
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
|
||||
}
|
||||
@@ -304,8 +424,6 @@ func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
}
|
||||
case "vac":
|
||||
status.Voltage, _ = strconv.ParseFloat(value, 64)
|
||||
case "meffa":
|
||||
status.Meffa = value
|
||||
case "vdd":
|
||||
status.VDD, _ = strconv.ParseFloat(value, 64)
|
||||
case "id":
|
||||
@@ -370,11 +488,34 @@ 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
|
||||
}
|
||||
|
||||
// SetOperate sets the operate mode
|
||||
// value can be: 0 (STANDBY) or 1 (OPERATE)
|
||||
func (c *Client) SetOperate(value int) error {
|
||||
if value != 0 && value != 1 {
|
||||
return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("operate=%d", value)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,34 +6,25 @@ import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connMu sync.Mutex
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Rotator1 RotatorData `json:"rotator1"`
|
||||
Rotator2 RotatorData `json:"rotator2"`
|
||||
Panic bool `json:"panic"`
|
||||
}
|
||||
|
||||
type RotatorData struct {
|
||||
CurrentAzimuth int `json:"current_azimuth"`
|
||||
LimitCW int `json:"limit_cw"`
|
||||
LimitCCW int `json:"limit_ccw"`
|
||||
Configuration string `json:"configuration"` // "A" for azimuth, "E" for elevation
|
||||
Moving int `json:"moving"` // 0=stopped, 1=CW, 2=CCW
|
||||
Offset int `json:"offset"`
|
||||
TargetAzimuth int `json:"target_azimuth"`
|
||||
StartAzimuth int `json:"start_azimuth"`
|
||||
OutsideLimit bool `json:"outside_limit"`
|
||||
Name string `json:"name"`
|
||||
Heading int `json:"heading"`
|
||||
TargetHeading int `json:"target_heading"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
@@ -41,200 +32,224 @@ func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("RotatorGenius: Attempting to connect to %s:%d\n", c.host, c.port)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
fmt.Printf("RotatorGenius: Connection failed: %v\n", err)
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
|
||||
fmt.Println("RotatorGenius: Connected successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.stopChan != nil {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
func (c *Client) Start() error {
|
||||
fmt.Println("RotatorGenius Start() called")
|
||||
|
||||
if c.running {
|
||||
fmt.Println("RotatorGenius already running, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("RotatorGenius attempting initial connection...")
|
||||
_ = c.Connect()
|
||||
|
||||
c.running = true
|
||||
fmt.Println("RotatorGenius launching pollLoop...")
|
||||
go c.pollLoop()
|
||||
|
||||
fmt.Println("RotatorGenius Start() completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
return "", err
|
||||
continue
|
||||
}
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
status.Connected = true
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
func (c *Client) sendCommand(cmd string) error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
// Format command with ID: C<id>|<command>
|
||||
fullCmd := fmt.Sprintf("C%d%s", cmdID, cmd)
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Send command
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
_, err := c.conn.Write([]byte(cmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return "", fmt.Errorf("failed to send command: %w", err)
|
||||
c.reader = nil
|
||||
return fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
reader := bufio.NewReader(c.conn)
|
||||
response, err := reader.ReadString('\n')
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Send |h command
|
||||
_, err := c.conn.Write([]byte("|h"))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
c.reader = nil
|
||||
return nil, fmt.Errorf("failed to send query: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response), nil
|
||||
// Read response - RotatorGenius doesn't send newline, read fixed amount
|
||||
c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
defer c.conn.SetReadDeadline(time.Time{})
|
||||
|
||||
buf := make([]byte, 100)
|
||||
n, err := c.reader.Read(buf)
|
||||
if err != nil || n == 0 {
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
response := string(buf[:n])
|
||||
|
||||
return c.parseStatus(response), nil
|
||||
}
|
||||
|
||||
func (c *Client) parseStatus(response string) *Status {
|
||||
status := &Status{}
|
||||
|
||||
// Response format: |h2<null><heading>...
|
||||
// Example: |h2\x00183 8 10A0...
|
||||
// After |h2 there's a null byte, then 3 digits for heading
|
||||
|
||||
if !strings.HasPrefix(response, "|h2") {
|
||||
return status
|
||||
}
|
||||
|
||||
// Skip |h2 (3 chars) and null byte (1 char), then read 3 digits
|
||||
if len(response) >= 7 {
|
||||
// Position 3 is the null byte, position 4-6 are the heading
|
||||
headingStr := response[4:7]
|
||||
heading, err := strconv.Atoi(strings.TrimSpace(headingStr))
|
||||
if err == nil {
|
||||
status.Heading = heading
|
||||
}
|
||||
targetStr := response[19:22]
|
||||
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||
if err == nil {
|
||||
status.TargetHeading = targetHeading
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
resp, err := c.sendCommand("|h")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
return parseStatusResponse(resp)
|
||||
return c.lastStatus, nil
|
||||
}
|
||||
|
||||
func parseStatusResponse(resp string) (*Status, error) {
|
||||
if len(resp) < 80 {
|
||||
return nil, fmt.Errorf("response too short: %d bytes", len(resp))
|
||||
// SetHeading rotates to a specific azimuth
|
||||
func (c *Client) SetHeading(azimuth int) error {
|
||||
cmd := fmt.Sprintf("|A1%d", azimuth)
|
||||
return c.sendCommand(cmd)
|
||||
}
|
||||
|
||||
status := &Status{}
|
||||
|
||||
// Parse panic flag
|
||||
status.Panic = resp[3] != 0x00
|
||||
|
||||
// Parse Rotator 1 (positions 4-38)
|
||||
status.Rotator1 = parseRotatorData(resp[4:38])
|
||||
|
||||
// Parse Rotator 2 (positions 38-72)
|
||||
if len(resp) >= 72 {
|
||||
status.Rotator2 = parseRotatorData(resp[38:72])
|
||||
// RotateCW rotates clockwise
|
||||
func (c *Client) RotateCW() error {
|
||||
return c.sendCommand("|P1")
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func parseRotatorData(data string) RotatorData {
|
||||
rd := RotatorData{}
|
||||
|
||||
// Current azimuth (3 bytes)
|
||||
if azStr := strings.TrimSpace(data[0:3]); azStr != "999" {
|
||||
rd.CurrentAzimuth, _ = strconv.Atoi(azStr)
|
||||
rd.Connected = true
|
||||
} else {
|
||||
rd.CurrentAzimuth = 999
|
||||
rd.Connected = false
|
||||
}
|
||||
|
||||
// Limits
|
||||
rd.LimitCW, _ = strconv.Atoi(strings.TrimSpace(data[3:6]))
|
||||
rd.LimitCCW, _ = strconv.Atoi(strings.TrimSpace(data[6:9]))
|
||||
|
||||
// Configuration
|
||||
rd.Configuration = string(data[9])
|
||||
|
||||
// Moving state
|
||||
rd.Moving, _ = strconv.Atoi(string(data[10]))
|
||||
|
||||
// Offset
|
||||
rd.Offset, _ = strconv.Atoi(strings.TrimSpace(data[11:15]))
|
||||
|
||||
// Target azimuth
|
||||
if targetStr := strings.TrimSpace(data[15:18]); targetStr != "999" {
|
||||
rd.TargetAzimuth, _ = strconv.Atoi(targetStr)
|
||||
} else {
|
||||
rd.TargetAzimuth = 999
|
||||
}
|
||||
|
||||
// Start azimuth
|
||||
if startStr := strings.TrimSpace(data[18:21]); startStr != "999" {
|
||||
rd.StartAzimuth, _ = strconv.Atoi(startStr)
|
||||
} else {
|
||||
rd.StartAzimuth = 999
|
||||
}
|
||||
|
||||
// Limit flag
|
||||
rd.OutsideLimit = data[21] == '1'
|
||||
|
||||
// Name
|
||||
rd.Name = strings.TrimSpace(data[22:34])
|
||||
|
||||
return rd
|
||||
}
|
||||
|
||||
func (c *Client) MoveToAzimuth(rotator int, azimuth int) error {
|
||||
if rotator < 1 || rotator > 2 {
|
||||
return fmt.Errorf("rotator must be 1 or 2")
|
||||
}
|
||||
if azimuth < 0 || azimuth > 360 {
|
||||
return fmt.Errorf("azimuth must be between 0 and 360")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("|A%d%03d", rotator, azimuth)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RotateCW(rotator int) error {
|
||||
if rotator < 1 || rotator > 2 {
|
||||
return fmt.Errorf("rotator must be 1 or 2")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("|P%d", rotator)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RotateCCW(rotator int) error {
|
||||
if rotator < 1 || rotator > 2 {
|
||||
return fmt.Errorf("rotator must be 1 or 2")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("|M%d", rotator)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
// RotateCCW rotates counter-clockwise
|
||||
func (c *Client) RotateCCW() error {
|
||||
return c.sendCommand("|M1")
|
||||
}
|
||||
|
||||
// Stop stops rotation
|
||||
func (c *Client) Stop() error {
|
||||
resp, err := c.sendCommand("|S")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(resp, "K") {
|
||||
return fmt.Errorf("command failed: %s", resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
return c.sendCommand("|S")
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package tunergenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "git.rouggy.com/rouggy/ShackMaster/internal/devices"
|
||||
@@ -14,52 +17,233 @@ type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
connMu sync.Mutex
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||
Bypass bool `json:"bypass"` // Bypass mode
|
||||
ActiveAntenna int `json:"active_antenna"` // 0=ANT1, 1=ANT2, 2=ANT3
|
||||
TuningStatus string `json:"tuning_status"`
|
||||
FrequencyA float64 `json:"frequency_a"`
|
||||
FrequencyB float64 `json:"frequency_b"`
|
||||
C1 int `json:"c1"`
|
||||
L int `json:"l"`
|
||||
C2 int `json:"c2"`
|
||||
PowerForward float64 `json:"power_forward"`
|
||||
PowerPeak float64 `json:"power_peak"`
|
||||
PowerMax float64 `json:"power_max"`
|
||||
SWR float64 `json:"swr"`
|
||||
Power float64 `json:"power"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
PTTA int `json:"ptt_a"`
|
||||
BandA int `json:"band_a"`
|
||||
FreqA float64 `json:"frequency_a"`
|
||||
BypassA bool `json:"bypass_a"`
|
||||
AntA int `json:"antenna_a"`
|
||||
PTTB int `json:"ptt_b"`
|
||||
BandB int `json:"band_b"`
|
||||
FreqB float64 `json:"frequency_b"`
|
||||
BypassB bool `json:"bypass_b"`
|
||||
AntB int `json:"antenna_b"`
|
||||
State int `json:"state"`
|
||||
Active int `json:"active"`
|
||||
Tuning int `json:"tuning"`
|
||||
Bypass bool `json:"bypass"`
|
||||
RelayC1 int `json:"c1"`
|
||||
RelayL int `json:"l"`
|
||||
RelayC2 int `json:"c2"`
|
||||
TuningStatus string `json:"tuning_status"`
|
||||
Connected bool `json:"connected"`
|
||||
|
||||
// Peak hold for display (internal)
|
||||
displayPower float64
|
||||
peakTime time.Time
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
return nil // Already connected
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
|
||||
// Read and discard version banner
|
||||
reader := bufio.NewReader(c.conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.stopChan != nil {
|
||||
close(c.stopChan)
|
||||
}
|
||||
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
if err := c.Connect(); err != nil {
|
||||
return "", err
|
||||
// Start begins continuous polling of the device
|
||||
func (c *Client) Start() error {
|
||||
if c.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to connect, but don't fail if it doesn't work
|
||||
// The poll loop will keep trying
|
||||
_ = c.Connect()
|
||||
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollLoop continuously polls the device for status
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// Try to reconnect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
|
||||
if err := c.Connect(); err != nil {
|
||||
// Silent fail, will retry on next tick
|
||||
continue
|
||||
}
|
||||
c.connMu.Lock()
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
// Connection lost, close and retry next tick
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected and reset all values
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{
|
||||
Connected: false,
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Check if device is actually alive
|
||||
// If all frequencies are 0, device is probably off
|
||||
if status.FreqA == 0 && status.FreqB == 0 && status.PowerForward == 0 {
|
||||
status.Connected = false
|
||||
}
|
||||
|
||||
// Peak hold logic - keep highest power for 1 second
|
||||
now := time.Now()
|
||||
if c.lastStatus != nil {
|
||||
// If new power is higher, update peak
|
||||
if status.PowerForward > c.lastStatus.displayPower {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
} else {
|
||||
// Check if peak has expired (1 second)
|
||||
if now.Sub(c.lastStatus.peakTime) < 1*time.Second {
|
||||
// Keep old peak
|
||||
status.displayPower = c.lastStatus.displayPower
|
||||
status.peakTime = c.lastStatus.peakTime
|
||||
} else {
|
||||
// Peak expired, use current value
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status.displayPower = status.PowerForward
|
||||
status.peakTime = now
|
||||
}
|
||||
|
||||
// Override PowerForward with display power for frontend
|
||||
status.PowerForward = status.displayPower
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
cmdID := GetGlobalCommandID().GetNextID()
|
||||
|
||||
// Format command with ID: C<id>|status get
|
||||
fullCmd := fmt.Sprintf("C%d|status get\n", cmdID)
|
||||
|
||||
// Send command
|
||||
_, err := c.conn.Write([]byte(fullCmd))
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return nil, fmt.Errorf("failed to send command: %w", err)
|
||||
}
|
||||
|
||||
// Read response
|
||||
reader := bufio.NewReader(c.conn)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
c.conn = nil
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return c.parseStatus(strings.TrimSpace(response))
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Get next command ID from global counter
|
||||
@@ -87,119 +271,156 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
resp, err := c.sendCommand("status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
// Parse the response - format will depend on actual device response
|
||||
// This is a placeholder that should be updated based on real response format
|
||||
return c.lastStatus, nil
|
||||
}
|
||||
|
||||
func (c *Client) parseStatus(resp string) (*Status, error) {
|
||||
status := &Status{
|
||||
Connected: true,
|
||||
}
|
||||
|
||||
// TODO: Parse actual status response from device
|
||||
// The response format needs to be determined from real device testing
|
||||
// For now, we just check if we got a response
|
||||
_ = resp // Temporary: will be used when we parse the actual response format
|
||||
// Response format: S<id>|status fwd=21.19 peak=21.55 ...
|
||||
// Extract the data part after "S<id>|status "
|
||||
idx := strings.Index(resp, "|status ")
|
||||
if idx == -1 {
|
||||
return nil, fmt.Errorf("invalid response format: %s", resp)
|
||||
}
|
||||
|
||||
data := resp[idx+8:] // Skip "|status "
|
||||
|
||||
// Parse key=value pairs separated by spaces
|
||||
pairs := strings.Fields(data)
|
||||
|
||||
for _, pair := range pairs {
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := kv[0]
|
||||
value := kv[1]
|
||||
|
||||
switch key {
|
||||
case "fwd":
|
||||
// fwd is in dBm (e.g., 42.62 dBm)
|
||||
// Formula: watts = 10^(dBm/10) / 1000
|
||||
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
milliwatts := math.Pow(10, dBm/10.0)
|
||||
status.PowerForward = milliwatts / 1000.0
|
||||
}
|
||||
case "peak":
|
||||
// peak power in dBm
|
||||
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
milliwatts := math.Pow(10, dBm/10.0)
|
||||
status.PowerPeak = milliwatts / 1000.0
|
||||
}
|
||||
case "max":
|
||||
if dBm, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
milliwatts := math.Pow(10, dBm/10.0)
|
||||
status.PowerMax = milliwatts / 1000.0
|
||||
}
|
||||
case "swr":
|
||||
// SWR from return loss
|
||||
// Formula: returnLoss = abs(swr) / 20
|
||||
// swr = (10^returnLoss + 1) / (10^returnLoss - 1)
|
||||
if swrRaw, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
returnLoss := math.Abs(swrRaw) / 20.0
|
||||
tenPowRL := math.Pow(10, returnLoss)
|
||||
calculatedSWR := (tenPowRL + 1) / (tenPowRL - 1)
|
||||
status.SWR = calculatedSWR
|
||||
}
|
||||
case "pttA":
|
||||
status.PTTA, _ = strconv.Atoi(value)
|
||||
case "bandA":
|
||||
status.BandA, _ = strconv.Atoi(value)
|
||||
case "freqA":
|
||||
status.FreqA, _ = strconv.ParseFloat(value, 64)
|
||||
case "bypassA":
|
||||
status.BypassA = value == "1"
|
||||
case "antA":
|
||||
status.AntA, _ = strconv.Atoi(value)
|
||||
case "pttB":
|
||||
status.PTTB, _ = strconv.Atoi(value)
|
||||
case "bandB":
|
||||
status.BandB, _ = strconv.Atoi(value)
|
||||
case "freqB":
|
||||
status.FreqB, _ = strconv.ParseFloat(value, 64)
|
||||
case "bypassB":
|
||||
status.BypassB = value == "1"
|
||||
case "antB":
|
||||
status.AntB, _ = strconv.Atoi(value)
|
||||
case "state":
|
||||
status.State, _ = strconv.Atoi(value)
|
||||
case "active":
|
||||
status.Active, _ = strconv.Atoi(value)
|
||||
case "tuning":
|
||||
status.Tuning, _ = strconv.Atoi(value)
|
||||
if status.Tuning == 1 {
|
||||
status.TuningStatus = "TUNING"
|
||||
} else {
|
||||
status.TuningStatus = "READY"
|
||||
}
|
||||
case "bypass":
|
||||
status.Bypass = value == "1"
|
||||
case "relayC1":
|
||||
status.RelayC1, _ = strconv.Atoi(value)
|
||||
case "relayL":
|
||||
status.RelayL, _ = strconv.Atoi(value)
|
||||
case "relayC2":
|
||||
status.RelayC2, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetOperate(operate bool) error {
|
||||
var state int
|
||||
if operate {
|
||||
state = 1
|
||||
// SetOperate switches between STANDBY (0) and OPERATE (1)
|
||||
func (c *Client) SetOperate(value int) error {
|
||||
if value != 0 && value != 1 {
|
||||
return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("operate set=%d", state)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
cmd := fmt.Sprintf("operate set=%d", value)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
// SetBypass sets BYPASS mode
|
||||
func (c *Client) SetBypass(value int) error {
|
||||
if value != 0 && value != 1 {
|
||||
return fmt.Errorf("invalid bypass value: %d (must be 0 or 1)", value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetBypass(bypass bool) error {
|
||||
var state int
|
||||
if bypass {
|
||||
state = 1
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("bypass set=%d", state)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
cmd := fmt.Sprintf("bypass set=%d", value)
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ActivateAntenna(antenna int) error {
|
||||
if antenna < 0 || antenna > 2 {
|
||||
return fmt.Errorf("antenna must be 0 (ANT1), 1 (ANT2), or 2 (ANT3)")
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("activate ant=%d", antenna)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoTune starts a tuning cycle
|
||||
func (c *Client) AutoTune() error {
|
||||
resp, err := c.sendCommand("autotune")
|
||||
if err != nil {
|
||||
_, err := c.sendCommand("autotune")
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TuneRelay adjusts tuning parameters manually
|
||||
// TuneRelay adjusts one tuning parameter by one step
|
||||
// relay: 0=C1, 1=L, 2=C2
|
||||
// move: -1 to decrease, 1 to increase
|
||||
func (c *Client) TuneRelay(relay int, move int) error {
|
||||
// move: -1 (decrease) or 1 (increase)
|
||||
func (c *Client) TuneRelay(relay, move int) error {
|
||||
if relay < 0 || relay > 2 {
|
||||
return fmt.Errorf("relay must be 0 (C1), 1 (L), or 2 (C2)")
|
||||
return fmt.Errorf("invalid relay: %d (must be 0, 1, or 2)", relay)
|
||||
}
|
||||
if move != -1 && move != 1 {
|
||||
return fmt.Errorf("move must be -1 or 1")
|
||||
return fmt.Errorf("invalid move: %d (must be -1 or 1)", move)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move)
|
||||
resp, err := c.sendCommand(cmd)
|
||||
if err != nil {
|
||||
_, err := c.sendCommand(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if command was successful
|
||||
if resp == "" {
|
||||
return fmt.Errorf("empty response from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
448
internal/devices/ultrabeam/ultrabeam.go
Normal file
448
internal/devices/ultrabeam/ultrabeam.go
Normal file
@@ -0,0 +1,448 @@
|
||||
package ultrabeam
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Protocol constants
|
||||
const (
|
||||
STX byte = 0xF5 // 245 decimal
|
||||
ETX byte = 0xFA // 250 decimal
|
||||
DLE byte = 0xF6 // 246 decimal
|
||||
)
|
||||
|
||||
// Command codes
|
||||
const (
|
||||
CMD_STATUS byte = 1 // General status query
|
||||
CMD_RETRACT byte = 2 // Retract elements
|
||||
CMD_FREQ byte = 3 // Change frequency
|
||||
CMD_READ_BANDS byte = 9 // Read current band adjustments
|
||||
CMD_PROGRESS byte = 10 // Read progress bar
|
||||
CMD_MODIFY_ELEM byte = 12 // Modify element length
|
||||
)
|
||||
|
||||
// Reply codes
|
||||
const (
|
||||
UB_OK byte = 0 // Normal execution
|
||||
UB_BAD byte = 1 // Invalid command
|
||||
UB_PAR byte = 2 // Bad parameters
|
||||
UB_ERR byte = 3 // Error executing command
|
||||
)
|
||||
|
||||
// Direction modes
|
||||
const (
|
||||
DIR_NORMAL byte = 0
|
||||
DIR_180 byte = 1
|
||||
DIR_BIDIR byte = 2
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
conn net.Conn
|
||||
connMu sync.Mutex
|
||||
reader *bufio.Reader
|
||||
lastStatus *Status
|
||||
statusMu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
running bool
|
||||
seqNum byte
|
||||
seqMu sync.Mutex
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
FirmwareMinor int `json:"firmware_minor"`
|
||||
FirmwareMajor int `json:"firmware_major"`
|
||||
CurrentOperation int `json:"current_operation"`
|
||||
Frequency int `json:"frequency"` // KHz
|
||||
Band int `json:"band"`
|
||||
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||
OffState bool `json:"off_state"`
|
||||
MotorsMoving int `json:"motors_moving"` // Bitmask
|
||||
FreqMin int `json:"freq_min"` // MHz
|
||||
FreqMax int `json:"freq_max"` // MHz
|
||||
ElementLengths []int `json:"element_lengths"` // mm
|
||||
ProgressTotal int `json:"progress_total"` // mm
|
||||
ProgressCurrent int `json:"progress_current"` // 0-60
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
return &Client{
|
||||
host: host,
|
||||
port: port,
|
||||
stopChan: make(chan struct{}),
|
||||
seqNum: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
c.running = true
|
||||
go c.pollLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Stop() {
|
||||
if !c.running {
|
||||
return
|
||||
}
|
||||
c.running = false
|
||||
close(c.stopChan)
|
||||
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
||||
defer ticker.Stop()
|
||||
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
|
||||
// Try to connect if not connected
|
||||
c.connMu.Lock()
|
||||
if c.conn == nil {
|
||||
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("Ultrabeam: Connection failed: %v", err)
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(c.conn)
|
||||
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Query status
|
||||
status, err := c.queryStatus()
|
||||
if err != nil {
|
||||
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
||||
// Close connection and retry
|
||||
c.connMu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
c.reader = nil
|
||||
}
|
||||
c.connMu.Unlock()
|
||||
|
||||
// Mark as disconnected
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = &Status{Connected: false}
|
||||
c.statusMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as connected
|
||||
status.Connected = true
|
||||
|
||||
// Query progress if motors moving
|
||||
if status.MotorsMoving != 0 {
|
||||
progress, err := c.queryProgress()
|
||||
if err == nil {
|
||||
status.ProgressTotal = progress[0]
|
||||
status.ProgressCurrent = progress[1]
|
||||
}
|
||||
} else {
|
||||
// Motors stopped - reset progress
|
||||
status.ProgressTotal = 0
|
||||
status.ProgressCurrent = 0
|
||||
}
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
case <-c.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus() (*Status, error) {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
|
||||
if c.lastStatus == nil {
|
||||
return &Status{Connected: false}, nil
|
||||
}
|
||||
|
||||
return c.lastStatus, nil
|
||||
}
|
||||
|
||||
// getNextSeq returns the next sequence number
|
||||
func (c *Client) getNextSeq() byte {
|
||||
c.seqMu.Lock()
|
||||
defer c.seqMu.Unlock()
|
||||
|
||||
seq := c.seqNum
|
||||
c.seqNum = (c.seqNum + 1) % 128
|
||||
return seq
|
||||
}
|
||||
|
||||
// calculateChecksum calculates the checksum for a packet
|
||||
func calculateChecksum(data []byte) byte {
|
||||
chk := byte(0x55)
|
||||
for _, b := range data {
|
||||
chk ^= b
|
||||
chk++
|
||||
}
|
||||
return chk
|
||||
}
|
||||
|
||||
// quoteByte handles DLE escaping
|
||||
func quoteByte(b byte) []byte {
|
||||
if b == STX || b == ETX || b == DLE {
|
||||
return []byte{DLE, b & 0x7F} // Clear MSB
|
||||
}
|
||||
return []byte{b}
|
||||
}
|
||||
|
||||
// buildPacket creates a complete packet with checksum and escaping
|
||||
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
||||
seq := c.getNextSeq()
|
||||
|
||||
// Calculate checksum on unquoted data
|
||||
payload := append([]byte{seq, cmd}, data...)
|
||||
chk := calculateChecksum(payload)
|
||||
|
||||
// Build packet with quoting
|
||||
packet := []byte{STX}
|
||||
|
||||
// Add quoted SEQ
|
||||
packet = append(packet, quoteByte(seq)...)
|
||||
|
||||
// Add quoted CMD
|
||||
packet = append(packet, quoteByte(cmd)...)
|
||||
|
||||
// Add quoted data
|
||||
for _, b := range data {
|
||||
packet = append(packet, quoteByte(b)...)
|
||||
}
|
||||
|
||||
// Add quoted checksum
|
||||
packet = append(packet, quoteByte(chk)...)
|
||||
|
||||
// Add ETX
|
||||
packet = append(packet, ETX)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
// parsePacket parses a received packet, handling DLE unescaping
|
||||
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
|
||||
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
|
||||
return 0, 0, nil, fmt.Errorf("packet too short")
|
||||
}
|
||||
|
||||
if data[0] != STX {
|
||||
return 0, 0, nil, fmt.Errorf("missing STX")
|
||||
}
|
||||
|
||||
if data[len(data)-1] != ETX {
|
||||
return 0, 0, nil, fmt.Errorf("missing ETX")
|
||||
}
|
||||
|
||||
// Unquote the data
|
||||
var unquoted []byte
|
||||
dle := false
|
||||
for i := 1; i < len(data)-1; i++ {
|
||||
b := data[i]
|
||||
if b == DLE {
|
||||
dle = true
|
||||
continue
|
||||
}
|
||||
if dle {
|
||||
b |= 0x80 // Set MSB
|
||||
dle = false
|
||||
}
|
||||
unquoted = append(unquoted, b)
|
||||
}
|
||||
|
||||
if len(unquoted) < 3 {
|
||||
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
|
||||
}
|
||||
|
||||
seq = unquoted[0]
|
||||
cmd = unquoted[1]
|
||||
chk := unquoted[len(unquoted)-1]
|
||||
payload = unquoted[2 : len(unquoted)-1]
|
||||
|
||||
// Verify checksum
|
||||
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
|
||||
if calcChk != chk {
|
||||
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
|
||||
}
|
||||
|
||||
return seq, cmd, payload, nil
|
||||
}
|
||||
|
||||
// sendCommand sends a command and waits for reply
|
||||
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
|
||||
if c.conn == nil || c.reader == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Build and send packet
|
||||
packet := c.buildPacket(cmd, data)
|
||||
|
||||
_, err := c.conn.Write(packet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write: %w", err)
|
||||
}
|
||||
|
||||
// Read reply with timeout
|
||||
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
|
||||
|
||||
// Read until we get a complete packet
|
||||
var buffer []byte
|
||||
for {
|
||||
b, err := c.reader.ReadByte()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read: %w", err)
|
||||
}
|
||||
|
||||
buffer = append(buffer, b)
|
||||
|
||||
// Check if we have a complete packet
|
||||
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
||||
break
|
||||
}
|
||||
|
||||
// Prevent infinite loop
|
||||
if len(buffer) > 256 {
|
||||
return nil, fmt.Errorf("packet too long")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse reply
|
||||
_, replyCmd, payload, err := parsePacket(buffer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
||||
}
|
||||
|
||||
// Log for debugging unknown codes
|
||||
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
|
||||
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
switch replyCmd {
|
||||
case UB_BAD:
|
||||
return nil, fmt.Errorf("invalid command")
|
||||
case UB_PAR:
|
||||
return nil, fmt.Errorf("bad parameters")
|
||||
case UB_ERR:
|
||||
return nil, fmt.Errorf("execution error")
|
||||
case UB_OK:
|
||||
return payload, nil
|
||||
default:
|
||||
// Unknown codes might indicate "busy" or "in progress"
|
||||
// Treat as non-fatal, return empty payload
|
||||
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
|
||||
return []byte{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// queryStatus queries general status (command 1)
|
||||
func (c *Client) queryStatus() (*Status, error) {
|
||||
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(reply) < 12 {
|
||||
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
|
||||
}
|
||||
|
||||
status := &Status{
|
||||
FirmwareMinor: int(reply[0]),
|
||||
FirmwareMajor: int(reply[1]),
|
||||
CurrentOperation: int(reply[2]),
|
||||
Frequency: int(reply[3]) | (int(reply[4]) << 8),
|
||||
Band: int(reply[5]),
|
||||
Direction: int(reply[6] & 0x0F),
|
||||
OffState: (reply[7] & 0x02) != 0,
|
||||
MotorsMoving: int(reply[9]),
|
||||
FreqMin: int(reply[10]),
|
||||
FreqMax: int(reply[11]),
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// queryProgress queries motor progress (command 10)
|
||||
func (c *Client) queryProgress() ([]int, error) {
|
||||
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(reply) < 4 {
|
||||
return nil, fmt.Errorf("progress reply too short")
|
||||
}
|
||||
|
||||
total := int(reply[0]) | (int(reply[1]) << 8)
|
||||
current := int(reply[2]) | (int(reply[3]) << 8)
|
||||
|
||||
return []int{total, current}, nil
|
||||
}
|
||||
|
||||
// SetFrequency changes frequency and optional direction (command 3)
|
||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
data := []byte{
|
||||
byte(freqKhz & 0xFF),
|
||||
byte((freqKhz >> 8) & 0xFF),
|
||||
byte(direction),
|
||||
}
|
||||
|
||||
_, err := c.sendCommand(CMD_FREQ, data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retract retracts all elements (command 2)
|
||||
func (c *Client) Retract() error {
|
||||
_, err := c.sendCommand(CMD_RETRACT, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ModifyElement modifies element length (command 12)
|
||||
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||
if elementNum < 0 || elementNum > 5 {
|
||||
return fmt.Errorf("invalid element number: %d", elementNum)
|
||||
}
|
||||
|
||||
data := []byte{
|
||||
byte(elementNum),
|
||||
0, // Reserved
|
||||
byte(lengthMm & 0xFF),
|
||||
byte((lengthMm >> 8) & 0xFF),
|
||||
}
|
||||
|
||||
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
||||
return err
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Client struct {
|
||||
|
||||
type Status struct {
|
||||
Relays []RelayState `json:"relays"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
type RelayState struct {
|
||||
@@ -67,20 +68,56 @@ func (c *Client) TurnOff(relay int) error {
|
||||
}
|
||||
|
||||
func (c *Client) AllOn() error {
|
||||
for i := 1; i <= 5; i++ {
|
||||
if err := c.TurnOn(i); err != nil {
|
||||
return fmt.Errorf("failed to turn on relay %d: %w", i, err)
|
||||
// Sequence for ALL ON:
|
||||
// 1. Turn on relays 1, 2, 3, 5 immediately
|
||||
// 2. Wait 5 seconds
|
||||
// 3. Turn on relay 4 (Flex Radio Start)
|
||||
|
||||
// Turn on relays 1, 2, 3, 5
|
||||
for _, relay := range []int{1, 2, 3, 5} {
|
||||
if err := c.TurnOn(relay); err != nil {
|
||||
return fmt.Errorf("failed to turn on relay %d: %w", relay, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 5 seconds for power supply to stabilize
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Turn on relay 4 (Flex Radio)
|
||||
if err := c.TurnOn(4); err != nil {
|
||||
return fmt.Errorf("failed to turn on relay 4: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) AllOff() error {
|
||||
for i := 1; i <= 5; i++ {
|
||||
if err := c.TurnOff(i); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay %d: %w", i, err)
|
||||
// Sequence for ALL OFF:
|
||||
// 1. Turn off relay 4 (Flex Radio) immediately
|
||||
// 2. Turn off relays 2, 3, 5 immediately
|
||||
// 3. Wait 35 seconds for Flex Radio to shut down
|
||||
// 4. Turn off relay 1 (Power Supply)
|
||||
|
||||
// Turn off relay 4 (Flex Radio)
|
||||
if err := c.TurnOff(4); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay 4: %w", err)
|
||||
}
|
||||
|
||||
// Turn off relays 2, 3, 5
|
||||
for _, relay := range []int{2, 3, 5} {
|
||||
if err := c.TurnOff(relay); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay %d: %w", relay, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 35 seconds for Flex Radio to shut down properly
|
||||
time.Sleep(35 * time.Second)
|
||||
|
||||
// Turn off relay 1 (Power Supply)
|
||||
if err := c.TurnOff(1); err != nil {
|
||||
return fmt.Errorf("failed to turn off relay 1: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -106,6 +143,7 @@ func (c *Client) GetStatus() (*Status, error) {
|
||||
// Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n"
|
||||
status := &Status{
|
||||
Relays: make([]RelayState, 0, 5),
|
||||
Connected: true,
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ShackMaster - XV9Q Shack</title>
|
||||
<title>ShackMaster - F4BPO Shack</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.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 {
|
||||
|
||||
478
web/src/app.css
478
web/src/app.css
@@ -1,128 +1,438 @@
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2a2a2a;
|
||||
--bg-card: #333333;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--accent-teal: #00bcd4;
|
||||
--accent-green: #4caf50;
|
||||
--accent-red: #f44336;
|
||||
/* Modern dark theme inspired by FlexDXCluster */
|
||||
--bg-primary: #0a1628;
|
||||
--bg-secondary: #1a2332;
|
||||
--bg-tertiary: #243447;
|
||||
--bg-hover: #2a3f5f;
|
||||
|
||||
--text-primary: #e0e6ed;
|
||||
--text-secondary: #a0aec0;
|
||||
--text-muted: #718096;
|
||||
|
||||
--accent-cyan: #4fc3f7;
|
||||
--accent-blue: #2196f3;
|
||||
--border-color: #444444;
|
||||
--accent-green: #4caf50;
|
||||
--accent-orange: #ff9800;
|
||||
--accent-red: #f44336;
|
||||
--accent-purple: #9c27b0;
|
||||
--accent-yellow: #ffc107;
|
||||
|
||||
--border-color: #2d3748;
|
||||
--border-light: #374151;
|
||||
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--card-radius: 6px;
|
||||
|
||||
--header-height: 56px;
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
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);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
/* ==================== HEADER ==================== */
|
||||
header {
|
||||
height: var(--header-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
.header-left h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background: var(--accent-red);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background-color: var(--accent-green);
|
||||
.status-indicator.status-online {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background-color: var(--text-secondary);
|
||||
.header-center {
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.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;
|
||||
.solar-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
.solar-item {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #45a049;
|
||||
.solar-item .value {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.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 {
|
||||
.weather-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.clock .time {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clock .date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ==================== MAIN CONTENT ==================== */
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ==================== CARDS ==================== */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--card-radius);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card h2::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
/* ==================== LABELS & VALUES ==================== */
|
||||
.label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ==================== BUTTONS ==================== */
|
||||
button, .button {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
button:hover, .button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
button:active, .button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #29b6f6;
|
||||
border-color: #29b6f6;
|
||||
}
|
||||
|
||||
button.success {
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==================== SELECT ==================== */
|
||||
select {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: var(--spacing-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* ==================== BADGES ==================== */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge.green {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.badge.red {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.badge.orange {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.badge.cyan {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.badge.purple {
|
||||
background: rgba(156, 39, 176, 0.2);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
/* ==================== PROGRESS BARS ==================== */
|
||||
.bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-orange), var(--accent-red));
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* ==================== METRICS ==================== */
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.metric.small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ==================== SCROLLBAR ==================== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ==================== RESPONSIVE ==================== */
|
||||
@media (max-width: 1400px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: var(--spacing-sm);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -3,127 +3,337 @@
|
||||
|
||||
export let status;
|
||||
|
||||
$: radio1Antenna = status?.radio1_antenna || 0;
|
||||
$: radio2Antenna = status?.radio2_antenna || 0;
|
||||
$: connected = status?.connected || false;
|
||||
$: portA = status?.port_a || {};
|
||||
$: portB = status?.port_b || {};
|
||||
$: antennas = status?.antennas || [];
|
||||
|
||||
async function setRadioAntenna(radio, antenna) {
|
||||
// Band names
|
||||
const bandNames = {
|
||||
0: '160M', 1: '80M', 2: '60M', 3: '40M', 4: '30M',
|
||||
5: '20M', 6: '17M', 7: '15M', 8: '12M', 9: '10M',
|
||||
10: '6M', 11: '4M', 12: '2M', 13: '1.25M', 14: '70CM', 15: 'GEN'
|
||||
};
|
||||
|
||||
$: bandAName = bandNames[portA.band] || 'None';
|
||||
$: bandBName = bandNames[portB.band] || 'None';
|
||||
|
||||
async function selectAntenna(port, antennaNum) {
|
||||
try {
|
||||
await api.antenna.set(radio, antenna);
|
||||
// Check if antenna is already selected on this port
|
||||
const isAlreadySelected = (port === 1 && portA.rx_ant === antennaNum) ||
|
||||
(port === 2 && portB.rx_ant === antennaNum);
|
||||
|
||||
if (isAlreadySelected) {
|
||||
// Deselect: set rxant to 00
|
||||
console.log(`Deselecting antenna ${antennaNum} from port ${port}`);
|
||||
await api.antenna.deselectAntenna(port, antennaNum);
|
||||
} else {
|
||||
// Select normally
|
||||
console.log(`Selecting antenna ${antennaNum} on port ${port}`);
|
||||
await api.antenna.selectAntenna(port, antennaNum);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to set antenna:', err);
|
||||
console.error('Failed to select/deselect antenna:', err);
|
||||
// No popup, just log the error
|
||||
}
|
||||
}
|
||||
|
||||
// Debug TX state - only log when tx state changes, not on every update
|
||||
let lastTxStateA = false;
|
||||
let lastTxStateB = false;
|
||||
$: if (status && (portA.tx !== lastTxStateA || portB.tx !== lastTxStateB)) {
|
||||
console.log('AntennaGenius TX state changed:', {
|
||||
portA_tx: portA.tx,
|
||||
portB_tx: portB.tx,
|
||||
portA_tx_ant: portA.tx_ant,
|
||||
portB_tx_ant: portB.tx_ant
|
||||
});
|
||||
lastTxStateA = portA.tx;
|
||||
lastTxStateB = portB.tx;
|
||||
}
|
||||
|
||||
async function reboot() {
|
||||
if (!confirm('Are you sure you want to reboot the Antenna Genius?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.antenna.reboot();
|
||||
console.log('Antenna Genius reboot command sent');
|
||||
} catch (err) {
|
||||
console.error('Failed to reboot:', err);
|
||||
// No popup, just log
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="antenna-card card">
|
||||
<h2>
|
||||
AG 8X2
|
||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
||||
</h2>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Antenna Genius</h2>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
|
||||
<div class="radio-section">
|
||||
<div class="radio-label">Radio 1 / Radio 2</div>
|
||||
<div class="metrics">
|
||||
<!-- Radio Sources -->
|
||||
<div class="sources">
|
||||
<div class="source-item">
|
||||
<div class="source-label">{portA.source || 'FLEX'}</div>
|
||||
</div>
|
||||
<div class="source-item">
|
||||
<div class="source-label">{portB.source || 'FLEX'}</div>
|
||||
</div>
|
||||
</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)}
|
||||
<!-- Bands -->
|
||||
<div class="bands">
|
||||
<div class="band-item">
|
||||
<div class="band-value">{bandAName}</div>
|
||||
</div>
|
||||
<div class="band-item">
|
||||
<div class="band-value">{bandBName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Antennas -->
|
||||
<div class="antennas">
|
||||
{#each antennas as antenna}
|
||||
{@const isPortATx = portA.tx && portA.tx_ant === antenna.number}
|
||||
{@const isPortBTx = portB.tx && portB.tx_ant === antenna.number}
|
||||
{@const isPortARx = !portA.tx && portA.rx_ant === antenna.number}
|
||||
{@const isPortBRx = !portB.tx && portB.rx_ant === antenna.number}
|
||||
{@const isTx = isPortATx || isPortBTx}
|
||||
{@const isActiveA = isPortARx || isPortATx}
|
||||
{@const isActiveB = isPortBRx || isPortBTx}
|
||||
|
||||
<div
|
||||
class="antenna-card"
|
||||
class:tx={isTx}
|
||||
class:active-a={isActiveA}
|
||||
class:active-b={isActiveB}
|
||||
>
|
||||
{i + 1}
|
||||
<div class="antenna-name">{antenna.name}</div>
|
||||
<div class="antenna-ports">
|
||||
<button
|
||||
class="port-btn"
|
||||
class:active={isActiveA}
|
||||
on:click={() => selectAntenna(1, antenna.number)}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<button
|
||||
class="port-btn"
|
||||
class:active={isActiveB}
|
||||
on:click={() => selectAntenna(2, antenna.number)}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</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}
|
||||
<!-- Reboot Button -->
|
||||
<button class="reboot-btn" on:click={reboot}>
|
||||
<span class="reboot-icon">🔄</span>
|
||||
REBOOT
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.antenna-card {
|
||||
min-width: 300px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
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;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.antenna-slots {
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Sources */
|
||||
.sources {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
.source-item {
|
||||
padding: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.source-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Bands */
|
||||
.bands {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.band-item {
|
||||
padding: 10px;
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.band-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Antennas */
|
||||
.antennas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.antenna-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.antenna-card.active-a {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 20px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.antenna-card.active-b {
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
border-color: #2196f3;
|
||||
box-shadow: 0 0 20px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
/* TX must come AFTER active-a/active-b to override */
|
||||
.antenna-card.tx {
|
||||
background: rgba(244, 67, 54, 0.2) !important;
|
||||
border-color: #f44336 !important;
|
||||
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4) !important;
|
||||
}
|
||||
|
||||
.antenna-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.slot:hover {
|
||||
border-color: var(--accent-blue);
|
||||
.antenna-ports {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slot.active {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
.port-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.port-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.port-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
/* Reboot Button */
|
||||
.reboot-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #ff9800, #f57c00);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.reboot-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(255, 152, 0, 0.5);
|
||||
}
|
||||
|
||||
.reboot-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.reboot-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
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;
|
||||
@@ -15,195 +18,430 @@ $: 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';
|
||||
|
||||
// Color functions
|
||||
$: tempColor = temperature < 40 ? '#4caf50' : temperature < 60 ? '#ffc107' : temperature < 75 ? '#ff9800' : '#f44336';
|
||||
$: swrColor = swr < 1.5 ? '#4caf50' : swr < 2.0 ? '#ffc107' : swr < 3.0 ? '#ff9800' : '#f44336';
|
||||
$: powerPercent = Math.min((powerForward / 2000) * 100, 100);
|
||||
|
||||
async function setFanMode(mode) {
|
||||
try {
|
||||
await api.power.setFanMode(mode);
|
||||
} catch (err) {
|
||||
console.error('Failed to set fan mode:', err);
|
||||
alert('Failed to set fan mode');
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOperate() {
|
||||
try {
|
||||
const operateValue = state === 'IDLE' ? 0 : 1;
|
||||
await api.power.setOperate(operateValue);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle operate:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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')}>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Power Genius XL</h2>
|
||||
<div class="header-right">
|
||||
<button
|
||||
class="state-badge"
|
||||
class:idle={state === 'IDLE'}
|
||||
class:transmit={state.includes('TRANSMIT')}
|
||||
on:click={toggleOperate}
|
||||
>
|
||||
{displayState}
|
||||
</button>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</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>
|
||||
<!-- 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-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
</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>
|
||||
<!-- SWR Circle Compact -->
|
||||
<div class="swr-circle-compact" style="--swr-color: {swrColor}">
|
||||
<div class="swr-value-compact">{swr.toFixed(2)}</div>
|
||||
<div class="swr-label-compact">SWR</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-row">
|
||||
<div class="metric small">
|
||||
<div class="label">VAC</div>
|
||||
<div class="value">{voltage.toFixed(0)}</div>
|
||||
<!-- Temperature Gauges -->
|
||||
<div class="temp-group">
|
||||
<div class="temp-item">
|
||||
<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 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 class="temp-item">
|
||||
<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>
|
||||
</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>
|
||||
<!-- Electrical Parameters -->
|
||||
<div class="params-grid">
|
||||
<div class="param-box">
|
||||
<div class="param-label">VAC</div>
|
||||
<div class="param-value">{voltage.toFixed(0)}</div>
|
||||
</div>
|
||||
<div class="param-box">
|
||||
<div class="param-label">VDD</div>
|
||||
<div class="param-value">{vdd.toFixed(1)}</div>
|
||||
</div>
|
||||
<div class="param-box">
|
||||
<div class="param-label">ID Peak</div>
|
||||
<div class="param-value">{peakCurrent.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Band Display -->
|
||||
<div class="band-display">
|
||||
<div class="band-item">
|
||||
<span class="band-label">Band A</span>
|
||||
<span class="band-value">{bandA}</span>
|
||||
</div>
|
||||
<div class="band-item">
|
||||
<span class="band-label">Band B</span>
|
||||
<span class="band-value">{bandB}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fan Control -->
|
||||
<div class="fan-control">
|
||||
<label for="fan-mode-select" class="control-label">Fan Mode</label>
|
||||
<select id="fan-mode-select" value={fanMode} on:change={(e) => setFanMode(e.target.value)}>
|
||||
<option value="STANDARD">Standard</option>
|
||||
<option value="CONTEST">Contest</option>
|
||||
<option value="BROADCAST">Broadcast</option>
|
||||
</select>
|
||||
</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;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
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;
|
||||
.state-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-label.normal {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
.state-badge.idle {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-label.warning {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
.state-badge.transmit {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
/* Power Display */
|
||||
/* Power + SWR Row */
|
||||
.power-swr-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric.small {
|
||||
.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);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-teal);
|
||||
.power-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
.power-label-inline {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.power-value-inline {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.power-bar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-bg {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
height: 28px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
.power-bar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-green), var(--accent-red));
|
||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||
border-radius: 14px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.scale {
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.swr-circle-compact {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||
border: 4px solid var(--swr-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 25px var(--swr-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swr-value-compact {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--swr-color);
|
||||
}
|
||||
|
||||
.swr-label-compact {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Temperature */
|
||||
.temp-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.temp-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.temp-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fan-speed select {
|
||||
width: 100%;
|
||||
.temp-mini-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.temp-mini-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Parameters Grid */
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.param-box {
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.band-info {
|
||||
.param-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Band Display */
|
||||
.band-display {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.band-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.band-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.band-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Fan Control */
|
||||
.fan-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,135 +1,75 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
export let status;
|
||||
export let ultrabeam = null;
|
||||
|
||||
$: rotator1 = status?.rotator1 || {};
|
||||
$: rotator2 = status?.rotator2 || {};
|
||||
$: currentHeading = rotator1.current_azimuth || 0;
|
||||
$: targetHeading = rotator1.target_azimuth || 0;
|
||||
$: moving = rotator1.moving || 0;
|
||||
$: connected = rotator1.connected || false;
|
||||
let heading = null; // Start with null instead of 0
|
||||
let connected = false;
|
||||
|
||||
let targetInput = currentHeading;
|
||||
let canvas;
|
||||
let ctx;
|
||||
// Get Ultrabeam direction mode: 0=Normal, 1=180°, 2=Bi-Dir
|
||||
$: ultrabeamDirection = ultrabeam?.direction ?? 0;
|
||||
|
||||
onMount(() => {
|
||||
if (canvas) {
|
||||
ctx = canvas.getContext('2d');
|
||||
drawGlobe();
|
||||
// Update heading with detailed logging to debug
|
||||
$: if (status?.heading !== undefined && status?.heading !== null) {
|
||||
const newHeading = status.heading;
|
||||
|
||||
if (heading === null) {
|
||||
// First time: accept any value
|
||||
heading = newHeading;
|
||||
console.log(` ✓ First load, set to ${heading}°`);
|
||||
} else if (newHeading === 0 && heading > 10 && heading < 350) {
|
||||
// Ignore sudden jump to 0 from middle range (glitch)
|
||||
console.log(` ✗ IGNORED glitch jump from ${heading}° to 0°`);
|
||||
} else {
|
||||
// Normal update
|
||||
heading = newHeading;
|
||||
}
|
||||
});
|
||||
|
||||
$: if (ctx && currentHeading !== undefined) {
|
||||
drawGlobe();
|
||||
}
|
||||
|
||||
function drawGlobe() {
|
||||
if (!ctx) return;
|
||||
// Display heading: use cached value or 0 if never set
|
||||
$: displayHeading = heading !== null ? heading : 0;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 2 - 20;
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
// ✅ Target heading from rotator status (when controlled by PST Rotator or other software)
|
||||
$: statusTargetHeading = status?.target_heading ?? null;
|
||||
|
||||
// Draw globe circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
// Local target (when clicking on map in ShackMaster)
|
||||
let localTargetHeading = null;
|
||||
|
||||
// Draw grid lines (latitude/longitude)
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 1;
|
||||
// ✅ Determine if antenna is moving to a target from status
|
||||
// (target differs from current heading by more than 2 degrees)
|
||||
$: isMovingFromStatus = statusTargetHeading !== null &&
|
||||
heading !== null &&
|
||||
(() => {
|
||||
const diff = Math.abs(statusTargetHeading - heading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
return wrappedDiff > 2;
|
||||
})();
|
||||
|
||||
// Latitude lines
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = centerY - radius + (radius * 2 * i / 4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - radius, y);
|
||||
ctx.lineTo(centerX + radius, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// ✅ Active target: prefer status target when moving, otherwise use local target
|
||||
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
|
||||
|
||||
// Longitude lines
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const x = centerX - radius + (radius * 2 * i / 4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, centerY - radius);
|
||||
ctx.lineTo(x, centerY + radius);
|
||||
ctx.stroke();
|
||||
}
|
||||
// ✅ Has target if there's an active target that differs from current heading
|
||||
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
|
||||
const diff = Math.abs(activeTargetHeading - heading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
return wrappedDiff > 2;
|
||||
})();
|
||||
|
||||
// Draw cardinal directions
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '14px Roboto';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillText('N', centerX, centerY - radius - 10);
|
||||
ctx.fillText('S', centerX, centerY + radius + 10);
|
||||
ctx.fillText('E', centerX + radius + 10, centerY);
|
||||
ctx.fillText('W', centerX - radius - 10, centerY);
|
||||
|
||||
// Draw heading indicator
|
||||
const angle = (currentHeading - 90) * Math.PI / 180;
|
||||
const lineLength = radius - 10;
|
||||
const endX = centerX + Math.cos(angle) * lineLength;
|
||||
const endY = centerY + Math.sin(angle) * lineLength;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.lineTo(endX, endY);
|
||||
ctx.strokeStyle = '#00bcd4';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw arrow head
|
||||
const arrowSize = 15;
|
||||
const arrowAngle1 = angle + Math.PI * 0.85;
|
||||
const arrowAngle2 = angle - Math.PI * 0.85;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(endX, endY);
|
||||
ctx.lineTo(endX + Math.cos(arrowAngle1) * arrowSize, endY + Math.sin(arrowAngle1) * arrowSize);
|
||||
ctx.moveTo(endX, endY);
|
||||
ctx.lineTo(endX + Math.cos(arrowAngle2) * arrowSize, endY + Math.sin(arrowAngle2) * arrowSize);
|
||||
ctx.strokeStyle = '#00bcd4';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw center dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 5, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#f44336';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
async function moveToHeading() {
|
||||
const heading = parseInt(targetInput);
|
||||
if (isNaN(heading) || heading < 0 || heading > 360) {
|
||||
alert('Please enter a valid heading (0-360)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.rotator.move(1, heading);
|
||||
} catch (err) {
|
||||
console.error('Failed to move rotator:', err);
|
||||
alert('Failed to move rotator');
|
||||
// Clear local target when we reach it (within 3 degrees)
|
||||
$: if (localTargetHeading !== null && heading !== null) {
|
||||
const diff = Math.abs(heading - localTargetHeading);
|
||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||
if (wrappedDiff < 3) {
|
||||
localTargetHeading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateCW() {
|
||||
try {
|
||||
await api.rotator.cw(1);
|
||||
await api.rotator.rotateCW();
|
||||
} catch (err) {
|
||||
console.error('Failed to rotate CW:', err);
|
||||
}
|
||||
@@ -137,7 +77,7 @@
|
||||
|
||||
async function rotateCCW() {
|
||||
try {
|
||||
await api.rotator.ccw(1);
|
||||
await api.rotator.rotateCCW();
|
||||
} catch (err) {
|
||||
console.error('Failed to rotate CCW:', err);
|
||||
}
|
||||
@@ -145,190 +85,468 @@
|
||||
|
||||
async function stop() {
|
||||
try {
|
||||
localTargetHeading = null; // Clear local target on stop
|
||||
await api.rotator.stop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Preset directions
|
||||
const presets = [
|
||||
{ name: 'EU-0', heading: 0 },
|
||||
{ name: 'JA-35', heading: 35 },
|
||||
{ name: 'AS-75', heading: 75 },
|
||||
{ name: 'VK-120', heading: 120 },
|
||||
{ name: 'AF-180', heading: 180 },
|
||||
{ name: 'SA-230', heading: 230 },
|
||||
{ name: 'WI-270', heading: 270 },
|
||||
{ name: 'NA-300', heading: 300 }
|
||||
];
|
||||
// Handle click on compass to set heading
|
||||
async function handleCompassClick(event) {
|
||||
const svg = event.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
async function gotoPreset(heading) {
|
||||
// Get click position relative to center
|
||||
const x = event.clientX - rect.left - centerX;
|
||||
const y = event.clientY - rect.top - centerY;
|
||||
|
||||
// Calculate angle (0° = North/top, clockwise)
|
||||
let angle = Math.atan2(x, -y) * (180 / Math.PI);
|
||||
if (angle < 0) angle += 360;
|
||||
|
||||
// Round to nearest 5 degrees
|
||||
const roundedHeading = Math.round(angle / 5) * 5;
|
||||
const adjustedHeading = (roundedHeading + 360) % 360;
|
||||
|
||||
// ✅ CORRIGÉ : Send command first, then set localTargetHeading only on success
|
||||
try {
|
||||
await api.rotator.move(1, heading);
|
||||
await api.rotator.setHeading(adjustedHeading);
|
||||
// Only set local target AFTER successful API call
|
||||
localTargetHeading = adjustedHeading;
|
||||
} catch (err) {
|
||||
console.error('Failed to move to preset:', err);
|
||||
console.error('Failed to set heading:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rotator-card card">
|
||||
<h2>
|
||||
ROTATOR GENIUS
|
||||
<span class="status-indicator" class:status-online={connected} class:status-offline={!connected}></span>
|
||||
</h2>
|
||||
|
||||
<div class="heading-display">
|
||||
CURRENT HEADING: <span class="heading-value">{currentHeading}°</span>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Rotator Genius</h2>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
|
||||
{#if moving > 0}
|
||||
<div class="moving-indicator">
|
||||
{moving === 1 ? '↻ ROTATING CW' : '↺ ROTATING CCW'}
|
||||
<div class="metrics">
|
||||
<!-- Current Heading Display with Compact Controls -->
|
||||
<div class="heading-controls-row">
|
||||
<div class="heading-display-compact">
|
||||
<div class="heading-label">CURRENT HEADING</div>
|
||||
<div class="heading-value">
|
||||
{displayHeading}°
|
||||
{#if hasTarget && activeTargetHeading !== null}
|
||||
<span class="target-indicator">→ {activeTargetHeading}°</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-compact">
|
||||
<button class="btn-mini ccw" on:click={rotateCCW} title="Rotate Counter-Clockwise">
|
||||
↺
|
||||
</button>
|
||||
<button class="btn-mini stop" on:click={stop} title="Stop Rotation">
|
||||
■
|
||||
</button>
|
||||
<button class="btn-mini cw" on:click={rotateCW} title="Rotate Clockwise">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map with Beam -->
|
||||
<div class="map-container">
|
||||
<svg viewBox="0 0 300 300" class="map-svg clickable-compass"
|
||||
on:click={handleCompassClick}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleCompassClick(e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Click to rotate antenna to direction">
|
||||
<defs>
|
||||
<!-- Gradient for beam -->
|
||||
<radialGradient id="beamGradient">
|
||||
<stop offset="0%" style="stop-color:rgba(79, 195, 247, 0.7);stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:rgba(79, 195, 247, 0);stop-opacity:0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Ocean background -->
|
||||
<circle cx="150" cy="150" r="140" fill="rgba(30, 64, 175, 0.15)" stroke="rgba(79, 195, 247, 0.4)" stroke-width="2"/>
|
||||
|
||||
<!-- Distance circles -->
|
||||
<circle cx="150" cy="150" r="105" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<circle cx="150" cy="150" r="70" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<circle cx="150" cy="150" r="35" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
|
||||
<!-- Rotated group for beam -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Physical antenna direction indicator (only in 180° or Bi-Dir mode) -->
|
||||
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||
<g transform="rotate({displayHeading})">
|
||||
<!-- Gray dashed line showing physical antenna direction -->
|
||||
<line x1="0" y1="0" x2="0" y2="-125"
|
||||
stroke="rgba(255, 255, 255, 0.3)"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5,5"
|
||||
opacity="0.6"/>
|
||||
<!-- Small triangle at end to show physical direction -->
|
||||
<g transform="translate(0, -125)">
|
||||
<polygon points="0,-8 -5,5 5,5"
|
||||
fill="rgba(255, 255, 255, 0.4)"
|
||||
stroke="rgba(255, 255, 255, 0.5)"
|
||||
stroke-width="1"/>
|
||||
</g>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<canvas bind:this={canvas} width="300" height="300"></canvas>
|
||||
<!-- Beam (rotates with heading) -->
|
||||
<g transform="rotate({displayHeading})">
|
||||
|
||||
<div class="controls">
|
||||
<div class="heading-input">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="360"
|
||||
bind:value={targetInput}
|
||||
placeholder="Enter heading"
|
||||
/>
|
||||
<button class="btn btn-primary" on:click={moveToHeading}>GO</button>
|
||||
</div>
|
||||
<!-- NORMAL MODE (0): Forward beam only -->
|
||||
{#if ultrabeamDirection === 0}
|
||||
<!-- Beam sector (±15° = 30° total beamwidth) -->
|
||||
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130}
|
||||
A 130,130 0 0,1 {Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130} Z"
|
||||
fill="url(#beamGradient)"
|
||||
opacity="0.85"/>
|
||||
|
||||
<div class="rotation-controls">
|
||||
<button class="btn btn-secondary" on:click={rotateCCW}>↺ CCW</button>
|
||||
<button class="btn btn-danger" on:click={stop}>STOP</button>
|
||||
<button class="btn btn-secondary" on:click={rotateCW}>CW ↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Beam outline -->
|
||||
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||
|
||||
<div class="presets">
|
||||
{#each presets as preset}
|
||||
<button
|
||||
class="preset-btn"
|
||||
class:active={Math.abs(currentHeading - preset.heading) < 5}
|
||||
on:click={() => gotoPreset(preset.heading)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
<!-- Direction arrow -->
|
||||
<g transform="translate(0, -110)">
|
||||
<polygon points="0,-20 -8,5 0,0 8,5"
|
||||
fill="#4fc3f7"
|
||||
stroke="#0288d1"
|
||||
stroke-width="2"
|
||||
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<!-- 180° MODE (1): Backward beam only -->
|
||||
{#if ultrabeamDirection === 1}
|
||||
<!-- Beam sector pointing BACKWARD (180° opposite) -->
|
||||
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130}
|
||||
A 130,130 0 0,0 {Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130} Z"
|
||||
fill="url(#beamGradient)"
|
||||
opacity="0.85"/>
|
||||
|
||||
<!-- Beam outline -->
|
||||
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
|
||||
|
||||
<!-- Direction arrow pointing BACKWARD -->
|
||||
<g transform="translate(0, 110)">
|
||||
<polygon points="0,20 -8,-5 0,0 8,-5"
|
||||
fill="#4fc3f7"
|
||||
stroke="#0288d1"
|
||||
stroke-width="2"
|
||||
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<!-- BI-DIRECTIONAL MODE (2): Both forward AND backward beams -->
|
||||
{#if ultrabeamDirection === 2}
|
||||
<!-- Forward beam -->
|
||||
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130}
|
||||
A 130,130 0 0,1 {Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130} Z"
|
||||
fill="url(#beamGradient)"
|
||||
opacity="0.7"/>
|
||||
|
||||
<!-- Backward beam -->
|
||||
<path d="M 0,0 L {-Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130}
|
||||
A 130,130 0 0,0 {Math.sin(15 * Math.PI/180) * 130},{Math.cos(15 * Math.PI/180) * 130} Z"
|
||||
fill="url(#beamGradient)"
|
||||
opacity="0.7"/>
|
||||
|
||||
<!-- Beam outlines -->
|
||||
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={-Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||
<line x1="0" y1="0" x2={-Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||
<line x1="0" y1="0" x2={Math.sin(15 * Math.PI/180) * 130} y2={Math.cos(15 * Math.PI/180) * 130}
|
||||
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
|
||||
|
||||
<!-- Direction arrows (both directions) -->
|
||||
<g transform="translate(0, -110)">
|
||||
<polygon points="0,-20 -8,5 0,0 8,5"
|
||||
fill="#4fc3f7"
|
||||
stroke="#0288d1"
|
||||
stroke-width="2"
|
||||
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||
</g>
|
||||
<g transform="translate(0, 110)">
|
||||
<polygon points="0,20 -8,-5 0,0 8,-5"
|
||||
fill="#4fc3f7"
|
||||
stroke="#0288d1"
|
||||
stroke-width="2"
|
||||
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
</g>
|
||||
|
||||
<!-- ✅ Target arrow (yellow) - shown when antenna is moving to target -->
|
||||
{#if hasTarget && activeTargetHeading !== null}
|
||||
<g transform="rotate({activeTargetHeading})">
|
||||
<!-- Target direction line (dashed yellow) -->
|
||||
<line x1="0" y1="0" x2="0" y2="-135"
|
||||
stroke="#ffc107"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="8,4"
|
||||
opacity="0.9"/>
|
||||
<!-- Target arrow head with pulse animation -->
|
||||
<g transform="translate(0, -135)">
|
||||
<polygon points="0,-12 -8,6 0,2 8,6"
|
||||
fill="#ffc107"
|
||||
stroke="#ff9800"
|
||||
stroke-width="1.5"
|
||||
style="filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.8))">
|
||||
<animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
|
||||
</polygon>
|
||||
</g>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<!-- Center dot (your QTH) -->
|
||||
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
||||
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="0" cy="0" r="10" fill="none" stroke="#f44336" stroke-width="1.5" opacity="0.5">
|
||||
<animate attributeName="r" values="10;16;10" dur="2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Cardinal points -->
|
||||
<text x="150" y="20" text-anchor="middle" class="cardinal">N</text>
|
||||
<text x="280" y="155" text-anchor="middle" class="cardinal">E</text>
|
||||
<text x="150" y="285" text-anchor="middle" class="cardinal">S</text>
|
||||
<text x="20" y="155" text-anchor="middle" class="cardinal">W</text>
|
||||
|
||||
<!-- Degree markers every 45° -->
|
||||
{#each [45, 135, 225, 315] as angle}
|
||||
{@const x = 150 + 125 * Math.sin(angle * Math.PI / 180)}
|
||||
{@const y = 150 - 125 * Math.cos(angle * Math.PI / 180)}
|
||||
<text x={x} y={y} text-anchor="middle" dominant-baseline="middle" class="degree-label">{angle}°</text>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Legend (only show in 180° or Bi-Dir mode) -->
|
||||
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||
<div class="map-legend">
|
||||
<div class="legend-item">
|
||||
<svg width="30" height="20" viewBox="0 0 30 20">
|
||||
<line x1="5" y1="10" x2="25" y2="10" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="3,3"/>
|
||||
</svg>
|
||||
<span>Physical antenna</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<svg width="30" height="20" viewBox="0 0 30 20">
|
||||
<line x1="5" y1="10" x2="25" y2="10" stroke="#4fc3f7" stroke-width="2"/>
|
||||
</svg>
|
||||
<span>Radiation pattern</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rotator-card {
|
||||
min-width: 350px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.heading-display {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.heading-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
.heading-display-compact {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.controls-compact {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-mini {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(79, 195, 247, 0.08);
|
||||
}
|
||||
|
||||
.btn-mini:hover {
|
||||
border-color: rgba(79, 195, 247, 0.6);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(79, 195, 247, 0.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 15px rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
.btn-mini.ccw:hover {
|
||||
transform: translateY(-1px) rotate(-5deg);
|
||||
}
|
||||
|
||||
.btn-mini.cw:hover {
|
||||
transform: translateY(-1px) rotate(5deg);
|
||||
}
|
||||
|
||||
.btn-mini.stop:hover {
|
||||
border-color: #f44336;
|
||||
color: #f44336;
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
|
||||
.heading-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heading-value {
|
||||
color: var(--accent-blue);
|
||||
font-size: 42px;
|
||||
font-weight: 200;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.target-indicator {
|
||||
font-size: 24px;
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
color: #ffc107;
|
||||
margin-left: 20px;
|
||||
text-shadow: 0 0 15px rgba(255, 193, 7, 0.6);
|
||||
animation: targetPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.moving-indicator {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
@keyframes targetPulse {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
margin: 16px auto;
|
||||
background: var(--bg-secondary);
|
||||
.map-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
background: rgba(10, 22, 40, 0.6);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
.map-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.heading-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heading-input input {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rotation-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rotation-controls button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 12px 8px;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
background: rgba(10, 22, 40, 0.4);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #1976d2;
|
||||
transform: translateY(-2px);
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: var(--accent-green);
|
||||
.map-svg {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.clickable-compass {
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clickable-compass:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.cardinal {
|
||||
fill: var(--accent-cyan);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 10px rgba(79, 195, 247, 0.8);
|
||||
}
|
||||
|
||||
.degree-label {
|
||||
fill: rgba(79, 195, 247, 0.7);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
625
web/src/components/StatusBanner.svelte
Normal file
625
web/src/components/StatusBanner.svelte
Normal file
@@ -0,0 +1,625 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let flexradio = null;
|
||||
export let weather = null;
|
||||
export let latitude = null;
|
||||
export let longitude = null;
|
||||
export let windWarningThreshold = 30; // km/h
|
||||
export let gustWarningThreshold = 50; // km/h
|
||||
export let graylineWindow = 30; // minutes avant/après sunrise/sunset
|
||||
|
||||
// FlexRadio frequency and mode
|
||||
$: frequency = flexradio?.frequency || 0;
|
||||
$: mode = flexradio?.mode || '';
|
||||
$: txEnabled = flexradio?.tx || false;
|
||||
$: connected = flexradio?.connected || false;
|
||||
|
||||
// Grayline calculation
|
||||
let sunrise = null;
|
||||
let sunset = null;
|
||||
let isGrayline = false;
|
||||
let graylineType = ''; // 'sunrise' ou 'sunset'
|
||||
let timeToNextEvent = '';
|
||||
let currentTime = new Date();
|
||||
let clockInterval;
|
||||
|
||||
// Update time every minute for grayline check
|
||||
onMount(() => {
|
||||
calculateSunTimes();
|
||||
clockInterval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
checkGrayline();
|
||||
updateTimeToNextEvent();
|
||||
}, 10000); // Update every 10 seconds
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (clockInterval) clearInterval(clockInterval);
|
||||
});
|
||||
|
||||
// Recalculate when location changes
|
||||
$: if (latitude && longitude) {
|
||||
calculateSunTimes();
|
||||
}
|
||||
|
||||
// SunCalc algorithm (simplified version)
|
||||
function calculateSunTimes() {
|
||||
if (!latitude || !longitude) return;
|
||||
|
||||
const now = new Date();
|
||||
const times = getSunTimes(now, latitude, longitude);
|
||||
sunrise = times.sunrise;
|
||||
sunset = times.sunset;
|
||||
checkGrayline();
|
||||
updateTimeToNextEvent();
|
||||
}
|
||||
|
||||
// Simplified sun calculation (based on NOAA algorithm)
|
||||
function getSunTimes(date, lat, lon) {
|
||||
const rad = Math.PI / 180;
|
||||
const dayOfYear = getDayOfYear(date);
|
||||
|
||||
// Fractional year
|
||||
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (date.getHours() - 12) / 24);
|
||||
|
||||
// Equation of time (minutes)
|
||||
const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma)
|
||||
- 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma));
|
||||
|
||||
// Solar declination (radians)
|
||||
const decl = 0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma)
|
||||
- 0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma)
|
||||
- 0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma);
|
||||
|
||||
// Hour angle for sunrise/sunset
|
||||
const latRad = lat * rad;
|
||||
const zenith = 90.833 * rad; // Official zenith for sunrise/sunset
|
||||
|
||||
const cosHA = (Math.cos(zenith) / (Math.cos(latRad) * Math.cos(decl)))
|
||||
- Math.tan(latRad) * Math.tan(decl);
|
||||
|
||||
// Check for polar day/night
|
||||
if (cosHA > 1 || cosHA < -1) {
|
||||
return { sunrise: null, sunset: null };
|
||||
}
|
||||
|
||||
const ha = Math.acos(cosHA) / rad; // Hour angle in degrees
|
||||
|
||||
// Sunrise and sunset times in minutes from midnight UTC
|
||||
const sunriseMinutes = 720 - 4 * (lon + ha) - eqTime;
|
||||
const sunsetMinutes = 720 - 4 * (lon - ha) - eqTime;
|
||||
|
||||
// Convert to local Date objects
|
||||
const sunriseDate = new Date(date);
|
||||
sunriseDate.setUTCHours(0, 0, 0, 0);
|
||||
sunriseDate.setUTCMinutes(sunriseMinutes);
|
||||
|
||||
const sunsetDate = new Date(date);
|
||||
sunsetDate.setUTCHours(0, 0, 0, 0);
|
||||
sunsetDate.setUTCMinutes(sunsetMinutes);
|
||||
|
||||
return { sunrise: sunriseDate, sunset: sunsetDate };
|
||||
}
|
||||
|
||||
function getDayOfYear(date) {
|
||||
const start = new Date(date.getFullYear(), 0, 0);
|
||||
const diff = date - start;
|
||||
const oneDay = 1000 * 60 * 60 * 24;
|
||||
return Math.floor(diff / oneDay);
|
||||
}
|
||||
|
||||
function checkGrayline() {
|
||||
if (!sunrise || !sunset) {
|
||||
isGrayline = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = currentTime.getTime();
|
||||
const windowMs = graylineWindow * 60 * 1000;
|
||||
|
||||
const nearSunrise = Math.abs(now - sunrise.getTime()) <= windowMs;
|
||||
const nearSunset = Math.abs(now - sunset.getTime()) <= windowMs;
|
||||
|
||||
isGrayline = nearSunrise || nearSunset;
|
||||
graylineType = nearSunrise ? 'sunrise' : (nearSunset ? 'sunset' : '');
|
||||
}
|
||||
|
||||
function updateTimeToNextEvent() {
|
||||
if (!sunrise || !sunset) {
|
||||
timeToNextEvent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const now = currentTime.getTime();
|
||||
let nextEvent = null;
|
||||
let eventName = '';
|
||||
|
||||
if (now < sunrise.getTime()) {
|
||||
nextEvent = sunrise;
|
||||
eventName = 'Sunrise';
|
||||
} else if (now < sunset.getTime()) {
|
||||
nextEvent = sunset;
|
||||
eventName = 'Sunset';
|
||||
} else {
|
||||
// After sunset, calculate tomorrow's sunrise
|
||||
const tomorrow = new Date(currentTime);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowTimes = getSunTimes(tomorrow, latitude, longitude);
|
||||
nextEvent = tomorrowTimes.sunrise;
|
||||
eventName = 'Sunrise';
|
||||
}
|
||||
|
||||
if (nextEvent) {
|
||||
const diffMs = nextEvent.getTime() - now;
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
timeToNextEvent = `${eventName} in ${hours}h${minutes.toString().padStart(2, '0')}m`;
|
||||
} else {
|
||||
timeToNextEvent = `${eventName} in ${minutes}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
if (!date) return '--:--';
|
||||
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Format frequency for display (MHz with appropriate decimals)
|
||||
function formatFrequency(freqMHz) {
|
||||
if (!freqMHz || freqMHz === 0) return '---';
|
||||
if (freqMHz < 10) {
|
||||
return freqMHz.toFixed(4);
|
||||
} else if (freqMHz < 100) {
|
||||
return freqMHz.toFixed(3);
|
||||
} else {
|
||||
return freqMHz.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Get band from frequency
|
||||
function getBand(freqMHz) {
|
||||
if (!freqMHz || freqMHz === 0) return '';
|
||||
if (freqMHz >= 1.8 && freqMHz <= 2.0) return '160M';
|
||||
if (freqMHz >= 3.5 && freqMHz <= 4.0) return '80M';
|
||||
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60M';
|
||||
if (freqMHz >= 7.0 && freqMHz <= 7.3) return '40M';
|
||||
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30M';
|
||||
if (freqMHz >= 14.0 && freqMHz <= 14.35) return '20M';
|
||||
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17M';
|
||||
if (freqMHz >= 21.0 && freqMHz <= 21.45) return '15M';
|
||||
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12M';
|
||||
if (freqMHz >= 28.0 && freqMHz <= 29.7) return '10M';
|
||||
if (freqMHz >= 50.0 && freqMHz <= 54.0) return '6M';
|
||||
if (freqMHz >= 144.0 && freqMHz <= 148.0) return '2M';
|
||||
if (freqMHz >= 430.0 && freqMHz <= 440.0) return '70CM';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Weather alerts
|
||||
$: windSpeed = weather?.wind_speed || 0;
|
||||
$: windGust = weather?.wind_gust || 0;
|
||||
$: hasWindWarning = windSpeed >= windWarningThreshold;
|
||||
$: hasGustWarning = windGust >= gustWarningThreshold;
|
||||
$: hasAnyWarning = hasWindWarning || hasGustWarning;
|
||||
|
||||
// Band colors
|
||||
function getBandColor(band) {
|
||||
const colors = {
|
||||
'160M': '#9c27b0',
|
||||
'80M': '#673ab7',
|
||||
'60M': '#3f51b5',
|
||||
'40M': '#2196f3',
|
||||
'30M': '#00bcd4',
|
||||
'20M': '#009688',
|
||||
'17M': '#4caf50',
|
||||
'15M': '#8bc34a',
|
||||
'12M': '#cddc39',
|
||||
'10M': '#ffeb3b',
|
||||
'6M': '#ff9800',
|
||||
'2M': '#ff5722',
|
||||
'70CM': '#f44336'
|
||||
};
|
||||
return colors[band] || '#4fc3f7';
|
||||
}
|
||||
|
||||
$: currentBand = getBand(frequency);
|
||||
$: bandColor = getBandColor(currentBand);
|
||||
</script>
|
||||
|
||||
<div class="status-banner" class:has-warning={hasAnyWarning}>
|
||||
<!-- FlexRadio Section -->
|
||||
<div class="flex-section">
|
||||
<div class="flex-icon" class:connected={connected} class:disconnected={!connected}>
|
||||
📻
|
||||
</div>
|
||||
|
||||
{#if connected && frequency > 0}
|
||||
<div class="frequency-display">
|
||||
<span class="frequency" style="--band-color: {bandColor}">
|
||||
{formatFrequency(frequency)}
|
||||
</span>
|
||||
<span class="unit">MHz</span>
|
||||
</div>
|
||||
|
||||
{#if currentBand}
|
||||
<span class="band-badge" style="background-color: {bandColor}">
|
||||
{currentBand}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if mode}
|
||||
<span class="mode-badge">
|
||||
{mode}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if txEnabled}
|
||||
<span class="tx-indicator">
|
||||
TX
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="no-signal">FlexRadio non connecté</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- Grayline Section -->
|
||||
<div class="grayline-section">
|
||||
{#if latitude && longitude}
|
||||
<div class="sun-times">
|
||||
<span class="sun-item" title="Sunrise">
|
||||
<svg class="sun-icon sunrise-icon" width="18" height="18" viewBox="0 0 24 24">
|
||||
<!-- Horizon line -->
|
||||
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
|
||||
<!-- Sun (half visible) -->
|
||||
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#fbbf24"/>
|
||||
<!-- Rays -->
|
||||
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Up arrow -->
|
||||
<path d="M12 14l-3 3M12 14l3 3M12 14v5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
{formatTime(sunrise)}
|
||||
</span>
|
||||
<span class="sun-item" title="Sunset">
|
||||
<svg class="sun-icon sunset-icon" width="18" height="18" viewBox="0 0 24 24">
|
||||
<!-- Horizon line -->
|
||||
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
|
||||
<!-- Sun (half visible, setting) -->
|
||||
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#f97316"/>
|
||||
<!-- Rays (dimmer) -->
|
||||
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#f97316" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Down arrow -->
|
||||
<path d="M12 22l-3-3M12 22l3-3M12 22v-5" stroke="#ef4444" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
{formatTime(sunset)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if isGrayline}
|
||||
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
|
||||
✨ GRAYLINE
|
||||
</span>
|
||||
{:else if timeToNextEvent}
|
||||
<span class="next-event">
|
||||
{timeToNextEvent}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="no-location">📍 Position non configurée</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- Weather Alerts Section -->
|
||||
<div class="weather-section">
|
||||
{#if hasWindWarning}
|
||||
<div class="alert wind-alert">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<span class="alert-text">
|
||||
Vent: <strong>{windSpeed.toFixed(0)} km/h</strong>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasGustWarning}
|
||||
<div class="alert gust-alert">
|
||||
<span class="alert-icon">🌪️</span>
|
||||
<span class="alert-text">
|
||||
Rafales: <strong>{windGust.toFixed(0)} km/h</strong>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasAnyWarning}
|
||||
<div class="status-ok">
|
||||
<span class="ok-icon">✓</span>
|
||||
<span class="ok-text">Météo OK</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 24px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
border-bottom: 1px solid rgba(79, 195, 247, 0.15);
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-banner.has-warning {
|
||||
background: linear-gradient(135deg, #1f1a12 0%, #29201b 50%, #2d1b0c 100%);
|
||||
border-bottom-color: #f59e0b;
|
||||
}
|
||||
|
||||
/* FlexRadio Section */
|
||||
.flex-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flex-icon {
|
||||
font-size: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.flex-icon.connected {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 4px rgba(79, 195, 247, 0.6));
|
||||
}
|
||||
|
||||
.flex-icon.disconnected {
|
||||
opacity: 0.4;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.frequency-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.frequency {
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
font-family: 'Roboto Mono', 'Consolas', monospace;
|
||||
color: var(--band-color, #4fc3f7);
|
||||
text-shadow: 0 0 15px var(--band-color, rgba(79, 195, 247, 0.5));
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.band-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
padding: 4px 10px;
|
||||
background: rgba(156, 39, 176, 0.3);
|
||||
border: 1px solid rgba(156, 39, 176, 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
.tx-indicator {
|
||||
padding: 4px 10px;
|
||||
background: rgba(244, 67, 54, 0.3);
|
||||
border: 1px solid #f44336;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #f44336;
|
||||
text-shadow: 0 0 10px rgba(244, 67, 54, 0.8);
|
||||
animation: txPulse 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes txPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.no-signal {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Separator */
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Grayline Section */
|
||||
.grayline-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sun-times {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sun-item {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sun-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sunrise-icon {
|
||||
color: rgba(251, 191, 36, 0.6);
|
||||
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.4));
|
||||
}
|
||||
|
||||
.sunset-icon {
|
||||
color: rgba(249, 115, 22, 0.6);
|
||||
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
|
||||
}
|
||||
|
||||
.grayline-badge {
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
animation: graylinePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.grayline-badge.sunrise {
|
||||
background: linear-gradient(135deg, rgba(255, 183, 77, 0.3) 0%, rgba(255, 138, 101, 0.3) 100%);
|
||||
border: 1px solid rgba(255, 183, 77, 0.6);
|
||||
color: #ffcc80;
|
||||
text-shadow: 0 0 10px rgba(255, 183, 77, 0.8);
|
||||
}
|
||||
|
||||
.grayline-badge.sunset {
|
||||
background: linear-gradient(135deg, rgba(255, 138, 101, 0.3) 0%, rgba(239, 83, 80, 0.3) 100%);
|
||||
border: 1px solid rgba(255, 138, 101, 0.6);
|
||||
color: #ffab91;
|
||||
text-shadow: 0 0 10px rgba(255, 138, 101, 0.8);
|
||||
}
|
||||
|
||||
@keyframes graylinePulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.85; transform: scale(1.02); }
|
||||
}
|
||||
|
||||
.next-event {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-location {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Weather Section */
|
||||
.weather-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
animation: alertPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wind-alert {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border: 1px solid rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.gust-alert {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
@keyframes alertPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.alert-text strong {
|
||||
color: #fbbf24;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gust-alert .alert-text strong {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ok-icon {
|
||||
color: #22c55e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ok-text {
|
||||
font-size: 13px;
|
||||
color: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.status-banner {
|
||||
padding: 8px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.frequency {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flex-section,
|
||||
.grayline-section,
|
||||
.weather-section {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,255 +3,438 @@
|
||||
|
||||
export let status;
|
||||
|
||||
$: operate = status?.operate || false;
|
||||
$: activeAntenna = status?.active_antenna || 0;
|
||||
$: powerForward = status?.power_forward || 0;
|
||||
$: swr = status?.swr || 1.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;
|
||||
$: bypass = status?.bypass || false;
|
||||
$: state = status?.state || 0;
|
||||
$: relayC1 = status?.c1 || 0;
|
||||
$: relayL = status?.l || 0;
|
||||
$: relayC2 = status?.c2 || 0;
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
let tuning = false;
|
||||
// Color functions
|
||||
$: swrColor = swr < 1.5 ? '#4caf50' : swr < 2.0 ? '#ffc107' : swr < 3.0 ? '#ff9800' : '#f44336';
|
||||
$: powerPercent = Math.min((powerForward / 2000) * 100, 100);
|
||||
|
||||
async function toggleOperate() {
|
||||
async function autoTune() {
|
||||
try {
|
||||
await api.tuner.operate(!operate);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle operate:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTune() {
|
||||
tuning = true;
|
||||
try {
|
||||
await api.tuner.tune();
|
||||
await api.tuner.autoTune();
|
||||
} catch (err) {
|
||||
console.error('Failed to tune:', err);
|
||||
alert('Tuning failed');
|
||||
} finally {
|
||||
tuning = false;
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function setAntenna(ant) {
|
||||
async function setBypass(value) {
|
||||
try {
|
||||
await api.tuner.antenna(ant);
|
||||
await api.tuner.setBypass(value);
|
||||
} catch (err) {
|
||||
console.error('Failed to set antenna:', err);
|
||||
console.error('Failed to set bypass:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function setOperate(value) {
|
||||
try {
|
||||
await api.tuner.setOperate(value);
|
||||
} catch (err) {
|
||||
console.error('Failed to set operate:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 class="card">
|
||||
<div class="card-header">
|
||||
<h2>Tuner Genius XL</h2>
|
||||
<div class="header-right">
|
||||
<span class="tuning-badge" class:tuning={tuningStatus === 'TUNING'}>{tuningStatus}</span>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tuning-controls">
|
||||
<div class="tuning-row">
|
||||
<div class="tuning-label">TG XL SWR 1.00 use</div>
|
||||
<div class="metrics">
|
||||
<!-- 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-container">
|
||||
<div class="power-bar-bg">
|
||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="antenna-buttons">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Tuning Capacitors -->
|
||||
<div class="capacitors">
|
||||
<div class="cap-item">
|
||||
<div class="cap-value">{relayC1}</div>
|
||||
<div class="cap-label">C1</div>
|
||||
</div>
|
||||
<div class="cap-item">
|
||||
<div class="cap-value">{relayL}</div>
|
||||
<div class="cap-label">L</div>
|
||||
</div>
|
||||
<div class="cap-item">
|
||||
<div class="cap-value">{relayC2}</div>
|
||||
<div class="cap-label">C2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequencies -->
|
||||
<div class="freq-display">
|
||||
<div class="freq-item">
|
||||
<div class="freq-label">Freq A</div>
|
||||
<div class="freq-value">{(frequencyA / 1000).toFixed(3)}<span class="freq-unit">MHz</span></div>
|
||||
</div>
|
||||
<div class="freq-item">
|
||||
<div class="freq-label">Freq B</div>
|
||||
<div class="freq-value">{(frequencyB / 1000).toFixed(3)}<span class="freq-unit">MHz</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="controls">
|
||||
<button
|
||||
class="antenna-btn"
|
||||
class:active={activeAntenna === 0}
|
||||
on:click={() => setAntenna(0)}
|
||||
class="control-btn operate"
|
||||
class:active={state === 1}
|
||||
on:click={() => setOperate(state === 1 ? 0 : 1)}
|
||||
>
|
||||
C1
|
||||
{state === 1 ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<button
|
||||
class="antenna-btn"
|
||||
class:active={activeAntenna === 1}
|
||||
on:click={() => setAntenna(1)}
|
||||
class="control-btn bypass"
|
||||
class:active={bypass}
|
||||
on:click={() => setBypass(bypass ? 0 : 1)}
|
||||
>
|
||||
L
|
||||
</button>
|
||||
<button
|
||||
class="antenna-btn"
|
||||
class:active={activeAntenna === 2}
|
||||
on:click={() => setAntenna(2)}
|
||||
>
|
||||
C2
|
||||
BYPASS
|
||||
</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 class="tune-btn" on:click={autoTune}>
|
||||
<span class="tune-icon">⚡</span>
|
||||
AUTO TUNE
|
||||
</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;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
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 {
|
||||
.tuning-badge {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-badge.tuning {
|
||||
background: var(--accent-green);
|
||||
color: white;
|
||||
.tuning-badge.tuning {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.tuning-controls {
|
||||
margin-bottom: 16px;
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.tuning-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.antenna-buttons {
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
/* Power Display */
|
||||
/* Power + SWR Row */
|
||||
.power-swr-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric {
|
||||
.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);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.power-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
flex: 1;
|
||||
.power-label-inline {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.power-value-inline {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.power-bar-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-bg {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-bar-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #ffc107, #ff9800, #f44336);
|
||||
border-radius: 14px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.swr-circle-compact {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(79, 195, 247, 0.1), transparent);
|
||||
border: 4px solid var(--swr-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 25px var(--swr-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swr-value-compact {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--swr-color);
|
||||
}
|
||||
|
||||
.swr-label-compact {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
/* SWR Circle */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Capacitors */
|
||||
.capacitors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.cap-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cap-value {
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.cap-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Frequencies */
|
||||
.freq-display {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.freq-item {
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.freq-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.freq-value {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.freq-unit {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: #000;
|
||||
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.tune-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.tune-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
|
||||
.tune-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tune-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
562
web/src/components/Ultrabeam.svelte
Normal file
562
web/src/components/Ultrabeam.svelte
Normal file
@@ -0,0 +1,562 @@
|
||||
<script>
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
export let status;
|
||||
export let flexradio = null;
|
||||
|
||||
$: connected = status?.connected || false;
|
||||
$: frequency = status?.frequency || 0;
|
||||
$: band = status?.band || 0;
|
||||
$: direction = status?.direction || 0;
|
||||
$: motorsMoving = status?.motors_moving || 0;
|
||||
$: progressTotal = status?.progress_total || 0;
|
||||
$: progressCurrent = status?.progress_current || 0;
|
||||
$: elementLengths = status?.element_lengths || [];
|
||||
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
||||
|
||||
// FlexRadio interlock
|
||||
$: interlockConnected = flexradio?.connected || false;
|
||||
$: interlockState = flexradio?.interlock_state || null;
|
||||
$: interlockColor = getInterlockColor(interlockState);
|
||||
|
||||
// Debug log
|
||||
$: if (flexradio) {
|
||||
console.log('FlexRadio data:', {
|
||||
connected: flexradio.connected,
|
||||
interlock_state: flexradio.interlock_state,
|
||||
interlockConnected,
|
||||
interlockState
|
||||
});
|
||||
}
|
||||
|
||||
function getInterlockColor(state) {
|
||||
switch(state) {
|
||||
case 'READY': return '#4caf50';
|
||||
case 'NOT_READY': return '#f44336';
|
||||
case 'PTT_REQUESTED': return '#ffc107';
|
||||
case 'TRANSMITTING': return '#ff9800';
|
||||
default: return 'rgba(255, 255, 255, 0.3)';
|
||||
}
|
||||
}
|
||||
|
||||
// Band names mapping - VL2.3 covers 6M to 40M only
|
||||
// Band 0=6M, 1=10M, 2=12M, 3=15M, 4=17M, 5=20M, 6=30M, 7=40M
|
||||
const bandNames = [
|
||||
'6M', '10M', '12M', '15M', '17M', '20M', '30M', '40M'
|
||||
];
|
||||
|
||||
// Detect band from frequency
|
||||
$: detectedBand = detectBandFromFrequency(frequency, band);
|
||||
|
||||
function detectBandFromFrequency(freq, bandIndex) {
|
||||
// If band index is valid (0-7), use it directly
|
||||
if (bandIndex >= 0 && bandIndex <= 7) {
|
||||
return bandNames[bandIndex];
|
||||
}
|
||||
|
||||
// Otherwise detect from frequency (in kHz)
|
||||
if (freq >= 7000 && freq <= 7300) return '40M';
|
||||
if (freq >= 10100 && freq <= 10150) return '30M';
|
||||
if (freq >= 14000 && freq <= 14350) return '20M';
|
||||
if (freq >= 18068 && freq <= 18168) return '17M';
|
||||
if (freq >= 21000 && freq <= 21450) return '15M';
|
||||
if (freq >= 24890 && freq <= 24990) return '12M';
|
||||
if (freq >= 28000 && freq <= 29700) return '10M';
|
||||
if (freq >= 50000 && freq <= 54000) return '6M';
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// Direction names
|
||||
const directionNames = ['Normal', '180°', 'Bi-Dir'];
|
||||
|
||||
// Auto-track threshold options
|
||||
const thresholdOptions = [
|
||||
{ value: 25, label: '25 kHz' },
|
||||
{ value: 50, label: '50 kHz' },
|
||||
{ value: 100, label: '100 kHz' }
|
||||
];
|
||||
|
||||
// Auto-track state
|
||||
let autoTrackEnabled = true; // Default enabled
|
||||
let autoTrackThreshold = 25; // Default 25 kHz
|
||||
|
||||
// Form state
|
||||
let targetDirection = 0;
|
||||
|
||||
// Auto-update targetDirection when status changes
|
||||
$: targetDirection = direction;
|
||||
|
||||
// Element names based on band (corrected order: 0=6M ... 10=160M)
|
||||
$: elementNames = getElementNames(band);
|
||||
|
||||
function getElementNames(band) {
|
||||
// 30M (band 6) and 40M (band 7): Reflector (inverted), Radiator (inverted)
|
||||
if (band === 6 || band === 7) {
|
||||
return ['Radiator (30/40M)', 'Reflector (30/40M)', null];
|
||||
}
|
||||
// 6M to 20M (bands 0-5): Reflector, Radiator, Director 1
|
||||
if (band >= 0 && band <= 5) {
|
||||
return ['Reflector', 'Radiator', 'Director 1'];
|
||||
}
|
||||
// Default
|
||||
return ['Element 1', 'Element 2', 'Element 3'];
|
||||
}
|
||||
|
||||
// Element calibration state
|
||||
let calibrationMode = false;
|
||||
let selectedElement = 0;
|
||||
let elementAdjustment = 0;
|
||||
|
||||
async function setDirection() {
|
||||
if (frequency === 0) {
|
||||
return; // Silently skip if no frequency
|
||||
}
|
||||
try {
|
||||
// Send command to antenna with current frequency and new direction
|
||||
await api.ultrabeam.setFrequency(frequency, targetDirection);
|
||||
// Also save direction preference for auto-track
|
||||
await api.ultrabeam.setDirection(targetDirection);
|
||||
} catch (err) {
|
||||
// Log error but don't alert - code 30 (busy) is normal
|
||||
console.log('Direction change sent (may show code 30 if busy):', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAutoTrack() {
|
||||
try {
|
||||
await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold);
|
||||
} catch (err) {
|
||||
console.error('Failed to update auto-track:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function retract() {
|
||||
if (!confirm('Retract all antenna elements?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.ultrabeam.retract();
|
||||
} catch (err) {
|
||||
console.error('Failed to retract:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
async function adjustElement() {
|
||||
try {
|
||||
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
||||
// TODO: Add API call when backend supports it
|
||||
// Removed alert popup - check console for errors
|
||||
elementAdjustment = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to adjust element:', err);
|
||||
// Removed alert popup - check console for errors
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
$: progressPercent = progressTotal > 0 ? (progressCurrent / 60) * 100 : 0;
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Ultrabeam VL2.3</h2>
|
||||
<div class="header-right">
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<!-- Current Status -->
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Frequency</div>
|
||||
<div class="status-value freq">{(frequency / 1000).toFixed(3)} MHz</div>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<div class="status-label">Band</div>
|
||||
<div class="status-value band">{detectedBand}</div>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<div class="status-label">Direction</div>
|
||||
<div class="status-value direction">{directionNames[direction]}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Track Control -->
|
||||
<div class="control-section compact">
|
||||
<h3>Auto Tracking</h3>
|
||||
<div class="auto-track-controls">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
|
||||
<span>Enable Auto-Track from Tuner</span>
|
||||
</label>
|
||||
|
||||
<div class="threshold-group">
|
||||
<label for="threshold-select">Threshold:</label>
|
||||
<select id="threshold-select" bind:value={autoTrackThreshold} on:change={updateAutoTrack}>
|
||||
{#each thresholdOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direction buttons on separate line -->
|
||||
<div class="direction-buttons">
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={targetDirection === 0}
|
||||
on:click={() => { targetDirection = 0; setDirection(); }}
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={targetDirection === 1}
|
||||
on:click={() => { targetDirection = 1; setDirection(); }}
|
||||
>
|
||||
180°
|
||||
</button>
|
||||
<button
|
||||
class="dir-btn"
|
||||
class:active={targetDirection === 2}
|
||||
on:click={() => { targetDirection = 2; setDirection(); }}
|
||||
>
|
||||
Bi-Dir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motor Progress -->
|
||||
{#if motorsMoving > 0}
|
||||
<div class="progress-section">
|
||||
<h3>Motors Moving...</h3>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">{progressCurrent} / 60 ({progressPercent.toFixed(0)}%)</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Element Lengths Display - HIDDEN: Command 9 returns status instead -->
|
||||
<!--
|
||||
<div class="elements-section">
|
||||
<h3>Element Lengths (mm)</h3>
|
||||
<div class="elements-grid">
|
||||
{#each elementLengths.slice(0, 3) as length, i}
|
||||
{#if length > 0 && elementNames[i]}
|
||||
<div class="element-item">
|
||||
<div class="element-label">{elementNames[i]}</div>
|
||||
<div class="element-value">{length} mm</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Calibration Mode - HIDDEN: Command 9 doesn't work -->
|
||||
<!--
|
||||
<div class="calibration-section">
|
||||
<div class="section-header">
|
||||
<h3>Calibration</h3>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class:active={calibrationMode}
|
||||
on:click={() => calibrationMode = !calibrationMode}
|
||||
>
|
||||
{calibrationMode ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if calibrationMode}
|
||||
<div class="calibration-controls">
|
||||
<div class="input-group">
|
||||
<label for="element-select">Element</label>
|
||||
<select id="element-select" bind:value={selectedElement}>
|
||||
{#each elementLengths.slice(0, 3) as length, i}
|
||||
{#if length > 0 && elementNames[i]}
|
||||
<option value={i}>{elementNames[i]} ({length}mm)</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="adjustment">Adjustment (mm)</label>
|
||||
<input
|
||||
id="adjustment"
|
||||
type="number"
|
||||
bind:value={elementAdjustment}
|
||||
step="1"
|
||||
placeholder="±10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn-caution" on:click={adjustElement}>
|
||||
<span class="icon">⚙️</span>
|
||||
Apply Adjustment
|
||||
</button>
|
||||
|
||||
<p class="warning-text">
|
||||
⚠️ Calibration changes are saved after 12 seconds. Do not turn off during this time.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="btn-danger" on:click={retract}>
|
||||
<span class="icon">↓</span>
|
||||
Retract Elements
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #666;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Status Grid */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #4fc3f7;
|
||||
text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.status-value.freq {
|
||||
color: #66bb6a;
|
||||
font-size: 22px;
|
||||
text-shadow: 0 0 10px rgba(102, 187, 106, 0.5);
|
||||
}
|
||||
|
||||
.status-value.band {
|
||||
color: #ffa726;
|
||||
text-shadow: 0 0 10px rgba(255, 167, 38, 0.5);
|
||||
}
|
||||
|
||||
.status-value.direction {
|
||||
color: #ab47bc;
|
||||
text-shadow: 0 0 10px rgba(171, 71, 188, 0.5);
|
||||
}
|
||||
|
||||
/* Control Section */
|
||||
.control-section {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.control-section.compact {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auto-track-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.threshold-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.threshold-group label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.direction-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dir-btn {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(79, 195, 247, 0.08);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dir-btn:hover {
|
||||
border-color: rgba(79, 195, 247, 0.6);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(79, 195, 247, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dir-btn.active {
|
||||
border-color: #4fc3f7;
|
||||
color: #4fc3f7;
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
box-shadow: 0 0 20px rgba(79, 195, 247, 0.4);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Progress Section */
|
||||
.progress-section {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.progress-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #4fc3f7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 20px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4fc3f7, #03a9f4);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
export let status;
|
||||
|
||||
$: relays = status?.relays || [];
|
||||
$: connected = status?.connected || false;
|
||||
|
||||
const relayNames = {
|
||||
1: 'Power Supply',
|
||||
@@ -51,102 +52,254 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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="card">
|
||||
<div class="card-header">
|
||||
<h2>WebSwitch</h2>
|
||||
<span class="status-dot" class:disconnected={!connected}></span>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<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>
|
||||
<div class="relay-card" class:relay-on={isOn}>
|
||||
<div class="relay-info">
|
||||
<div class="relay-details">
|
||||
<div class="relay-name">{relayNames[relayNum]}</div>
|
||||
<div class="relay-status">{isOn ? 'ON' : 'OFF'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="relay-toggle"
|
||||
class:active={isOn}
|
||||
class:loading={loading[relayNum]}
|
||||
disabled={loading[relayNum]}
|
||||
on:click={() => toggleRelay(relayNum)}
|
||||
>
|
||||
<div class="toggle-icon"></div>
|
||||
<div class="toggle-track">
|
||||
<div class="toggle-thumb"></div>
|
||||
</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>
|
||||
<button class="control-btn all-on" on:click={allOn}>
|
||||
<span class="btn-icon">⚡</span>
|
||||
ALL ON
|
||||
</button>
|
||||
<button class="control-btn all-off" on:click={allOff}>
|
||||
<span class="btn-icon">⏻</span>
|
||||
ALL OFF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.webswitch-card {
|
||||
min-width: 280px;
|
||||
.card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
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 {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(79, 195, 247, 0.05);
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
.relay-name {
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.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;
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
transition: transform 0.3s ease;
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.relay-toggle.active .toggle-icon {
|
||||
transform: translateX(28px);
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
box-shadow: 0 0 8px #f44336;
|
||||
}
|
||||
|
||||
.controls {
|
||||
.metrics {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
flex: 1;
|
||||
/* Relays */
|
||||
.relays {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.relay-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.relay-card.relay-on {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border-color: rgba(76, 175, 80, 0.3);
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
.relay-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.relay-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.relay-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.relay-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.relay-card.relay-on .relay-status {
|
||||
color: #4caf50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.relay-toggle {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.relay-toggle:hover .toggle-track {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.relay-toggle.active .toggle-track {
|
||||
background: linear-gradient(135deg, #4caf50, #66bb6a);
|
||||
border-color: #4caf50;
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.relay-toggle.active .toggle-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.relay-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.all-on {
|
||||
background: linear-gradient(135deg, #4caf50, #66bb6a);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.all-on:hover {
|
||||
box-shadow: 0 6px 16px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.all-off {
|
||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.all-off:hover {
|
||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -47,23 +47,28 @@ export const api = {
|
||||
|
||||
// Tuner
|
||||
tuner: {
|
||||
operate: (operate) => request('/tuner/operate', {
|
||||
setOperate: (value) => request('/tuner/operate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ operate }),
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
tune: () => request('/tuner/tune', { method: 'POST' }),
|
||||
antenna: (antenna) => request('/tuner/antenna', {
|
||||
setBypass: (value) => request('/tuner/bypass', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ antenna }),
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
autoTune: () => request('/tuner/autotune', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Antenna Genius
|
||||
antenna: {
|
||||
set: (radio, antenna) => request('/antenna/set', {
|
||||
selectAntenna: (port, antenna) => request('/antenna/select', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ radio, antenna }),
|
||||
body: JSON.stringify({ port, antenna }),
|
||||
}),
|
||||
deselectAntenna: (port, antenna) => request('/antenna/deselect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ port, antenna }),
|
||||
}),
|
||||
reboot: () => request('/antenna/reboot', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Power Genius
|
||||
@@ -72,5 +77,37 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
setOperate: (value) => request('/power/operate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
},
|
||||
|
||||
// Rotator Genius
|
||||
rotator: {
|
||||
setHeading: (heading) => request('/rotator/heading', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ heading }),
|
||||
}),
|
||||
rotateCW: () => request('/rotator/cw', { method: 'POST' }),
|
||||
rotateCCW: () => request('/rotator/ccw', { method: 'POST' }),
|
||||
stop: () => request('/rotator/stop', { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Ultrabeam
|
||||
ultrabeam: {
|
||||
setFrequency: (frequency, direction) => request('/ultrabeam/frequency', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ frequency, direction }),
|
||||
}),
|
||||
retract: () => request('/ultrabeam/retract', { method: 'POST' }),
|
||||
setAutoTrack: (enabled, threshold) => request('/ultrabeam/autotrack', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled, threshold }),
|
||||
}),
|
||||
setDirection: (direction) => request('/ultrabeam/direction', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ direction }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,7 @@ class WebSocketService {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'update') {
|
||||
console.log('System status updated:', message.data);
|
||||
systemStatus.set(message.data);
|
||||
lastUpdate.set(new Date(message.timestamp));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user