Files
OpsLog/internal/ultrabeam/ultrabeam.go
T
rouggy bb3542c920 fix(ultrabeam): hold a commanded pattern over a remote link until confirmed
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).
2026-08-04 18:56:21 +02:00

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
}