corrected all bugs
This commit is contained in:
@@ -4,33 +4,39 @@ server:
|
|||||||
|
|
||||||
devices:
|
devices:
|
||||||
webswitch:
|
webswitch:
|
||||||
host: "10.10.10.119"
|
host: "10.10.10.100"
|
||||||
|
|
||||||
power_genius:
|
power_genius:
|
||||||
host: "10.10.10.128"
|
host: "10.10.10.110"
|
||||||
port: 9008
|
port: 4001
|
||||||
|
|
||||||
tuner_genius:
|
tuner_genius:
|
||||||
host: "10.10.10.129"
|
host: "10.10.10.111"
|
||||||
port: 9010
|
port: 4001
|
||||||
|
|
||||||
antenna_genius:
|
antenna_genius:
|
||||||
host: "10.10.10.130"
|
host: "10.10.10.112"
|
||||||
port: 9007
|
port: 4001
|
||||||
|
|
||||||
rotator_genius:
|
rotator_genius:
|
||||||
host: "10.10.10.121"
|
host: "10.10.10.113"
|
||||||
port: 9006
|
port: 4533
|
||||||
|
|
||||||
ultrabeam:
|
ultrabeam:
|
||||||
host: "10.10.10.124"
|
host: "10.10.10.124"
|
||||||
port: 4210
|
port: 4210
|
||||||
|
|
||||||
|
flexradio:
|
||||||
|
enabled: true
|
||||||
|
host: "10.10.10.120"
|
||||||
|
port: 4992
|
||||||
|
interlock_name: "Ultrabeam"
|
||||||
|
|
||||||
weather:
|
weather:
|
||||||
openweathermap_api_key: "YOUR_API_KEY_HERE"
|
openweathermap_api_key: ""
|
||||||
lightning_enabled: true
|
lightning_enabled: false
|
||||||
|
|
||||||
location:
|
location:
|
||||||
latitude: 46.2833
|
latitude: 46.2814
|
||||||
longitude: 6.2333
|
longitude: 6.2389
|
||||||
callsign: "F4BPO"
|
callsign: "F4BPO"
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius"
|
||||||
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/flexradio"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius"
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
"git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius"
|
||||||
@@ -25,6 +26,7 @@ type DeviceManager struct {
|
|||||||
antennaGenius *antennagenius.Client
|
antennaGenius *antennagenius.Client
|
||||||
rotatorGenius *rotatorgenius.Client
|
rotatorGenius *rotatorgenius.Client
|
||||||
ultrabeam *ultrabeam.Client
|
ultrabeam *ultrabeam.Client
|
||||||
|
flexRadio *flexradio.Client
|
||||||
solarClient *solar.Client
|
solarClient *solar.Client
|
||||||
weatherClient *weather.Client
|
weatherClient *weather.Client
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ type DeviceManager struct {
|
|||||||
ultrabeamDirectionSet bool // True if user has explicitly set a direction
|
ultrabeamDirectionSet bool // True if user has explicitly set a direction
|
||||||
lastFreqUpdateTime time.Time // Last time we sent frequency update
|
lastFreqUpdateTime time.Time // Last time we sent frequency update
|
||||||
freqUpdateCooldown time.Duration // Minimum time between updates
|
freqUpdateCooldown time.Duration // Minimum time between updates
|
||||||
|
|
||||||
|
// Cached Ultrabeam state for FlexRadio interlock (avoid mutex contention)
|
||||||
|
ultrabeamMotorsMoving int
|
||||||
|
ultrabeamStateMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemStatus struct {
|
type SystemStatus struct {
|
||||||
@@ -51,6 +57,7 @@ type SystemStatus struct {
|
|||||||
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
AntennaGenius *antennagenius.Status `json:"antenna_genius"`
|
||||||
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
RotatorGenius *rotatorgenius.Status `json:"rotator_genius"`
|
||||||
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
|
Ultrabeam *ultrabeam.Status `json:"ultrabeam"`
|
||||||
|
FlexRadio *flexradio.Status `json:"flexradio,omitempty"`
|
||||||
Solar *solar.SolarData `json:"solar"`
|
Solar *solar.SolarData `json:"solar"`
|
||||||
Weather *weather.WeatherData `json:"weather"`
|
Weather *weather.WeatherData `json:"weather"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -60,12 +67,12 @@ func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager {
|
|||||||
return &DeviceManager{
|
return &DeviceManager{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
updateInterval: 1 * time.Second, // Update status every second
|
updateInterval: 200 * time.Millisecond, // Update status every second
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
freqThreshold: 25000, // 25 kHz default
|
freqThreshold: 25000, // 25 kHz default
|
||||||
autoTrackEnabled: true, // Enabled by default
|
autoTrackEnabled: true, // Enabled by default
|
||||||
ultrabeamDirection: 0, // Normal direction by default
|
ultrabeamDirection: 0, // Normal direction by default
|
||||||
freqUpdateCooldown: 2 * time.Second, // Wait 2 seconds between updates
|
freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +114,31 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
dm.config.Devices.Ultrabeam.Port,
|
dm.config.Devices.Ultrabeam.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize FlexRadio if enabled
|
||||||
|
if dm.config.Devices.FlexRadio.Enabled {
|
||||||
|
log.Printf("Initializing FlexRadio: host=%s port=%d", dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port)
|
||||||
|
dm.flexRadio = flexradio.New(
|
||||||
|
dm.config.Devices.FlexRadio.Host,
|
||||||
|
dm.config.Devices.FlexRadio.Port,
|
||||||
|
dm.config.Devices.FlexRadio.InterlockName,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set callback to check if transmit is allowed (based on Ultrabeam motors)
|
||||||
|
// Use cached state to avoid mutex contention with update loop
|
||||||
|
dm.flexRadio.SetTransmitCheckCallback(func() bool {
|
||||||
|
dm.ultrabeamStateMu.RLock()
|
||||||
|
motorsMoving := dm.ultrabeamMotorsMoving
|
||||||
|
dm.ultrabeamStateMu.RUnlock()
|
||||||
|
// Block transmit if motors are moving
|
||||||
|
return motorsMoving == 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set callback for immediate frequency changes (no waiting for update cycle)
|
||||||
|
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
|
||||||
|
dm.handleFrequencyChange(freqMHz)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Solar data client
|
// Initialize Solar data client
|
||||||
dm.solarClient = solar.New()
|
dm.solarClient = solar.New()
|
||||||
|
|
||||||
@@ -154,6 +186,17 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
}()
|
}()
|
||||||
log.Println("Ultrabeam goroutine launched")
|
log.Println("Ultrabeam goroutine launched")
|
||||||
|
|
||||||
|
// Start FlexRadio if enabled
|
||||||
|
if dm.flexRadio != nil {
|
||||||
|
log.Println("Starting FlexRadio connection...")
|
||||||
|
go func() {
|
||||||
|
if err := dm.flexRadio.Start(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to start FlexRadio: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Println("FlexRadio goroutine launched")
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("Device manager initialized")
|
log.Println("Device manager initialized")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -164,6 +207,69 @@ func (dm *DeviceManager) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleFrequencyChange is called immediately when FlexRadio frequency changes
|
||||||
|
// This provides instant auto-track response instead of waiting for updateStatus cycle
|
||||||
|
func (dm *DeviceManager) handleFrequencyChange(freqMHz float64) {
|
||||||
|
// Check if ultrabeam is initialized
|
||||||
|
if dm.ultrabeam == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cooldown first
|
||||||
|
timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime)
|
||||||
|
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached status instead of calling GetStatus (which can block)
|
||||||
|
dm.statusMu.RLock()
|
||||||
|
hasStatus := dm.lastStatus != nil
|
||||||
|
var ubStatus *ultrabeam.Status
|
||||||
|
if hasStatus {
|
||||||
|
ubStatus = dm.lastStatus.Ultrabeam
|
||||||
|
}
|
||||||
|
dm.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if ubStatus == nil || !ubStatus.Connected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't update if motors are already moving
|
||||||
|
if ubStatus.MotorsMoving != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
freqKhz := int(freqMHz * 1000)
|
||||||
|
ultrabeamFreqKhz := ubStatus.Frequency
|
||||||
|
|
||||||
|
// Only track if in Ultrabeam range (7-54 MHz)
|
||||||
|
if freqKhz < 7000 || freqKhz > 54000 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
freqDiff := freqKhz - ultrabeamFreqKhz
|
||||||
|
if freqDiff < 0 {
|
||||||
|
freqDiff = -freqDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
freqDiffHz := freqDiff * 1000
|
||||||
|
|
||||||
|
if freqDiffHz >= dm.freqThreshold {
|
||||||
|
directionToUse := dm.ultrabeamDirection
|
||||||
|
if !dm.ultrabeamDirectionSet && ubStatus.Direction != 0 {
|
||||||
|
directionToUse = ubStatus.Direction
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Auto-track (immediate): Updating to %d kHz (diff=%d kHz)", freqKhz, freqDiff)
|
||||||
|
|
||||||
|
if err := dm.ultrabeam.SetFrequency(freqKhz, directionToUse); err != nil {
|
||||||
|
log.Printf("Auto-track (immediate): Failed: %v", err)
|
||||||
|
} else {
|
||||||
|
dm.lastFreqUpdateTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (dm *DeviceManager) Stop() {
|
func (dm *DeviceManager) Stop() {
|
||||||
log.Println("Stopping device manager...")
|
log.Println("Stopping device manager...")
|
||||||
close(dm.stopChan)
|
close(dm.stopChan)
|
||||||
@@ -250,19 +356,57 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
if !dm.ultrabeamDirectionSet {
|
if !dm.ultrabeamDirectionSet {
|
||||||
dm.ultrabeamDirection = ubStatus.Direction
|
dm.ultrabeamDirection = ubStatus.Direction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache motors state for FlexRadio interlock callback
|
||||||
|
dm.ultrabeamStateMu.Lock()
|
||||||
|
previousMotors := dm.ultrabeamMotorsMoving
|
||||||
|
dm.ultrabeamMotorsMoving = ubStatus.MotorsMoving
|
||||||
|
dm.ultrabeamStateMu.Unlock()
|
||||||
|
|
||||||
|
// Log motor state changes
|
||||||
|
if previousMotors != ubStatus.MotorsMoving {
|
||||||
|
if ubStatus.MotorsMoving > 0 {
|
||||||
|
log.Printf("Ultrabeam: Motors STARTED (bitmask=%d)", ubStatus.MotorsMoving)
|
||||||
|
} else {
|
||||||
|
log.Printf("Ultrabeam: Motors STOPPED")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Ultrabeam error: %v", err)
|
log.Printf("Ultrabeam error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto frequency tracking: Update Ultrabeam when TunerGenius frequency differs from Ultrabeam
|
// FlexRadio (use direct cache access to avoid mutex contention)
|
||||||
if dm.autoTrackEnabled && status.TunerGenius != nil && status.TunerGenius.Connected && status.Ultrabeam != nil && status.Ultrabeam.Connected {
|
if dm.flexRadio != nil {
|
||||||
tunerFreqKhz := int(status.TunerGenius.FreqA) // TunerGenius frequency is already in kHz
|
// 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
|
||||||
|
// Priority: FlexRadio (fast) > TunerGenius (slow backup)
|
||||||
|
var radioFreqKhz int
|
||||||
|
var radioSource string
|
||||||
|
|
||||||
|
if dm.flexRadio != nil && status.FlexRadio != nil && status.FlexRadio.Connected && status.FlexRadio.Frequency > 0 {
|
||||||
|
// Use FlexRadio frequency (in MHz, convert to kHz)
|
||||||
|
radioFreqKhz = int(status.FlexRadio.Frequency * 1000)
|
||||||
|
radioSource = "FlexRadio"
|
||||||
|
} else if dm.autoTrackEnabled && 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
|
ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz
|
||||||
|
|
||||||
// Only do auto-track if frequency is in Ultrabeam range (40M-6M: 7000-54000 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
|
// This prevents retraction when slice is closed (FreqA becomes 0) or on out-of-range bands
|
||||||
if tunerFreqKhz >= 7000 && tunerFreqKhz <= 54000 {
|
if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 {
|
||||||
freqDiff := tunerFreqKhz - ultrabeamFreqKhz
|
freqDiff := radioFreqKhz - ultrabeamFreqKhz
|
||||||
if freqDiff < 0 {
|
if freqDiff < 0 {
|
||||||
freqDiff = -freqDiff
|
freqDiff = -freqDiff
|
||||||
}
|
}
|
||||||
@@ -284,10 +428,10 @@ func (dm *DeviceManager) updateStatus() {
|
|||||||
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
if timeSinceLastUpdate < dm.freqUpdateCooldown {
|
||||||
log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate)
|
log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Auto-track: Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", freqDiff, tunerFreqKhz, directionToUse)
|
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
|
// Send to Ultrabeam with saved or current direction
|
||||||
if err := dm.ultrabeam.SetFrequency(tunerFreqKhz, directionToUse); err != nil {
|
if err := dm.ultrabeam.SetFrequency(radioFreqKhz, directionToUse); err != nil {
|
||||||
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
|
log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
|
log.Printf("Auto-track: Successfully sent frequency to Ultrabeam")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type DevicesConfig struct {
|
|||||||
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"`
|
||||||
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"`
|
||||||
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
|
Ultrabeam UltrabeamConfig `yaml:"ultrabeam"`
|
||||||
|
FlexRadio FlexRadioConfig `yaml:"flexradio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSwitchConfig struct {
|
type WebSwitchConfig struct {
|
||||||
@@ -57,6 +58,13 @@ type UltrabeamConfig struct {
|
|||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FlexRadioConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
InterlockName string `yaml:"interlock_name"`
|
||||||
|
}
|
||||||
|
|
||||||
type WeatherConfig struct {
|
type WeatherConfig struct {
|
||||||
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
OpenWeatherMapAPIKey string `yaml:"openweathermap_api_key"`
|
||||||
LightningEnabled bool `yaml:"lightning_enabled"`
|
LightningEnabled bool `yaml:"lightning_enabled"`
|
||||||
|
|||||||
412
internal/devices/flexradio/flexradio.go
Normal file
412
internal/devices/flexradio/flexradio.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
interlockID string
|
||||||
|
interlockName string
|
||||||
|
interlockMu sync.RWMutex
|
||||||
|
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
|
||||||
|
cmdSeq int
|
||||||
|
cmdSeqMu sync.Mutex
|
||||||
|
|
||||||
|
running bool
|
||||||
|
stopChan chan struct{}
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
checkTransmitAllowed func() bool
|
||||||
|
onFrequencyChange func(freqMHz float64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int, interlockName string) *Client {
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
interlockName: interlockName,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
lastStatus: &Status{
|
||||||
|
Connected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTransmitCheckCallback sets the callback function to check if transmit is allowed
|
||||||
|
func (c *Client) SetTransmitCheckCallback(callback func() bool) {
|
||||||
|
c.checkTransmitAllowed = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequencyChangeCallback sets the callback function called when frequency changes
|
||||||
|
func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) {
|
||||||
|
c.onFrequencyChange = 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()
|
||||||
|
|
||||||
|
// Create interlock (no sleep needed, connection is synchronous)
|
||||||
|
if err := c.createInterlock(); err != nil {
|
||||||
|
log.Printf("FlexRadio: Failed to create interlock: %v", err)
|
||||||
|
return 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) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.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 := c.conn.Write([]byte(fullCmd))
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
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>
|
||||||
|
parts := strings.SplitN(msg, "|", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := parts[1]
|
||||||
|
if status != "0" {
|
||||||
|
log.Printf("FlexRadio: Command error: status=%s", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is interlock create response
|
||||||
|
if len(parts) >= 3 && parts[2] != "" {
|
||||||
|
// This is likely the interlock ID
|
||||||
|
interlockID := parts[2]
|
||||||
|
c.interlockMu.Lock()
|
||||||
|
c.interlockID = interlockID
|
||||||
|
c.interlockMu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("FlexRadio: Interlock created with ID: %s", interlockID)
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus.InterlockID = interlockID
|
||||||
|
c.lastStatus.InterlockState = InterlockStateReady
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 interlock state changes
|
||||||
|
if state, ok := statusMap["state"]; ok && strings.Contains(msg, "interlock") {
|
||||||
|
c.handleInterlockState(state, statusMap)
|
||||||
|
}
|
||||||
|
// 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) handleInterlockState(state string, _ map[string]string) {
|
||||||
|
log.Printf("FlexRadio: Interlock state changed to: %s", state)
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus.InterlockState = state
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
// Handle PTT_REQUESTED - this is where we decide to allow or block transmit
|
||||||
|
if state == "PTT_REQUESTED" {
|
||||||
|
c.handlePTTRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handlePTTRequest() {
|
||||||
|
log.Println("FlexRadio: PTT requested, checking if transmit is allowed...")
|
||||||
|
|
||||||
|
c.interlockMu.RLock()
|
||||||
|
interlockID := c.interlockID
|
||||||
|
c.interlockMu.RUnlock()
|
||||||
|
|
||||||
|
if interlockID == "" {
|
||||||
|
log.Println("FlexRadio: No interlock ID, cannot respond to PTT request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if transmit is allowed via callback
|
||||||
|
allowed := true
|
||||||
|
if c.checkTransmitAllowed != nil {
|
||||||
|
allowed = c.checkTransmitAllowed()
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
log.Println("FlexRadio: Transmit ALLOWED - sending ready")
|
||||||
|
c.sendCommand(fmt.Sprintf("interlock ready %s", interlockID))
|
||||||
|
// Update state immediately for UI
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus.InterlockState = InterlockStateReady
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
} else {
|
||||||
|
log.Println("FlexRadio: Transmit BLOCKED - sending not_ready")
|
||||||
|
c.sendCommand(fmt.Sprintf("interlock not_ready %s", interlockID))
|
||||||
|
// Update state immediately for UI
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus.InterlockState = InterlockStateNotReady
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) createInterlock() error {
|
||||||
|
log.Printf("FlexRadio: Creating interlock with name: %s", c.interlockName)
|
||||||
|
|
||||||
|
// Format: interlock create type=ant name=<name> serial=<serial>
|
||||||
|
cmd := fmt.Sprintf("interlock create type=ant name=%s serial=ShackMaster", c.interlockName)
|
||||||
|
|
||||||
|
_, err := c.sendCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create interlock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) 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"
|
||||||
|
)
|
||||||
@@ -106,9 +106,12 @@ func (c *Client) pollLoop() {
|
|||||||
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
pollCount := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
pollCount++
|
||||||
|
|
||||||
// Try to connect if not connected
|
// Try to connect if not connected
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
@@ -161,6 +164,10 @@ func (c *Client) pollLoop() {
|
|||||||
status.ProgressTotal = progress[0]
|
status.ProgressTotal = progress[0]
|
||||||
status.ProgressCurrent = progress[1]
|
status.ProgressCurrent = progress[1]
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Motors stopped - reset progress
|
||||||
|
status.ProgressTotal = 0
|
||||||
|
status.ProgressCurrent = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
@@ -388,69 +395,6 @@ func (c *Client) queryStatus() (*Status, error) {
|
|||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryElementLengths queries element lengths (command 9)
|
|
||||||
func (c *Client) queryElementLengths() ([]int, error) {
|
|
||||||
reply, err := c.sendCommand(CMD_READ_BANDS, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: log raw bytes
|
|
||||||
log.Printf("Ultrabeam element lengths raw reply (%d bytes): %v", len(reply), reply)
|
|
||||||
|
|
||||||
// Try to extract 6 words - the protocol says 6 words (12 bytes)
|
|
||||||
// But we're receiving 14 bytes, so there might be padding
|
|
||||||
|
|
||||||
if len(reply) < 12 {
|
|
||||||
return nil, fmt.Errorf("element lengths reply too short: %d bytes", len(reply))
|
|
||||||
}
|
|
||||||
|
|
||||||
lengths := make([]int, 6)
|
|
||||||
|
|
||||||
// Try different interpretations
|
|
||||||
log.Printf("=== Attempting different parsings ===")
|
|
||||||
|
|
||||||
// Method 1: Standard little-endian from byte 0
|
|
||||||
log.Printf("Method 1 (little-endian from 0):")
|
|
||||||
for i := 0; i < 6 && i*2+1 < len(reply); i++ {
|
|
||||||
lo := int(reply[i*2])
|
|
||||||
hi := int(reply[i*2+1])
|
|
||||||
val := lo | (hi << 8)
|
|
||||||
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2, i*2+1, lo, hi, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Big-endian from byte 0
|
|
||||||
log.Printf("Method 2 (big-endian from 0):")
|
|
||||||
for i := 0; i < 6 && i*2+1 < len(reply); i++ {
|
|
||||||
hi := int(reply[i*2])
|
|
||||||
lo := int(reply[i*2+1])
|
|
||||||
val := lo | (hi << 8)
|
|
||||||
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2, i*2+1, hi, lo, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Skip first 2 bytes, then little-endian
|
|
||||||
log.Printf("Method 3 (skip 2 bytes, little-endian):")
|
|
||||||
for i := 0; i < 6 && i*2+3 < len(reply); i++ {
|
|
||||||
lo := int(reply[i*2+2])
|
|
||||||
hi := int(reply[i*2+3])
|
|
||||||
val := lo | (hi << 8)
|
|
||||||
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2+2, i*2+3, lo, hi, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, use method 1 (original)
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
if i*2+1 >= len(reply) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
lo := int(reply[i*2])
|
|
||||||
hi := int(reply[i*2+1])
|
|
||||||
lengths[i] = lo | (hi << 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Final lengths: %v", lengths)
|
|
||||||
return lengths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryProgress queries motor progress (command 10)
|
// queryProgress queries motor progress (command 10)
|
||||||
func (c *Client) queryProgress() ([]int, error) {
|
func (c *Client) queryProgress() ([]int, error) {
|
||||||
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
||||||
|
|||||||
@@ -112,13 +112,13 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<WebSwitch status={status?.webswitch} />
|
<WebSwitch status={status?.webswitch} />
|
||||||
<PowerGenius status={status?.power_genius} />
|
<PowerGenius status={status?.power_genius} />
|
||||||
<TunerGenius status={status?.tuner_genius} />
|
<TunerGenius status={status?.tuner_genius} flexradio={status?.flexradio} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<AntennaGenius status={status?.antenna_genius} />
|
<AntennaGenius status={status?.antenna_genius} />
|
||||||
<Ultrabeam status={status?.ultrabeam} />
|
<Ultrabeam status={status?.ultrabeam} flexradio={status?.flexradio} />
|
||||||
<RotatorGenius status={status?.rotator_genius} />
|
<RotatorGenius status={status?.rotator_genius} ultrabeam={status?.ultrabeam} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -7,15 +7,6 @@
|
|||||||
$: powerReflected = status?.power_reflected || 0;
|
$: powerReflected = status?.power_reflected || 0;
|
||||||
$: swr = status?.swr || 1.0;
|
$: swr = status?.swr || 1.0;
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
$: if (status) {
|
|
||||||
console.log('PowerGenius status update:', {
|
|
||||||
powerForward: status.power_forward,
|
|
||||||
swr: status.swr,
|
|
||||||
state: status.state,
|
|
||||||
connected: status.connected
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$: voltage = status?.voltage || 0;
|
$: voltage = status?.voltage || 0;
|
||||||
$: vdd = status?.vdd || 0;
|
$: vdd = status?.vdd || 0;
|
||||||
$: current = status?.current || 0;
|
$: current = status?.current || 0;
|
||||||
|
|||||||
@@ -2,31 +2,66 @@
|
|||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
export let ultrabeam = null;
|
||||||
|
|
||||||
let heading = 0;
|
let heading = null; // Start with null instead of 0
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
|
||||||
// Update heading only if we have a valid value from status
|
// Get Ultrabeam direction mode: 0=Normal, 1=180°, 2=Bi-Dir
|
||||||
|
$: ultrabeamDirection = ultrabeam?.direction ?? 0;
|
||||||
|
|
||||||
|
// Update heading with detailed logging to debug
|
||||||
$: if (status?.heading !== undefined && status?.heading !== null) {
|
$: if (status?.heading !== undefined && status?.heading !== null) {
|
||||||
heading = status.heading;
|
const newHeading = status.heading;
|
||||||
|
const oldHeading = heading;
|
||||||
|
|
||||||
|
console.log(`RotatorGenius heading update: ${oldHeading} -> ${newHeading}`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
console.log(` ✓ Updated to ${heading}°`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display heading: use cached value or 0 if never set
|
||||||
|
$: displayHeading = heading !== null ? heading : 0;
|
||||||
|
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
|
|
||||||
let targetHeading = 0;
|
let targetHeading = 0;
|
||||||
|
let hasTarget = false;
|
||||||
|
|
||||||
|
// Clear target when we reach it (within 5 degrees)
|
||||||
|
$: if (hasTarget && heading !== null) {
|
||||||
|
const diff = Math.abs(heading - targetHeading);
|
||||||
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
|
if (wrappedDiff < 5) {
|
||||||
|
hasTarget = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function goToHeading() {
|
async function goToHeading() {
|
||||||
if (targetHeading < 0 || targetHeading > 359) {
|
if (targetHeading < 0 || targetHeading > 359) {
|
||||||
alert('Heading must be between 0 and 359');
|
// Removed alert popup - check console for errors
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
hasTarget = true; // Mark that we have a target
|
||||||
// Subtract 10 degrees to compensate for rotator momentum
|
// Subtract 10 degrees to compensate for rotator momentum
|
||||||
const adjustedHeading = (targetHeading - 10 + 360) % 360;
|
const adjustedHeading = (targetHeading - 10 + 360) % 360;
|
||||||
await api.rotator.setHeading(adjustedHeading);
|
await api.rotator.setHeading(adjustedHeading);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set heading:', err);
|
console.error('Failed to set heading:', err);
|
||||||
alert('Failed to rotate');
|
hasTarget = false;
|
||||||
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +124,12 @@
|
|||||||
<div class="heading-controls-row">
|
<div class="heading-controls-row">
|
||||||
<div class="heading-display-compact">
|
<div class="heading-display-compact">
|
||||||
<div class="heading-label">CURRENT HEADING</div>
|
<div class="heading-label">CURRENT HEADING</div>
|
||||||
<div class="heading-value">{heading}°</div>
|
<div class="heading-value">
|
||||||
|
{displayHeading}°
|
||||||
|
{#if hasTarget}
|
||||||
|
<span class="target-indicator">→ {targetHeading}°</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-compact">
|
<div class="controls-compact">
|
||||||
@@ -107,7 +147,12 @@
|
|||||||
|
|
||||||
<!-- Map with Beam -->
|
<!-- Map with Beam -->
|
||||||
<div class="map-container">
|
<div class="map-container">
|
||||||
<svg viewBox="0 0 300 300" class="map-svg clickable-compass" on:click={handleCompassClick}>
|
<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>
|
<defs>
|
||||||
<!-- Gradient for beam -->
|
<!-- Gradient for beam -->
|
||||||
<radialGradient id="beamGradient">
|
<radialGradient id="beamGradient">
|
||||||
@@ -126,8 +171,30 @@
|
|||||||
|
|
||||||
<!-- Rotated group for beam -->
|
<!-- Rotated group for beam -->
|
||||||
<g transform="translate(150, 150)">
|
<g transform="translate(150, 150)">
|
||||||
|
<!-- Physical antenna direction indicator (only in 180° or Bi-Dir mode) -->
|
||||||
|
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
|
||||||
|
<g transform="rotate({displayHeading})">
|
||||||
|
<!-- Gray dashed line showing physical antenna direction -->
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-125"
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-dasharray="5,5"
|
||||||
|
opacity="0.6"/>
|
||||||
|
<!-- Small triangle at end to show physical direction -->
|
||||||
|
<g transform="translate(0, -125)">
|
||||||
|
<polygon points="0,-8 -5,5 5,5"
|
||||||
|
fill="rgba(255, 255, 255, 0.4)"
|
||||||
|
stroke="rgba(255, 255, 255, 0.5)"
|
||||||
|
stroke-width="1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Beam (rotates with heading) -->
|
<!-- Beam (rotates with heading) -->
|
||||||
<g transform="rotate({heading})">
|
<g transform="rotate({displayHeading})">
|
||||||
|
|
||||||
|
<!-- NORMAL MODE (0): Forward beam only -->
|
||||||
|
{#if ultrabeamDirection === 0}
|
||||||
<!-- Beam sector (±15° = 30° total beamwidth) -->
|
<!-- 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}
|
<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"
|
A 130,130 0 0,1 {Math.sin(15 * Math.PI/180) * 130},{-Math.cos(15 * Math.PI/180) * 130} Z"
|
||||||
@@ -148,7 +215,94 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
style="filter: drop-shadow(0 0 10px rgba(79, 195, 247, 1))"/>
|
||||||
</g>
|
</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>
|
</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 (if we have a target) -->
|
||||||
|
{#if hasTarget}
|
||||||
|
<g transform="rotate({targetHeading})">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-120"
|
||||||
|
stroke="#ffc107"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-dasharray="8,4"
|
||||||
|
opacity="0.9"/>
|
||||||
|
<g transform="translate(0, -120)">
|
||||||
|
<polygon points="0,-15 -10,10 0,5 10,10"
|
||||||
|
fill="#ffc107"
|
||||||
|
stroke="#ff9800"
|
||||||
|
stroke-width="2"
|
||||||
|
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 - JN36dg) -->
|
<!-- Center dot (your QTH - JN36dg) -->
|
||||||
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
||||||
@@ -175,6 +329,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
<!-- Go To Heading -->
|
<!-- Go To Heading -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,14 +398,6 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Heading Display */
|
|
||||||
.heading-display {
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(79, 195, 247, 0.1);
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-controls-row {
|
.heading-controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -259,45 +423,39 @@
|
|||||||
.btn-mini {
|
.btn-mini {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: none;
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(79, 195, 247, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-mini.ccw {
|
.btn-mini:hover {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
border-color: rgba(79, 195, 247, 0.6);
|
||||||
color: white;
|
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 {
|
.btn-mini.ccw:hover {
|
||||||
transform: rotate(-15deg) scale(1.05);
|
transform: translateY(-1px) rotate(-5deg);
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mini.stop {
|
|
||||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mini.stop:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 4px 12px rgba(245, 87, 108, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mini.cw {
|
|
||||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-mini.cw:hover {
|
.btn-mini.cw:hover {
|
||||||
transform: rotate(15deg) scale(1.05);
|
transform: translateY(-1px) rotate(5deg);
|
||||||
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
|
}
|
||||||
|
|
||||||
|
.btn-mini.stop:hover {
|
||||||
|
border-color: #f44336;
|
||||||
|
color: #f44336;
|
||||||
|
background: rgba(244, 67, 54, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-label {
|
.heading-label {
|
||||||
@@ -315,6 +473,20 @@
|
|||||||
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.target-indicator {
|
||||||
|
font-size: 24px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes targetPulse {
|
||||||
|
0%, 100% { opacity: 0.8; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Map */
|
/* Map */
|
||||||
.map-container {
|
.map-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -324,6 +496,24 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: rgba(10, 22, 40, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.map-svg {
|
.map-svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
@@ -352,70 +542,4 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Go To Heading */
|
|
||||||
.goto-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.go-btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(135deg, var(--accent-cyan), #0288d1);
|
|
||||||
color: #000;
|
|
||||||
transition: all 0.2s;
|
|
||||||
box-shadow: 0 4px 12px rgba(79, 195, 247, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.go-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 16px rgba(79, 195, 247, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Controls */
|
|
||||||
.controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn:hover {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.stop {
|
|
||||||
background: linear-gradient(135deg, #f44336, #d32f2f);
|
|
||||||
border-color: #f44336;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-btn.stop:hover {
|
|
||||||
box-shadow: 0 6px 16px rgba(244, 67, 54, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
export let status;
|
export let status;
|
||||||
|
export let flexradio = null;
|
||||||
|
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
$: frequency = status?.frequency || 0;
|
$: frequency = status?.frequency || 0;
|
||||||
@@ -13,6 +14,21 @@
|
|||||||
$: elementLengths = status?.element_lengths || [];
|
$: elementLengths = status?.element_lengths || [];
|
||||||
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
$: firmwareVersion = status ? `${status.firmware_major}.${status.firmware_minor}` : '0.0';
|
||||||
|
|
||||||
|
// FlexRadio interlock
|
||||||
|
$: interlockConnected = flexradio?.connected || false;
|
||||||
|
$: interlockState = flexradio?.interlock_state || null;
|
||||||
|
$: interlockColor = getInterlockColor(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 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
|
// Band 0=6M, 1=10M, 2=12M, 3=15M, 4=17M, 5=20M, 6=30M, 7=40M
|
||||||
const bandNames = [
|
const bandNames = [
|
||||||
@@ -102,7 +118,7 @@
|
|||||||
await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold);
|
await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update auto-track:', err);
|
console.error('Failed to update auto-track:', err);
|
||||||
alert('Failed to update auto-track settings');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +130,7 @@
|
|||||||
await api.ultrabeam.retract();
|
await api.ultrabeam.retract();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to retract:', err);
|
console.error('Failed to retract:', err);
|
||||||
alert('Failed to retract');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +138,11 @@
|
|||||||
try {
|
try {
|
||||||
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
const newLength = elementLengths[selectedElement] + elementAdjustment;
|
||||||
// TODO: Add API call when backend supports it
|
// TODO: Add API call when backend supports it
|
||||||
alert(`Would adjust element ${selectedElement} by ${elementAdjustment}mm to ${newLength}mm`);
|
// Removed alert popup - check console for errors
|
||||||
elementAdjustment = 0;
|
elementAdjustment = 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to adjust element:', err);
|
console.error('Failed to adjust element:', err);
|
||||||
alert('Failed to adjust element');
|
// Removed alert popup - check console for errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +153,18 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Ultrabeam VL2.3</h2>
|
<h2>Ultrabeam VL2.3</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
{#if interlockConnected && interlockState}
|
||||||
|
<div class="interlock-badge" style="border-color: {interlockColor}; color: {interlockColor}">
|
||||||
|
{interlockState === 'READY' ? '🔓 TX OK' :
|
||||||
|
interlockState === 'NOT_READY' ? '🔒 TX Block' :
|
||||||
|
interlockState === 'PTT_REQUESTED' ? '⏳ PTT' :
|
||||||
|
interlockState === 'TRANSMITTING' ? '📡 TX' : '❓'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<span class="status-dot" class:disconnected={!connected}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<!-- Current Status -->
|
<!-- Current Status -->
|
||||||
@@ -176,24 +202,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Direction buttons on separate line -->
|
||||||
<div class="direction-buttons">
|
<div class="direction-buttons">
|
||||||
<button
|
<button
|
||||||
class="dir-btn normal"
|
class="dir-btn"
|
||||||
class:active={targetDirection === 0}
|
class:active={targetDirection === 0}
|
||||||
on:click={() => { targetDirection = 0; setDirection(); }}
|
on:click={() => { targetDirection = 0; setDirection(); }}
|
||||||
>
|
>
|
||||||
Normal
|
Normal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="dir-btn rotate180"
|
class="dir-btn"
|
||||||
class:active={targetDirection === 1}
|
class:active={targetDirection === 1}
|
||||||
on:click={() => { targetDirection = 1; setDirection(); }}
|
on:click={() => { targetDirection = 1; setDirection(); }}
|
||||||
>
|
>
|
||||||
180°
|
180°
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="dir-btn bidir"
|
class="dir-btn"
|
||||||
class:active={targetDirection === 2}
|
class:active={targetDirection === 2}
|
||||||
on:click={() => { targetDirection = 2; setDirection(); }}
|
on:click={() => { targetDirection = 2; setDirection(); }}
|
||||||
>
|
>
|
||||||
@@ -201,7 +229,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Motor Progress -->
|
<!-- Motor Progress -->
|
||||||
{#if motorsMoving > 0}
|
{#if motorsMoving > 0}
|
||||||
@@ -310,6 +337,24 @@
|
|||||||
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
|
border-bottom: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interlock-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -447,267 +492,76 @@
|
|||||||
.direction-buttons {
|
.direction-buttons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
margin-top: 12px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dir-btn {
|
.dir-btn {
|
||||||
padding: 14px 20px;
|
padding: 12px 16px;
|
||||||
border: none;
|
border: 2px solid rgba(79, 195, 247, 0.3);
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
color: white;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.normal {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.normal:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.normal.active {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
box-shadow: 0 0 25px rgba(102, 126, 234, 0.8), 0 6px 20px rgba(102, 126, 234, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.rotate180 {
|
|
||||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.rotate180:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(245, 87, 108, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.rotate180.active {
|
|
||||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
||||||
box-shadow: 0 0 25px rgba(245, 87, 108, 0.8), 0 6px 20px rgba(245, 87, 108, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.bidir {
|
|
||||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.bidir:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-btn.bidir.active {
|
|
||||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
|
||||||
box-shadow: 0 0 25px rgba(79, 172, 254, 0.8), 0 6px 20px rgba(79, 172, 254, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.freq-control {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr auto;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"],
|
|
||||||
select {
|
|
||||||
background: rgba(15, 23, 42, 0.8);
|
|
||||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
font-size: 13px;
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="number"]:focus,
|
|
||||||
select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4fc3f7;
|
|
||||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
button {
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
display: flex;
|
color: rgba(255, 255, 255, 0.7);
|
||||||
align-items: center;
|
background: rgba(79, 195, 247, 0.08);
|
||||||
gap: 8px;
|
letter-spacing: 0.5px;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.dir-btn:hover {
|
||||||
background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%);
|
border-color: rgba(79, 195, 247, 0.6);
|
||||||
color: #fff;
|
color: rgba(255, 255, 255, 0.9);
|
||||||
box-shadow: 0 4px 16px rgba(79, 195, 247, 0.4);
|
background: rgba(79, 195, 247, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.dir-btn.active {
|
||||||
transform: translateY(-2px);
|
border-color: #4fc3f7;
|
||||||
box-shadow: 0 6px 20px rgba(79, 195, 247, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 4px 16px rgba(244, 67, 54, 0.4);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-caution {
|
|
||||||
background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 4px 16px rgba(255, 167, 38, 0.4);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-caution:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 6px 20px rgba(255, 167, 38, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-toggle {
|
|
||||||
background: rgba(79, 195, 247, 0.1);
|
|
||||||
color: #4fc3f7;
|
color: #4fc3f7;
|
||||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-toggle.active {
|
|
||||||
background: rgba(79, 195, 247, 0.2);
|
background: rgba(79, 195, 247, 0.2);
|
||||||
|
box-shadow: 0 0 20px rgba(79, 195, 247, 0.4);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
/* Progress Section */
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress */
|
|
||||||
.progress-section {
|
.progress-section {
|
||||||
background: rgba(15, 23, 42, 0.4);
|
background: rgba(79, 195, 247, 0.1);
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
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 {
|
.progress-bar {
|
||||||
width: 100%;
|
height: 20px;
|
||||||
height: 24px;
|
background: rgba(15, 23, 42, 0.6);
|
||||||
background: rgba(15, 23, 42, 0.8);
|
border-radius: 10px;
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 12px 0;
|
position: relative;
|
||||||
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4fc3f7 0%, #66bb6a 100%);
|
background: linear-gradient(90deg, #4fc3f7, #03a9f4);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.6);
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #4fc3f7;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Elements */
|
|
||||||
.elements-section {
|
|
||||||
background: rgba(15, 23, 42, 0.4);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.elements-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-item {
|
|
||||||
background: rgba(15, 23, 42, 0.6);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #66bb6a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Calibration */
|
|
||||||
.calibration-section {
|
|
||||||
background: rgba(255, 152, 0, 0.05);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calibration-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-text {
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(255, 152, 0, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 3px solid #ffa726;
|
|
||||||
color: #ffa726;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -715,13 +569,4 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.freq-control {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elements-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user