// Package spid drives a SPID (AlfaSpid) rotator over its own serial protocol, // Rot1Prog or Rot2Prog — the controllers sold as RAS, RAK, BIG-RAS/HR, MD-01 // and MD-02. // // It exists so an operator with SPID rotators does not need PstRotator running // just to turn an antenna. Two towers with a controller each is the ordinary // case; each one is a separate serial port and a separate rotor in OpsLog. // // WIRE FORMAT // // Every command is 13 bytes: // // 0 1 2 3 4 5 6 7 8 9 10 11 12 // 0x57 H1 H2 H3 H4 PH V1 V2 V3 V4 PV K 0x20 // // K is the command: 0x0F stop, 0x1F status, 0x2F set. // // The DIGITS ARE ASCII in a command ('0'+d) and RAW BYTES in a reply (0..9). // That asymmetry is the whole trap in this protocol: send raw digits and the // controller ignores you, read them as ASCII and every heading is 48 degrees // times a hundred out. It is pinned by the tests beside this file. // // PH and PV are the resolution in pulses per degree — 1, 2 or 4 — and are raw // in both directions. The target is scaled by it: // // u_az = PH × (360 + az) and the four decimal digits of u_az are sent // // A reply is 12 bytes for Rot2Prog (azimuth and elevation) or 5 for Rot1Prog // (azimuth only, three digits): // // az = H1×100 + H2×10 + H3 + H4/10 − 360 // // The 360 offset is what lets the controller report a rotator that has turned // past north in either direction, which is the point of a pulse-counting // rotator: −180…540 rather than 0…359. // // Serial is 8N1 at 600 baud for Rot2Prog and 1200 for Rot1Prog. Those are not // typos — a pulse controller has nothing to say quickly. // // Verified against Hamlib's spid.c (rotators/spid/spid.c), which is the // reference implementation, and SPID's published protocol note. NOT yet run // against real hardware here; the tests pin the frames, the controller is the // only thing that can confirm the rest. package spid import ( "fmt" "strings" "sync" "time" "go.bug.st/serial" ) // Model selects the dialect. type Model string const ( Rot1Prog Model = "rot1prog" // azimuth only, 5-byte reply, 1200 baud Rot2Prog Model = "rot2prog" // azimuth + elevation, 12-byte reply, 600 baud ) const ( cmdStop = 0x0F cmdStatus = 0x1F cmdSet = 0x2F frameStart = 0x57 frameEnd = 0x20 ) // Client is one controller on one serial port. // // The port is opened per exchange rather than held: a rotator is polled every // few seconds at most, and holding a COM port open for the life of the program // is what stops an operator from using their controller's own software // alongside — which they will want while they are still trusting this. type Client struct { mu sync.Mutex port string baud int model Model // resolution is pulses per degree: 1, 2 or 4. The controller is configured // for one of them and answers with it, so a wrong value here corrects itself // on the first status read. resolution byte } // New builds a client. baud 0 takes the model's documented default. func New(comPort string, baud int, model Model) *Client { if model != Rot1Prog { model = Rot2Prog } if baud <= 0 { baud = 600 if model == Rot1Prog { baud = 1200 } } return &Client{port: strings.TrimSpace(comPort), baud: baud, model: model, resolution: 1} } // BuildStatus frames the "where are you" command. func BuildStatus() []byte { return buildCmd(0, 0, 0, 0, cmdStatus) } // BuildStop frames the "stop now" command. func BuildStop() []byte { return buildCmd(0, 0, 0, 0, cmdStop) } // BuildSet frames a target. resolution is the controller's pulses per degree. // // Azimuth is offset by 360 before scaling, so a target of −10° and one of 350° // are different instructions: the first turns anticlockwise past north, the // second does not. Feeding a 0…359 heading in is therefore always safe. func BuildSet(az, el float64, resolution byte) []byte { if resolution == 0 { resolution = 1 } uaz := int(float64(resolution)*(360+az) + 0.5) uel := int(float64(resolution)*(360+el) + 0.5) return buildCmd(uaz, uel, resolution, resolution, cmdSet) } func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte { c := make([]byte, 13) c[0] = frameStart if k == cmdSet { c[1] = '0' + byte(uaz/1000%10) c[2] = '0' + byte(uaz/100%10) c[3] = '0' + byte(uaz/10%10) c[4] = '0' + byte(uaz%10) c[5] = ph c[6] = '0' + byte(uel/1000%10) c[7] = '0' + byte(uel/100%10) c[8] = '0' + byte(uel/10%10) c[9] = '0' + byte(uel%10) c[10] = pv } c[11] = k c[12] = frameEnd return c } // ParseStatus decodes a reply. Returns the azimuth, the elevation (0 for // Rot1Prog) and the resolution the controller reported. func ParseStatus(buf []byte, model Model) (az, el float64, resolution byte, err error) { want := 12 if model == Rot1Prog { want = 5 } if len(buf) < want { return 0, 0, 0, fmt.Errorf("spid: short reply (%d bytes, want %d)", len(buf), want) } if buf[0] != frameStart || buf[want-1] != frameEnd { return 0, 0, 0, fmt.Errorf("spid: not a reply frame: % X", buf[:want]) } az = float64(buf[1])*100 + float64(buf[2])*10 + float64(buf[3]) if model == Rot1Prog { return az - 360, 0, 1, nil } az += float64(buf[4]) / 10 el = float64(buf[6])*100 + float64(buf[7])*10 + float64(buf[8]) + float64(buf[9])/10 resolution = buf[5] if resolution == 0 { resolution = 1 } return az - 360, el - 360, resolution, nil } // GoTo points the rotator at az (and el, on a Rot2Prog with elevation). func (c *Client) GoTo(az int, el int) error { c.mu.Lock() res := c.resolution c.mu.Unlock() e := 0.0 if el >= 0 && c.model == Rot2Prog { e = float64(el) } _, err := c.exchange(BuildSet(float64(az), e, res), 0) return err } // Stop interrupts a rotation in progress. func (c *Client) Stop() error { // The controller answers a stop with its position, like a status — read it // so the reply does not sit in the buffer and get taken for the ANSWER to // the next poll, which would report a heading one command stale for ever. _, err := c.exchange(BuildStop(), c.replyLen()) return err } // Heading reads the current position. func (c *Client) Heading() (az int, el int, err error) { buf, err := c.exchange(BuildStatus(), c.replyLen()) if err != nil { return 0, 0, err } a, e, res, err := ParseStatus(buf, c.model) if err != nil { return 0, 0, err } // Believe the controller about its own resolution: it is configured on the // front panel, and a wrong guess here would scale every target we send. c.mu.Lock() c.resolution = res c.mu.Unlock() return int(a + 0.5), int(e + 0.5), nil } func (c *Client) replyLen() int { if c.model == Rot1Prog { return 5 } return 12 } // exchange opens the port, writes one frame and reads the expected reply. func (c *Client) exchange(cmd []byte, wantBytes int) ([]byte, error) { if c.port == "" { return nil, fmt.Errorf("spid: no serial port configured") } c.mu.Lock() defer c.mu.Unlock() p, err := serial.Open(c.port, &serial.Mode{ BaudRate: c.baud, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit, }) if err != nil { return nil, fmt.Errorf("spid: open %s: %w", c.port, err) } defer p.Close() // 600 baud is 60 bytes a second: a 12-byte reply takes a fifth of a second // to arrive on the wire alone, before the controller has thought about it. _ = p.SetReadTimeout(2 * time.Second) if _, err := p.Write(cmd); err != nil { return nil, fmt.Errorf("spid: write: %w", err) } if wantBytes == 0 { return nil, nil } buf := make([]byte, 0, wantBytes) tmp := make([]byte, wantBytes) deadline := time.Now().Add(3 * time.Second) for len(buf) < wantBytes && time.Now().Before(deadline) { n, err := p.Read(tmp) if n > 0 { buf = append(buf, tmp[:n]...) continue } if err != nil { break } } if len(buf) < wantBytes { return nil, fmt.Errorf("spid: no reply from %s (%d of %d bytes) — check the port, the baud rate (%d) and that nothing else holds the controller", c.port, len(buf), wantBytes, c.baud) } return buf, nil }