The antenna does NOT echo our sequence number — its replies carry their own counter — so the previous seq-matching drained every reply and stalled status updates. Revert to reading one reply per command, but flush any bytes left in the stream before each command (drainStale): a reply left by a timed-out command is discarded so the next read stays 1:1. readPacket also resyncs to the next STX. This fixes the intermittent disconnects, phantom frequency jumps and wrong element-length readings without depending on the seq. Also: report motion for a short window right after a commanded move, so the "moving" indicator and the Flex TX-inhibit fire the instant a band/pattern is clicked instead of a poll (~2 s) later; the real motor state takes over once polled.
640 lines
20 KiB
Go
640 lines
20 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
|
|
|
|
// 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(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
|
|
}
|
|
|
|
// 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)
|
|
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.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
|
|
buffer, err := c.readPacket()
|
|
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.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
|
|
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) {
|
|
var buffer []byte
|
|
for {
|
|
b, err := c.reader.ReadByte()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read: %w", err)
|
|
}
|
|
if len(buffer) == 0 && b != STX {
|
|
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
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|