package rotor import ( "fmt" "log" "math" "net" "sync" "sync/atomic" "time" ) // PstRotatorClient controls PstRotator via UDP XML protocol. // Protocol: send XML to port N, responses come back on port N+1 // Format: 180.0 or 45.0 // Multiple commands: 180.045.0 type PstRotatorClient struct { mu sync.Mutex conn *net.UDPConn addr *net.UDPAddr connected atomic.Bool lastAz float64 lastEl float64 azThreshold float64 elThreshold float64 } func NewPstRotatorClient() *PstRotatorClient { return &PstRotatorClient{ azThreshold: 5.0, // 5° dead-band for Az elThreshold: 5.0, } } func (r *PstRotatorClient) Connect(host string, port int) error { r.mu.Lock() defer r.mu.Unlock() if r.connected.Load() { r.disconnectLocked() } addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port)) if err != nil { return fmt.Errorf("resolve UDP addr: %w", err) } conn, err := net.DialUDP("udp", nil, addr) if err != nil { return fmt.Errorf("UDP connect to PstRotator: %w", err) } r.conn = conn r.addr = addr r.connected.Store(true) r.lastAz = -999 r.lastEl = -999 log.Printf("[Rotor] Connected to PstRotator at %s:%d", host, port) return nil } func (r *PstRotatorClient) Disconnect() { r.mu.Lock() defer r.mu.Unlock() r.disconnectLocked() } func (r *PstRotatorClient) disconnectLocked() { if r.conn != nil { r.conn.Close() r.conn = nil } r.connected.Store(false) log.Println("[Rotor] Disconnected") } func (r *PstRotatorClient) IsConnected() bool { return r.connected.Load() } // SetAzEl sends azimuth (and optionally elevation) to PstRotator. // El < 0 is treated as azimuth-only (no elevation rotor). func (r *PstRotatorClient) SetAzEl(az, el float64) error { if !r.connected.Load() { return fmt.Errorf("not connected to PstRotator") } // Normalize azimuth 0-360 az = math.Mod(az, 360.0) if az < 0 { az += 360 } // Clamp elevation if el < 0 { el = 0 } if el > 90 { el = 90 } r.mu.Lock() defer r.mu.Unlock() if r.conn == nil { return fmt.Errorf("not connected") } // Dead-band check (inside mutex — no race on lastAz/lastEl) azChanged := math.Abs(az-r.lastAz) >= r.azThreshold elChanged := math.Abs(el-r.lastEl) >= r.elThreshold if !azChanged && !elChanged { return nil } // Build XML command wrapped in ... var cmd string if azChanged && elChanged { cmd = fmt.Sprintf("%.0f%.0f", az, el) } else if azChanged { cmd = fmt.Sprintf("%.0f", az) } else { cmd = fmt.Sprintf("%.0f", el) } log.Printf("[Rotor] UDP → %s", cmd) r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) if _, err := r.conn.Write([]byte(cmd)); err != nil { r.connected.Store(false) return fmt.Errorf("UDP write: %w", err) } if azChanged { r.lastAz = az } if elChanged { r.lastEl = el } log.Printf("[Rotor] → AZ=%.1f° EL=%.1f°", az, el) return nil } // SetAzOnly sends only azimuth — for stations with no elevation rotor. func (r *PstRotatorClient) SetAzOnly(az float64) error { if !r.connected.Load() { return fmt.Errorf("not connected") } az = math.Mod(az, 360.0) if az < 0 { az += 360 } r.mu.Lock() defer r.mu.Unlock() if r.conn == nil { return fmt.Errorf("not connected") } // Dead-band check (inside mutex — no race on lastAz) if math.Abs(az-r.lastAz) < r.azThreshold { return nil } cmd := fmt.Sprintf("%.0f", az) log.Printf("[Rotor] UDP → %s", cmd) r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) if _, err := r.conn.Write([]byte(cmd)); err != nil { r.connected.Store(false) return fmt.Errorf("UDP write: %w", err) } r.lastAz = az log.Printf("[Rotor] → AZ=%.1f°", az) return nil } // StopRotor sends stop command. func (r *PstRotatorClient) StopRotor() error { return r.sendRaw("1") } // QueryAzEl requests current position — answer comes back on port+1. func (r *PstRotatorClient) QueryAzEl() error { return r.sendRaw("11") } func (r *PstRotatorClient) sendRaw(cmd string) error { r.mu.Lock() defer r.mu.Unlock() if r.conn == nil { return fmt.Errorf("not connected") } log.Printf("[Rotor] UDP → %s", cmd) r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) n, err := r.conn.Write([]byte(cmd)) if err != nil { r.connected.Store(false) return fmt.Errorf("UDP write: %w", err) } log.Printf("[Rotor] UDP sent %d bytes to %s", n, r.addr) return nil } // ResetDeadband forces next azimuth command to be sent immediately. func (r *PstRotatorClient) ResetDeadband() { r.mu.Lock() defer r.mu.Unlock() r.lastAz = -999 r.lastEl = -999 } func (r *PstRotatorClient) SetThresholds(azDeg, elDeg float64) { r.mu.Lock() defer r.mu.Unlock() r.azThreshold = azDeg r.elThreshold = elDeg }