Files
OpsLog/internal/ultrabeam/ultrabeam.go
rouggy cb430a22ee feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas
Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:

  - Stop() did not wait for the poll loop, so a stopped client kept the COM
    port. Every later client then failed with "Serial port busy" — the
    program holding it being OpsLog itself.
  - startUltrabeam tore the old client down CONCURRENTLY with starting the
    new one, and "Test connection" built a second client on a port already
    ours. Harmless over TCP, fatal on a port with one owner.
  - A silent serial port returns (0, nil) and bufio retries that a hundred
    times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
    nothing.

The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.

Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.

Also:
  - Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
    searched, ready for the card dates.
  - The record button no longer goes missing after an update: whether manual
    recording is possible is a per-profile question that was asked once, at
    startup, before the profile was known.
  - Alert rules and filter presets confirm that they were saved.
  - Spot clicks on the radio panadapter carry the POTA park into F3.
  - The build gate is re-checked wherever the active callsign can change; it
    ran at startup alone, and a fresh install has no callsign then.
2026-08-22 14:07:00 +02:00

893 lines
29 KiB
Go

// Package ultrabeam drives an Ultrabeam remote-controlled antenna over SERIAL
// or TCP. The wire protocol (STX/ETX framing, DLE escaping, XOR checksum) and
// command codes are the manufacturer's, and identical on both: the Ethernet
// route is an RS232↔Ethernet adapter passing the same bytes.
//
// Serial came second, which is the wrong way round for most stations: the
// controller has an RS232 port and the PC is usually right next to it, so a
// plain FTDI cable does the job and the adapter is only needed to put the
// antenna at the other end of a link.
package ultrabeam
import (
"bufio"
"errors"
"fmt"
"io"
"log"
"net"
"runtime"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
// Transport says how to reach the controller. Mirrors internal/steppir, which
// has had both routes from the start — one shape for the two antennas rather
// than two shapes to remember.
type Transport struct {
Mode string // "tcp" | "serial"
Host string // tcp
Port int // tcp
COM string // serial device (COM3, /dev/ttyUSB0)
Baud int // serial baud
}
// 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 {
tr Transport
conn io.ReadWriteCloser
connMu sync.Mutex
reader *bufio.Reader
lastStatus *Status
statusMu sync.RWMutex
stopChan chan struct{}
// done is closed by pollLoop as it exits, so Stop can WAIT for it. Without
// that wait the loop outlives the client that owns it, and on a serial link
// the zombie keeps the port open: every later client then fails with "Serial
// port busy" — forever, because the thing holding the port is us.
done chan struct{}
running bool
seqNum byte
seqMu sync.Mutex
// First-exchange diagnostics — see armDiag.
diagMu sync.Mutex
diag bool
diagJunk []byte
// 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
// moveCmdAt is when a move (frequency or direction) was last COMMANDED. The
// status poll runs every couple of seconds, so without this the "moving" flag
// — which drives the UI indicator and the Flex TX-inhibit — appeared up to a
// poll late even though the elements start moving at once. GetStatus reports
// motion during a short window after a command so both react immediately.
moveCmdAt time.Time
}
// ubMoveOptimisticWindow is how long after a commanded move GetStatus reports
// "moving" before the status poll has had a chance to read the real motor state.
// It only needs to bridge one poll interval; once a poll sees real motion, that
// takes over. Bounded, so if the antenna never reports motion the flag still
// clears rather than latching the TX-inhibit on for ever.
const ubMoveOptimisticWindow = 3 * time.Second
// 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(tr Transport) *Client {
if tr.Baud <= 0 {
tr.Baud = 9600
}
return &Client{
tr: tr,
stopChan: make(chan struct{}),
seqNum: 0,
}
}
// open dials the transport. Callers hold connMu.
func (c *Client) open() (io.ReadWriteCloser, error) {
if c.tr.Mode == "serial" {
if c.tr.COM == "" {
return nil, fmt.Errorf("ultrabeam: no serial port configured")
}
// 8N1 spelled out. The library's zero values happen to mean the same
// thing today, but a controller that answers nothing is impossible to
// diagnose with a line count that depends on a default.
p, err := serial.Open(c.tr.COM, &serial.Mode{
BaudRate: c.tr.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
if err != nil {
return nil, err
}
// A finite read timeout so a silent controller cannot wedge the poll
// loop. Replaced per exchange by setReadTimeout below.
_ = p.SetReadTimeout(ubReadTimeout)
return p, nil
}
if c.tr.Host == "" {
return nil, fmt.Errorf("ultrabeam: no host configured")
}
dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
return dialer.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
}
// diagNextExchange asks for the next command/reply to be dumped to the log.
//
// "It does not work" is unanswerable for a serial link, because the three
// possible causes look identical from the outside: nothing arrives (wrong port,
// dead cable, controller off), something arrives but is not our protocol (wrong
// baud), or a valid frame arrives and we misread it. One hex dump of the first
// exchange after each connect separates them, and costs two log lines per
// connection.
func (c *Client) armDiag() {
c.diagMu.Lock()
c.diag = true
c.diagJunk = c.diagJunk[:0]
c.diagMu.Unlock()
}
func (c *Client) diagOn() bool {
c.diagMu.Lock()
defer c.diagMu.Unlock()
return c.diag
}
// target names what the client is talking to, for the log.
func (c *Client) target() string {
if c.tr.Mode == "serial" {
return fmt.Sprintf("%s @ %d baud", c.tr.COM, c.tr.Baud)
}
return fmt.Sprintf("%s:%d", c.tr.Host, c.tr.Port)
}
// setReadTimeout bounds the next read, whichever transport is open.
//
// TCP takes a deadline (an instant) and serial a timeout (a duration) — the two
// libraries disagree, and the caller should not have to care.
func (c *Client) setReadTimeout(d time.Duration) {
switch t := c.conn.(type) {
case net.Conn:
_ = t.SetReadDeadline(time.Now().Add(d))
case serial.Port:
_ = t.SetReadTimeout(d)
}
}
// transientRead reports a read that timed out rather than failed.
//
// The two transports say it differently. TCP returns a net.Error with
// Timeout(); a serial port that stays silent returns (0, nil) on every read,
// which bufio turns into io.ErrNoProgress after a hundred empty attempts.
// Neither means the link is gone — over a remote link, or with a controller
// busy moving its motors, a slow reply is ordinary.
func transientRead(err error) bool {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return true
}
return errors.Is(err, io.ErrNoProgress)
}
func (c *Client) Start() error {
c.running = true
c.done = make(chan struct{})
go c.pollLoop()
return nil
}
// Stop closes the link and WAITS for the poll loop to leave.
//
// The wait is the point. Closing the port from another goroutine does not undo
// an open() the loop is already inside: that open returns a fresh handle, the
// loop stores it, and the client that was told to stop goes on owning the serial
// port. The next client — a settings save, a profile switch — then cannot open
// it, and the operator sees "Serial port busy" with no other program running.
//
// Bounded, because a serial open can sit in the driver for a while and a stuck
// device must not freeze a settings save. If the deadline passes, the loop is
// still on its way out and the log says so.
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()
if c.done == nil {
return
}
select {
case <-c.done:
case <-time.After(6 * time.Second):
log.Printf("Ultrabeam: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target())
}
}
func (c *Client) pollLoop() {
// Signals Stop that the port is genuinely released.
defer func() {
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
if c.done != nil {
close(c.done)
}
}()
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 to %s...", c.target())
conn, err := c.open()
if err != nil {
log.Printf("Ultrabeam: Connection failed: %v%s", err, portBusyHint(c.tr.Mode, c.tr.COM, err))
c.connMu.Unlock()
// Mark as disconnected
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
c.statusMu.Unlock()
continue
}
c.conn = conn
// Stopped while open() was running? Let go of the port at once.
// Stop cannot interrupt an open already in flight, so without this
// the fresh handle is stored by a client that has been told to die,
// and it keeps the port — which is precisely the "Serial port busy"
// the next client then reports, forever.
select {
case <-c.stopChan:
conn.Close()
c.conn = nil // already closed; keep the deferred cleanup from closing it twice
c.connMu.Unlock()
return
default:
}
c.reader = bufio.NewReader(c.conn)
pollFails = 0
log.Printf("Ultrabeam: Connected to %s", c.target())
// Dump the first exchange on this link. A connection that opens and
// then says nothing is the whole of what a serial user can see.
c.armDiag()
}
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.
transient := transientRead(err)
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
}
// Copy so the optimistic-motion tweak below never mutates the cached status
// the poll goroutine owns.
st := *c.lastStatus
// Optimistic motion: right after a commanded move, report "moving" until a
// status poll can read the real motor state (~one poll interval). The elements
// start moving the instant the operator clicks a band/pattern, so this makes
// the UI indicator and the Flex TX-inhibit react immediately instead of a poll
// later. Real polled motion takes over once seen; the window is bounded so the
// flag can't latch the inhibit on for ever.
if st.Connected && st.MotorsMoving == 0 && !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow {
st.MotorsMoving = 1 // sentinel: optimistically moving (read only as != 0)
}
return &st, 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. The seq is
// supplied by the caller so sendCommand can match the reply against it.
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
// 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")
}
// Flush anything already sitting in the stream before we send. The antenna
// does NOT echo our sequence number — its replies carry their own counter — so
// a reply cannot be matched to its request. Instead we keep the exchange 1:1:
// a reply left behind by an earlier timed-out command is discarded here, so
// the reply we read next belongs to THIS command. Reading a stale reply as the
// current one crossed STATUS with READ_BANDS/PROGRESS — phantom frequencies (a
// spurious follow-loop re-tune), dropped connections and wrong element lengths.
c.drainStale()
seq := c.getNextSeq()
packet := c.buildPacket(seq, cmd, data)
diag := c.diagOn()
if diag {
log.Printf("Ultrabeam: first exchange on %s — sending %d bytes: % X", c.target(), len(packet), packet)
}
if _, err := c.conn.Write(packet); err != nil {
return nil, fmt.Errorf("failed to write: %w", err)
}
// Read the reply with a timeout generous enough for a remote link.
c.setReadTimeout(ubReadTimeout)
buffer, err := c.readPacket()
if diag {
c.diagMu.Lock()
junk := append([]byte(nil), c.diagJunk...)
c.diag = false
c.diagMu.Unlock()
switch {
case err != nil && len(junk) == 0:
log.Printf("Ultrabeam: first exchange — NOTHING came back (%v). The controller is not answering on this port. Note that the USB cable presents TWO COM ports and only the SECOND one reaches the controller (at 19200 baud on the units seen so far); also check the cable and that the controller is on.", err)
case err != nil:
log.Printf("Ultrabeam: first exchange — %d bytes came back but no frame started (% X): %v. Bytes with no frame usually mean the wrong baud rate.", len(junk), junk, err)
default:
log.Printf("Ultrabeam: first exchange — reply %d bytes: % X (%d discarded before the frame: % X)", len(buffer), buffer, len(junk), junk)
}
}
if err != nil {
return nil, err
}
_, 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
}
}
// drainStale discards any bytes already waiting in the stream — a reply left
// behind by a command that timed out. A short read deadline lets it consume what
// is there and stop quickly when the stream is clean. Caller holds connMu.
func (c *Client) drainStale() {
c.setReadTimeout(5 * time.Millisecond)
defer c.setReadTimeout(ubReadTimeout) // leave the link on its normal budget
buf := make([]byte, 256)
for {
n, err := c.reader.Read(buf)
if n == 0 || err != nil {
return
}
}
}
// readPacket reads one complete STX…ETX frame, skipping any leading bytes until
// an STX so a partial/garbage remnant left in the stream can't derail the parse.
// STX/ETX/DLE are all high-bit (0xF5/0xFA/0xF6) and the escaping clears the MSB,
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
// has set a read deadline.
func (c *Client) readPacket() ([]byte, error) {
// A DEADLINE for the whole frame, not a timeout per read.
//
// A silent serial port does not error: it returns (0, nil) on every read once
// its timeout expires, and bufio retries that a hundred times before giving up
// with ErrNoProgress. With a 4-second port timeout that is over six minutes of
// a poll loop frozen mid-exchange, logging nothing — which is exactly how a
// controller that never answered looked like a program that had hung. Short
// port timeouts, checked against a deadline here, turn it into one clear
// "nothing came back" after four seconds.
deadline := time.Now().Add(ubReadTimeout)
c.setReadTimeout(250 * time.Millisecond)
defer c.setReadTimeout(ubReadTimeout)
var buffer []byte
for {
// A stop must not have to wait out the deadline. Without this, tearing the
// client down mid-exchange took up to four seconds — long enough for the
// replacement client to find the port still held, and for Stop to give up
// waiting and say so.
select {
case <-c.stopChan:
return nil, fmt.Errorf("stopped")
default:
}
if time.Now().After(deadline) {
if len(buffer) == 0 {
return nil, fmt.Errorf("no reply within %s", ubReadTimeout)
}
return nil, fmt.Errorf("incomplete frame within %s (% X)", ubReadTimeout, buffer)
}
b, err := c.reader.ReadByte()
if err != nil {
// A quiet port between bytes is normal — go.bug.st returns (0, nil) on
// its timeout, which bufio eventually reports as ErrNoProgress. Only the
// deadline above decides that the exchange has failed.
if transientRead(err) {
continue
}
return nil, fmt.Errorf("failed to read: %w", err)
}
if len(buffer) == 0 && b != STX {
// Kept, briefly, for the first exchange after a connect: what gets
// discarded here IS the diagnosis when the link is misconfigured.
c.diagMu.Lock()
if c.diag && len(c.diagJunk) < 32 {
c.diagJunk = append(c.diagJunk, b)
}
c.diagMu.Unlock()
continue // resync to the start of a frame
}
buffer = append(buffer, b)
if b == ETX {
return buffer, nil
}
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
}
}
// 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
}
// An RCU-06 controller — seen behind an RS232-to-Ethernet bridge — answers with
// an 11-byte status frame: the standard one WITHOUT the trailing FreqMax byte.
// The packet checksum was already verified, so a short-but-valid frame is real,
// not a fragment. Accept it and default the missing tail fields instead of
// reconnect-looping on "reply too short". reply[9]/[10] (MotorsMoving, FreqMin)
// are the last we truly need, so 10 bytes is the floor.
if len(reply) < 10 {
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]),
}
if len(reply) > 10 {
status.FreqMin = int(reply[10])
}
if len(reply) > 11 {
status.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
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
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
}
// portBusyHint turns "Serial port busy" into something actionable.
//
// A COM port has exactly one owner. The message the driver gives back says the
// port is busy and stops there, which reads like a fault in OpsLog — and the
// program actually holding it is usually the antenna manufacturer's own control
// window, sitting open on the same desktop. Naming that is the difference
// between a bug report and a five-second fix.
func portBusyHint(mode, com string, err error) string {
if mode != "serial" || err == nil {
return ""
}
msg := strings.ToLower(err.Error())
if !strings.Contains(msg, "busy") && !strings.Contains(msg, "access is denied") && !strings.Contains(msg, "denied") {
return ""
}
return " — another program already has " + com + " open (the UltraBeam Controller window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
}