Changing Normal/180/bi over a remote connection reverted to the old pattern after ~4s: the fixed 4s optimistic window expired while the motors were still flipping the elements, and the lagging remote status poll then overrode the UI. Hold the commanded direction while the motors are moving, then a short grace window after they stop for the confirmation poll to land (SteppIR already used a generous 45s hold, which is why only Ultrabeam-remote hit this).
587 lines
16 KiB
Go
587 lines
16 KiB
Go
// Package ultrabeam drives an Ultrabeam remote-controlled antenna over TCP
|
|
// (typically via an RS232↔Ethernet adapter). The wire protocol (STX/ETX
|
|
// framing, DLE escaping, XOR checksum) and command codes are the manufacturer's.
|
|
package ultrabeam
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Connection tuning. Remote operation (the antenna controller reached over the
|
|
// internet, not the LAN) sees real latency and jitter, so the read timeout is
|
|
// generous and a few transient timeouts are tolerated before the link is torn
|
|
// down — otherwise a single slow reply dropped the whole connection and the
|
|
// client churned reconnect/disconnect.
|
|
const (
|
|
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
|
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
|
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
|
// How long a just-commanded direction is trusted AFTER the motors have stopped
|
|
// but before the antenna's status confirms it. The timer is held off entirely
|
|
// while the motors are still moving, so this is only the grace period for the
|
|
// confirmation poll to arrive once the elements have settled — generous, because
|
|
// over a remote link that poll lags by several seconds.
|
|
ubPendingDirGrace = 8 * time.Second
|
|
)
|
|
|
|
// Protocol constants
|
|
const (
|
|
STX byte = 0xF5 // 245 decimal
|
|
ETX byte = 0xFA // 250 decimal
|
|
DLE byte = 0xF6 // 246 decimal
|
|
)
|
|
|
|
// Command codes
|
|
const (
|
|
CMD_STATUS byte = 1 // General status query
|
|
CMD_RETRACT byte = 2 // Retract elements
|
|
CMD_FREQ byte = 3 // Change frequency
|
|
CMD_READ_BANDS byte = 9 // Read current band adjustments
|
|
CMD_PROGRESS byte = 10 // Read progress bar
|
|
CMD_MODIFY_ELEM byte = 12 // Modify element length
|
|
)
|
|
|
|
// Reply codes
|
|
const (
|
|
UB_OK byte = 0 // Normal execution
|
|
UB_BAD byte = 1 // Invalid command
|
|
UB_PAR byte = 2 // Bad parameters
|
|
UB_ERR byte = 3 // Error executing command
|
|
)
|
|
|
|
// Direction modes
|
|
const (
|
|
DIR_NORMAL byte = 0
|
|
DIR_180 byte = 1
|
|
DIR_BIDIR byte = 2
|
|
)
|
|
|
|
type Client struct {
|
|
host string
|
|
port int
|
|
conn net.Conn
|
|
connMu sync.Mutex
|
|
reader *bufio.Reader
|
|
lastStatus *Status
|
|
statusMu sync.RWMutex
|
|
stopChan chan struct{}
|
|
running bool
|
|
seqNum byte
|
|
seqMu sync.Mutex
|
|
|
|
// Optimistic pattern direction kept until the antenna's status poll reports
|
|
// it (or it ages out) — the motors take a second or two, and a stale poll in
|
|
// between would otherwise snap the UI back to the old direction.
|
|
pendingDir int
|
|
pendingDirAt time.Time
|
|
pendingDirSet bool
|
|
|
|
// lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop
|
|
// deadband reference when the antenna's own status hasn't reported a frequency
|
|
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
|
|
// re-tunes the motors.
|
|
lastSetKHz int
|
|
}
|
|
|
|
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
|
|
// or 0 if none yet.
|
|
func (c *Client) LastSetKHz() int {
|
|
c.statusMu.RLock()
|
|
defer c.statusMu.RUnlock()
|
|
return c.lastSetKHz
|
|
}
|
|
|
|
type Status struct {
|
|
FirmwareMinor int `json:"firmware_minor"`
|
|
FirmwareMajor int `json:"firmware_major"`
|
|
CurrentOperation int `json:"current_operation"`
|
|
Frequency int `json:"frequency"` // KHz
|
|
Band int `json:"band"`
|
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
|
OffState bool `json:"off_state"`
|
|
MotorsMoving int `json:"motors_moving"` // Bitmask
|
|
FreqMin int `json:"freq_min"` // MHz
|
|
FreqMax int `json:"freq_max"` // MHz
|
|
ElementLengths []int `json:"element_lengths"` // mm
|
|
ProgressTotal int `json:"progress_total"` // mm
|
|
ProgressCurrent int `json:"progress_current"` // 0-60
|
|
Connected bool `json:"connected"`
|
|
}
|
|
|
|
func New(host string, port int) *Client {
|
|
return &Client{
|
|
host: host,
|
|
port: port,
|
|
stopChan: make(chan struct{}),
|
|
seqNum: 0,
|
|
}
|
|
}
|
|
|
|
func (c *Client) Start() error {
|
|
c.running = true
|
|
go c.pollLoop()
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) Stop() {
|
|
if !c.running {
|
|
return
|
|
}
|
|
c.running = false
|
|
close(c.stopChan)
|
|
|
|
c.connMu.Lock()
|
|
if c.conn != nil {
|
|
c.conn.Close()
|
|
c.conn = nil
|
|
}
|
|
c.connMu.Unlock()
|
|
}
|
|
|
|
func (c *Client) pollLoop() {
|
|
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
|
defer ticker.Stop()
|
|
|
|
pollCount := 0
|
|
pollFails := 0 // consecutive failed status polls (transient timeouts tolerated)
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
pollCount++
|
|
|
|
// Try to connect if not connected
|
|
c.connMu.Lock()
|
|
if c.conn == nil {
|
|
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
|
dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
|
|
conn, err := dialer.Dial("tcp", net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)))
|
|
if err != nil {
|
|
log.Printf("Ultrabeam: Connection failed: %v", err)
|
|
c.connMu.Unlock()
|
|
|
|
// Mark as disconnected
|
|
c.statusMu.Lock()
|
|
c.lastStatus = &Status{Connected: false}
|
|
c.statusMu.Unlock()
|
|
continue
|
|
}
|
|
c.conn = conn
|
|
c.reader = bufio.NewReader(c.conn)
|
|
pollFails = 0
|
|
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
|
}
|
|
c.connMu.Unlock()
|
|
|
|
// Query status
|
|
status, err := c.queryStatus()
|
|
if err != nil {
|
|
// A single slow/lost reply over a remote link is normal — keep
|
|
// the connection (and the last status) for a few tries before
|
|
// tearing it down, so we don't churn reconnect/disconnect.
|
|
var ne net.Error
|
|
transient := errors.As(err, &ne) && ne.Timeout()
|
|
pollFails++
|
|
if transient && pollFails < ubMaxPollTimeout {
|
|
log.Printf("Ultrabeam: status timeout (%d/%d), keeping link: %v", pollFails, ubMaxPollTimeout, err)
|
|
continue
|
|
}
|
|
log.Printf("Ultrabeam: Failed to query status, reconnecting: %v", err)
|
|
c.connMu.Lock()
|
|
if c.conn != nil {
|
|
c.conn.Close()
|
|
c.conn = nil
|
|
c.reader = nil
|
|
}
|
|
c.connMu.Unlock()
|
|
|
|
// Mark as disconnected
|
|
c.statusMu.Lock()
|
|
c.lastStatus = &Status{Connected: false}
|
|
c.statusMu.Unlock()
|
|
continue
|
|
}
|
|
pollFails = 0
|
|
|
|
// Mark as connected
|
|
status.Connected = true
|
|
|
|
// Query progress if motors moving
|
|
if status.MotorsMoving != 0 {
|
|
progress, err := c.queryProgress()
|
|
if err == nil {
|
|
status.ProgressTotal = progress[0]
|
|
status.ProgressCurrent = progress[1]
|
|
}
|
|
} else {
|
|
// Motors stopped - reset progress
|
|
status.ProgressTotal = 0
|
|
status.ProgressCurrent = 0
|
|
}
|
|
|
|
c.statusMu.Lock()
|
|
// Keep a just-commanded direction until the antenna actually reports it.
|
|
// Over a remote link the confirmation arrives several seconds after the
|
|
// command — the motors flip the elements first — so the old fixed 4 s
|
|
// timeout expired WHILE the change was still in flight, and the stale poll
|
|
// reverted the UI to the old pattern even though the antenna was on its way
|
|
// to the new one. Now: while the motors are still moving the change is in
|
|
// progress, so hold the commanded pattern and keep resetting the timer;
|
|
// only once the motors have stopped does the short grace window run, giving
|
|
// the confirmation poll time to land. The poll only wins if the motors are
|
|
// idle AND the antenna still reports a different pattern past that window —
|
|
// i.e. the command genuinely did not take.
|
|
if c.pendingDirSet {
|
|
if status.MotorsMoving != 0 {
|
|
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
|
|
}
|
|
switch {
|
|
case status.Direction == c.pendingDir:
|
|
c.pendingDirSet = false // confirmed by the antenna
|
|
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
|
|
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
|
|
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
|
|
c.pendingDir, status.Direction)
|
|
default:
|
|
status.Direction = c.pendingDir // still changing, or within the grace window
|
|
}
|
|
}
|
|
c.lastStatus = status
|
|
c.statusMu.Unlock()
|
|
|
|
case <-c.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) GetStatus() (*Status, error) {
|
|
c.statusMu.RLock()
|
|
defer c.statusMu.RUnlock()
|
|
|
|
if c.lastStatus == nil {
|
|
return &Status{Connected: false}, nil
|
|
}
|
|
|
|
return c.lastStatus, nil
|
|
}
|
|
|
|
// getNextSeq returns the next sequence number
|
|
func (c *Client) getNextSeq() byte {
|
|
c.seqMu.Lock()
|
|
defer c.seqMu.Unlock()
|
|
|
|
seq := c.seqNum
|
|
c.seqNum = (c.seqNum + 1) % 128
|
|
return seq
|
|
}
|
|
|
|
// calculateChecksum calculates the checksum for a packet
|
|
func calculateChecksum(data []byte) byte {
|
|
chk := byte(0x55)
|
|
for _, b := range data {
|
|
chk ^= b
|
|
chk++
|
|
}
|
|
return chk
|
|
}
|
|
|
|
// quoteByte handles DLE escaping
|
|
func quoteByte(b byte) []byte {
|
|
if b == STX || b == ETX || b == DLE {
|
|
return []byte{DLE, b & 0x7F} // Clear MSB
|
|
}
|
|
return []byte{b}
|
|
}
|
|
|
|
// buildPacket creates a complete packet with checksum and escaping
|
|
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
|
seq := c.getNextSeq()
|
|
|
|
// Calculate checksum on unquoted data
|
|
payload := append([]byte{seq, cmd}, data...)
|
|
chk := calculateChecksum(payload)
|
|
|
|
// Build packet with quoting
|
|
packet := []byte{STX}
|
|
|
|
// Add quoted SEQ
|
|
packet = append(packet, quoteByte(seq)...)
|
|
|
|
// Add quoted CMD
|
|
packet = append(packet, quoteByte(cmd)...)
|
|
|
|
// Add quoted data
|
|
for _, b := range data {
|
|
packet = append(packet, quoteByte(b)...)
|
|
}
|
|
|
|
// Add quoted checksum
|
|
packet = append(packet, quoteByte(chk)...)
|
|
|
|
// Add ETX
|
|
packet = append(packet, ETX)
|
|
|
|
return packet
|
|
}
|
|
|
|
// parsePacket parses a received packet, handling DLE unescaping
|
|
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
|
|
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
|
|
return 0, 0, nil, fmt.Errorf("packet too short")
|
|
}
|
|
|
|
if data[0] != STX {
|
|
return 0, 0, nil, fmt.Errorf("missing STX")
|
|
}
|
|
|
|
if data[len(data)-1] != ETX {
|
|
return 0, 0, nil, fmt.Errorf("missing ETX")
|
|
}
|
|
|
|
// Unquote the data
|
|
var unquoted []byte
|
|
dle := false
|
|
for i := 1; i < len(data)-1; i++ {
|
|
b := data[i]
|
|
if b == DLE {
|
|
dle = true
|
|
continue
|
|
}
|
|
if dle {
|
|
b |= 0x80 // Set MSB
|
|
dle = false
|
|
}
|
|
unquoted = append(unquoted, b)
|
|
}
|
|
|
|
if len(unquoted) < 3 {
|
|
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
|
|
}
|
|
|
|
seq = unquoted[0]
|
|
cmd = unquoted[1]
|
|
chk := unquoted[len(unquoted)-1]
|
|
payload = unquoted[2 : len(unquoted)-1]
|
|
|
|
// Verify checksum
|
|
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
|
|
if calcChk != chk {
|
|
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
|
|
}
|
|
|
|
return seq, cmd, payload, nil
|
|
}
|
|
|
|
// sendCommand sends a command and waits for reply
|
|
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
|
c.connMu.Lock()
|
|
defer c.connMu.Unlock()
|
|
|
|
if c.conn == nil || c.reader == nil {
|
|
return nil, fmt.Errorf("not connected")
|
|
}
|
|
|
|
// Build and send packet
|
|
packet := c.buildPacket(cmd, data)
|
|
|
|
_, err := c.conn.Write(packet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to write: %w", err)
|
|
}
|
|
|
|
// Read reply with timeout (generous — tolerates remote-link latency).
|
|
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
|
|
|
|
// Read until we get a complete packet
|
|
var buffer []byte
|
|
for {
|
|
b, err := c.reader.ReadByte()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read: %w", err)
|
|
}
|
|
|
|
buffer = append(buffer, b)
|
|
|
|
// Check if we have a complete packet
|
|
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
|
break
|
|
}
|
|
|
|
// Prevent infinite loop
|
|
if len(buffer) > 256 {
|
|
return nil, fmt.Errorf("packet too long")
|
|
}
|
|
}
|
|
|
|
// Parse reply
|
|
_, replyCmd, payload, err := parsePacket(buffer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
|
}
|
|
|
|
// Log for debugging unknown codes
|
|
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
|
|
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
|
|
}
|
|
|
|
// Check for errors
|
|
switch replyCmd {
|
|
case UB_BAD:
|
|
return nil, fmt.Errorf("invalid command")
|
|
case UB_PAR:
|
|
return nil, fmt.Errorf("bad parameters")
|
|
case UB_ERR:
|
|
return nil, fmt.Errorf("execution error")
|
|
case UB_OK:
|
|
return payload, nil
|
|
default:
|
|
// Unknown codes might indicate "busy" or "in progress"
|
|
// Treat as non-fatal, return empty payload
|
|
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
|
|
return []byte{}, nil
|
|
}
|
|
}
|
|
|
|
// queryStatus queries general status (command 1)
|
|
func (c *Client) queryStatus() (*Status, error) {
|
|
reply, err := c.sendCommand(CMD_STATUS, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(reply) < 12 {
|
|
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
|
|
}
|
|
|
|
status := &Status{
|
|
FirmwareMinor: int(reply[0]),
|
|
FirmwareMajor: int(reply[1]),
|
|
CurrentOperation: int(reply[2]),
|
|
Frequency: int(reply[3]) | (int(reply[4]) << 8),
|
|
Band: int(reply[5]),
|
|
Direction: int(reply[6] & 0x0F),
|
|
OffState: (reply[7] & 0x02) != 0,
|
|
MotorsMoving: int(reply[9]),
|
|
FreqMin: int(reply[10]),
|
|
FreqMax: int(reply[11]),
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// queryProgress queries motor progress (command 10)
|
|
func (c *Client) queryProgress() ([]int, error) {
|
|
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(reply) < 4 {
|
|
return nil, fmt.Errorf("progress reply too short")
|
|
}
|
|
|
|
total := int(reply[0]) | (int(reply[1]) << 8)
|
|
current := int(reply[2]) | (int(reply[3]) << 8)
|
|
|
|
return []int{total, current}, nil
|
|
}
|
|
|
|
// ReadElements reads the current per-element lengths for the active band
|
|
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
|
|
// the only way to see the current lengths — needed so the operator isn't
|
|
// adjusting blind. The reply payload layout is not documented in the code, so we
|
|
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
|
|
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
|
|
// format from the logged bytes on real hardware, then tighten the parse.
|
|
func (c *Client) ReadElements() ([]int, error) {
|
|
payload, err := c.sendCommand(CMD_READ_BANDS, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
|
|
// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
|
|
out := make([]int, 0, len(payload)/2)
|
|
for i := 0; i+1 < len(payload); i += 2 {
|
|
out = append(out, int(payload[i])|int(payload[i+1])<<8)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SetFrequency changes frequency and optional direction (command 3)
|
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|
// Trace WHO asked for the change — the caller's function + line — so an
|
|
// unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced
|
|
// to the follow loop, an immediate re-tune, or a direction re-issue.
|
|
caller := "?"
|
|
if pc, _, line, ok := runtime.Caller(1); ok {
|
|
caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line)
|
|
}
|
|
log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller)
|
|
|
|
data := []byte{
|
|
byte(freqKhz & 0xFF),
|
|
byte((freqKhz >> 8) & 0xFF),
|
|
byte(direction),
|
|
}
|
|
|
|
_, err := c.sendCommand(CMD_FREQ, data)
|
|
if err == nil {
|
|
c.statusMu.Lock()
|
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
|
c.lastSetKHz = freqKhz
|
|
if c.lastStatus != nil {
|
|
c.lastStatus.Direction = direction // reflect immediately
|
|
}
|
|
c.statusMu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// SetDirection changes only the pattern direction (Normal / 180° / Bidirectional)
|
|
// by re-issuing the current frequency with the new direction byte — the device
|
|
// has no standalone direction command. Needs a status poll to have populated the
|
|
// current frequency first.
|
|
func (c *Client) SetDirection(direction int) error {
|
|
c.statusMu.RLock()
|
|
freq := 0
|
|
if c.lastStatus != nil {
|
|
freq = c.lastStatus.Frequency
|
|
}
|
|
c.statusMu.RUnlock()
|
|
if freq <= 0 {
|
|
return fmt.Errorf("current frequency not known yet — wait for the antenna to report status")
|
|
}
|
|
return c.SetFrequency(freq, direction)
|
|
}
|
|
|
|
// Retract retracts all elements (command 2)
|
|
func (c *Client) Retract() error {
|
|
_, err := c.sendCommand(CMD_RETRACT, nil)
|
|
return err
|
|
}
|
|
|
|
// ModifyElement modifies element length (command 12)
|
|
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
|
if elementNum < 0 || elementNum > 5 {
|
|
return fmt.Errorf("invalid element number: %d", elementNum)
|
|
}
|
|
|
|
data := []byte{
|
|
byte(elementNum),
|
|
0, // Reserved
|
|
byte(lengthMm & 0xFF),
|
|
byte((lengthMm >> 8) & 0xFF),
|
|
}
|
|
|
|
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
|
return err
|
|
}
|