From 9837657dd904a5a037b0cf7d7fef88df1b3e747c Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 11 Jan 2026 15:33:44 +0100 Subject: [PATCH] corrected all bugs --- configs/config.example.yaml | 32 +- internal/api/device_manager.go | 168 +++++++++- internal/config/config.go | 8 + internal/devices/flexradio/flexradio.go | 412 ++++++++++++++++++++++++ internal/devices/flexradio/types.go | 21 ++ internal/devices/ultrabeam/ultrabeam.go | 70 +--- web/src/App.svelte | 8 +- web/src/components/PowerGenius.svelte | 9 - web/src/components/RotatorGenius.svelte | 378 ++++++++++++++-------- web/src/components/Ultrabeam.svelte | 383 +++++++--------------- 10 files changed, 992 insertions(+), 497 deletions(-) create mode 100644 internal/devices/flexradio/flexradio.go create mode 100644 internal/devices/flexradio/types.go diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 91ed156..7e145a7 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -4,33 +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" \ No newline at end of file diff --git a/internal/api/device_manager.go b/internal/api/device_manager.go index a3095ed..6b6ccea 100644 --- a/internal/api/device_manager.go +++ b/internal/api/device_manager.go @@ -7,6 +7,7 @@ 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" @@ -25,6 +26,7 @@ type DeviceManager struct { antennaGenius *antennagenius.Client rotatorGenius *rotatorgenius.Client ultrabeam *ultrabeam.Client + flexRadio *flexradio.Client solarClient *solar.Client weatherClient *weather.Client @@ -42,6 +44,10 @@ type DeviceManager struct { 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 + + // Cached Ultrabeam state for FlexRadio interlock (avoid mutex contention) + ultrabeamMotorsMoving int + ultrabeamStateMu sync.RWMutex } type SystemStatus struct { @@ -51,6 +57,7 @@ type SystemStatus struct { 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"` @@ -60,12 +67,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: 2 * time.Second, // Wait 2 seconds between updates + freqThreshold: 25000, // 25 kHz default + autoTrackEnabled: true, // Enabled by default + ultrabeamDirection: 0, // Normal direction by default + freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec) } } @@ -107,6 +114,31 @@ func (dm *DeviceManager) Initialize() error { 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 dm.solarClient = solar.New() @@ -154,6 +186,17 @@ func (dm *DeviceManager) Initialize() error { }() 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 } @@ -164,6 +207,69 @@ 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 + 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) @@ -250,19 +356,57 @@ func (dm *DeviceManager) updateStatus() { if !dm.ultrabeamDirectionSet { 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 { log.Printf("Ultrabeam error: %v", err) } - // Auto frequency tracking: Update Ultrabeam when TunerGenius frequency differs from Ultrabeam - if dm.autoTrackEnabled && status.TunerGenius != nil && status.TunerGenius.Connected && status.Ultrabeam != nil && status.Ultrabeam.Connected { - tunerFreqKhz := int(status.TunerGenius.FreqA) // TunerGenius frequency is already in kHz + // 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 + // 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 // 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 tunerFreqKhz >= 7000 && tunerFreqKhz <= 54000 { - freqDiff := tunerFreqKhz - ultrabeamFreqKhz + if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 { + freqDiff := radioFreqKhz - ultrabeamFreqKhz if freqDiff < 0 { freqDiff = -freqDiff } @@ -284,10 +428,10 @@ func (dm *DeviceManager) updateStatus() { if timeSinceLastUpdate < dm.freqUpdateCooldown { log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate) } 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 - 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) } else { log.Printf("Auto-track: Successfully sent frequency to Ultrabeam") diff --git a/internal/config/config.go b/internal/config/config.go index e1c2752..3acd524 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type DevicesConfig struct { AntennaGenius AntennaGeniusConfig `yaml:"antenna_genius"` RotatorGenius RotatorGeniusConfig `yaml:"rotator_genius"` Ultrabeam UltrabeamConfig `yaml:"ultrabeam"` + FlexRadio FlexRadioConfig `yaml:"flexradio"` } type WebSwitchConfig struct { @@ -57,6 +58,13 @@ type UltrabeamConfig struct { 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"` diff --git a/internal/devices/flexradio/flexradio.go b/internal/devices/flexradio/flexradio.go new file mode 100644 index 0000000..d014a8f --- /dev/null +++ b/internal/devices/flexradio/flexradio.go @@ -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|| + if strings.HasPrefix(msg, "R") { + c.handleResponse(msg) + return + } + + // Status format: S|= ... + if strings.HasPrefix(msg, "S") { + c.handleStatus(msg) + return + } + + // Version/handle format: V|H + if strings.HasPrefix(msg, "V") { + log.Printf("FlexRadio: Version/Handle received: %s", msg) + return + } + + // Message format: M| + if strings.HasPrefix(msg, "M") { + log.Printf("FlexRadio: Message: %s", msg) + return + } +} + +func (c *Client) handleResponse(msg string) { + // Format: R|| + 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|= ... + 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= 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 +} diff --git a/internal/devices/flexradio/types.go b/internal/devices/flexradio/types.go new file mode 100644 index 0000000..fcee5da --- /dev/null +++ b/internal/devices/flexradio/types.go @@ -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" +) diff --git a/internal/devices/ultrabeam/ultrabeam.go b/internal/devices/ultrabeam/ultrabeam.go index b4a95f1..24f90de 100644 --- a/internal/devices/ultrabeam/ultrabeam.go +++ b/internal/devices/ultrabeam/ultrabeam.go @@ -106,9 +106,12 @@ 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() @@ -161,6 +164,10 @@ func (c *Client) pollLoop() { status.ProgressTotal = progress[0] status.ProgressCurrent = progress[1] } + } else { + // Motors stopped - reset progress + status.ProgressTotal = 0 + status.ProgressCurrent = 0 } c.statusMu.Lock() @@ -388,69 +395,6 @@ func (c *Client) queryStatus() (*Status, error) { 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) func (c *Client) queryProgress() ([]int, error) { reply, err := c.sendCommand(CMD_PROGRESS, nil) diff --git a/web/src/App.svelte b/web/src/App.svelte index 47f32c8..11d87a8 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -112,13 +112,13 @@
- +
- - + +
@@ -292,4 +292,4 @@ flex-wrap: wrap; } } - \ No newline at end of file + diff --git a/web/src/components/PowerGenius.svelte b/web/src/components/PowerGenius.svelte index 1269fa5..6f3f80c 100644 --- a/web/src/components/PowerGenius.svelte +++ b/web/src/components/PowerGenius.svelte @@ -7,15 +7,6 @@ $: powerReflected = status?.power_reflected || 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; $: vdd = status?.vdd || 0; $: current = status?.current || 0; diff --git a/web/src/components/RotatorGenius.svelte b/web/src/components/RotatorGenius.svelte index 27c5917..19aafca 100644 --- a/web/src/components/RotatorGenius.svelte +++ b/web/src/components/RotatorGenius.svelte @@ -2,31 +2,66 @@ import { api } from '../lib/api.js'; export let status; + export let ultrabeam = null; - let heading = 0; + let heading = null; // Start with null instead of 0 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) { - 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; 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() { if (targetHeading < 0 || targetHeading > 359) { - alert('Heading must be between 0 and 359'); + // Removed alert popup - check console for errors return; } try { + hasTarget = true; // Mark that we have a target // Subtract 10 degrees to compensate for rotator momentum const adjustedHeading = (targetHeading - 10 + 360) % 360; await api.rotator.setHeading(adjustedHeading); } catch (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 @@
CURRENT HEADING
-
{heading}°
+
+ {displayHeading}° + {#if hasTarget} + → {targetHeading}° + {/if} +
@@ -107,7 +147,12 @@
- + e.key === 'Enter' && handleCompassClick(e)} + role="button" + tabindex="0" + aria-label="Click to rotate antenna to direction"> @@ -126,30 +171,139 @@ - - - - - - - - - - - - + + {#if ultrabeamDirection === 1 || ultrabeamDirection === 2} + + + + + + + + {/if} + + + + + + {#if ultrabeamDirection === 0} + + + + + + + + + + + + {/if} + + + {#if ultrabeamDirection === 1} + + + + + + + + + + + + {/if} + + + {#if ultrabeamDirection === 2} + + + + + + + + + + + + + + + + + + + + {/if} + + + {#if hasTarget} + + + + + + + + + {/if} + @@ -175,6 +329,24 @@
+ + {#if ultrabeamDirection === 1 || ultrabeamDirection === 2} +
+
+ + + + Physical antenna +
+
+ + + + Radiation pattern +
+
+ {/if} +
@@ -226,14 +398,6 @@ 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 { display: flex; @@ -259,45 +423,39 @@ .btn-mini { width: 36px; height: 36px; - border: none; + border: 2px solid rgba(79, 195, 247, 0.3); border-radius: 6px; font-size: 20px; - font-weight: bold; + 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.ccw { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + .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: rotate(-15deg) scale(1.05); - 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; + transform: translateY(-1px) rotate(-5deg); } .btn-mini.cw:hover { - transform: rotate(15deg) scale(1.05); - box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4); + transform: translateY(-1px) rotate(5deg); + } + + .btn-mini.stop:hover { + border-color: #f44336; + color: #f44336; + background: rgba(244, 67, 54, 0.15); } .heading-label { @@ -315,6 +473,20 @@ 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-container { display: flex; @@ -324,6 +496,24 @@ 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 { width: 100%; max-width: 300px; @@ -352,70 +542,4 @@ 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); - } - - + \ No newline at end of file diff --git a/web/src/components/Ultrabeam.svelte b/web/src/components/Ultrabeam.svelte index 044a8e4..d7bcdb7 100644 --- a/web/src/components/Ultrabeam.svelte +++ b/web/src/components/Ultrabeam.svelte @@ -2,6 +2,7 @@ import { api } from '../lib/api.js'; export let status; + export let flexradio = null; $: connected = status?.connected || false; $: frequency = status?.frequency || 0; @@ -13,6 +14,21 @@ $: 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); + + 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 = [ @@ -102,7 +118,7 @@ await api.ultrabeam.setAutoTrack(autoTrackEnabled, autoTrackThreshold); } catch (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(); } catch (err) { console.error('Failed to retract:', err); - alert('Failed to retract'); + // Removed alert popup - check console for errors } } @@ -122,11 +138,11 @@ try { const newLength = elementLengths[selectedElement] + elementAdjustment; // 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; } catch (err) { console.error('Failed to adjust element:', err); - alert('Failed to adjust element'); + // Removed alert popup - check console for errors } } @@ -137,7 +153,17 @@

Ultrabeam VL2.3

- +
+ {#if interlockConnected && interlockState} +
+ {interlockState === 'READY' ? '🔓 TX OK' : + interlockState === 'NOT_READY' ? '🔒 TX Block' : + interlockState === 'PTT_REQUESTED' ? '⏳ PTT' : + interlockState === 'TRANSMITTING' ? '📡 TX' : '❓'} +
+ {/if} + +
@@ -176,30 +202,31 @@ {/each}
- -
- - - -
+
+ + +
+ + +
@@ -310,6 +337,24 @@ 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 { margin: 0; font-size: 20px; @@ -447,267 +492,76 @@ .direction-buttons { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 10px; - margin-top: 12px; + gap: 12px; + margin-top: 16px; } .dir-btn { - padding: 14px 20px; - border: none; - 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); + padding: 12px 16px; + border: 2px solid rgba(79, 195, 247, 0.3); border-radius: 8px; - padding: 10px 12px; - 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-size: 13px; font-weight: 600; - font-size: 14px; + text-transform: uppercase; cursor: pointer; transition: all 0.2s; - display: flex; - align-items: center; - gap: 8px; - justify-content: center; + color: rgba(255, 255, 255, 0.7); + background: rgba(79, 195, 247, 0.08); + letter-spacing: 0.5px; } - .btn-primary { - background: linear-gradient(135deg, #4fc3f7 0%, #03a9f4 100%); - color: #fff; - box-shadow: 0 4px 16px rgba(79, 195, 247, 0.4); + .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); } - .btn-primary:hover { - transform: translateY(-2px); - 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); + .dir-btn.active { + border-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); + box-shadow: 0 0 20px rgba(79, 195, 247, 0.4); + font-weight: 700; } - .icon { - font-size: 16px; - } - - /* Progress */ + /* Progress Section */ .progress-section { - background: rgba(15, 23, 42, 0.4); - padding: 12px; - border-radius: 12px; - border: 1px solid rgba(255, 193, 7, 0.3); + 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 { - width: 100%; - height: 24px; - background: rgba(15, 23, 42, 0.8); - border-radius: 12px; + height: 20px; + background: rgba(15, 23, 42, 0.6); + border-radius: 10px; overflow: hidden; - margin: 12px 0; - border: 1px solid rgba(79, 195, 247, 0.3); + position: relative; } .progress-fill { height: 100%; - background: linear-gradient(90deg, #4fc3f7 0%, #66bb6a 100%); + background: linear-gradient(90deg, #4fc3f7, #03a9f4); transition: width 0.3s ease; - box-shadow: 0 0 12px rgba(79, 195, 247, 0.6); + border-radius: 10px; } .progress-text { 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; + color: rgba(255, 255, 255, 0.8); + margin-top: 8px; } .actions { @@ -715,13 +569,4 @@ gap: 12px; } - @media (max-width: 768px) { - .freq-control { - grid-template-columns: 1fr; - } - - .elements-grid { - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - } - } \ No newline at end of file