505 lines
12 KiB
Go
505 lines
12 KiB
Go
package ultrabeam
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
|
|
// Try to connect if not connected
|
|
c.connMu.Lock()
|
|
if c.conn == nil {
|
|
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
|
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)
|
|
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
|
}
|
|
c.connMu.Unlock()
|
|
|
|
// Query status
|
|
status, err := c.queryStatus()
|
|
if err != nil {
|
|
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
|
// Close connection and retry
|
|
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
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
}
|
|
|
|
c.statusMu.Lock()
|
|
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
|
|
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
|
|
|
|
// 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
|
|
}
|
|
|
|
// queryElementLengths queries element lengths (command 9)
|
|
func (c *Client) queryElementLengths() ([]int, error) {
|
|
reply, err := c.sendCommand(CMD_READ_BANDS, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Debug: log raw bytes
|
|
log.Printf("Ultrabeam element lengths raw reply (%d bytes): %v", len(reply), reply)
|
|
|
|
// Try to extract 6 words - the protocol says 6 words (12 bytes)
|
|
// But we're receiving 14 bytes, so there might be padding
|
|
|
|
if len(reply) < 12 {
|
|
return nil, fmt.Errorf("element lengths reply too short: %d bytes", len(reply))
|
|
}
|
|
|
|
lengths := make([]int, 6)
|
|
|
|
// Try different interpretations
|
|
log.Printf("=== Attempting different parsings ===")
|
|
|
|
// Method 1: Standard little-endian from byte 0
|
|
log.Printf("Method 1 (little-endian from 0):")
|
|
for i := 0; i < 6 && i*2+1 < len(reply); i++ {
|
|
lo := int(reply[i*2])
|
|
hi := int(reply[i*2+1])
|
|
val := lo | (hi << 8)
|
|
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2, i*2+1, lo, hi, val)
|
|
}
|
|
|
|
// Method 2: Big-endian from byte 0
|
|
log.Printf("Method 2 (big-endian from 0):")
|
|
for i := 0; i < 6 && i*2+1 < len(reply); i++ {
|
|
hi := int(reply[i*2])
|
|
lo := int(reply[i*2+1])
|
|
val := lo | (hi << 8)
|
|
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2, i*2+1, hi, lo, val)
|
|
}
|
|
|
|
// Method 3: Skip first 2 bytes, then little-endian
|
|
log.Printf("Method 3 (skip 2 bytes, little-endian):")
|
|
for i := 0; i < 6 && i*2+3 < len(reply); i++ {
|
|
lo := int(reply[i*2+2])
|
|
hi := int(reply[i*2+3])
|
|
val := lo | (hi << 8)
|
|
log.Printf(" Element %d: bytes[%d,%d] = [%d,%d] => %d mm", i, i*2+2, i*2+3, lo, hi, val)
|
|
}
|
|
|
|
// For now, use method 1 (original)
|
|
for i := 0; i < 6; i++ {
|
|
if i*2+1 >= len(reply) {
|
|
break
|
|
}
|
|
lo := int(reply[i*2])
|
|
hi := int(reply[i*2+1])
|
|
lengths[i] = lo | (hi << 8)
|
|
}
|
|
|
|
log.Printf("Final lengths: %v", lengths)
|
|
return lengths, 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
|
|
}
|
|
|
|
// SetFrequency changes frequency and optional direction (command 3)
|
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|
data := []byte{
|
|
byte(freqKhz & 0xFF),
|
|
byte((freqKhz >> 8) & 0xFF),
|
|
byte(direction),
|
|
}
|
|
|
|
_, err := c.sendCommand(CMD_FREQ, data)
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|