From 5bf7eb45d7d659d8b72584c0b3c8c9cdbae2a035 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sat, 15 Aug 2026 09:22:51 +0200 Subject: [PATCH] feat(rotator): native SPID / AlfaSpid, so PstRotator can go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operator with a tower at each end and an AlfaSpid on both wanted OpsLog to talk to them directly. Multiple rotors were already there; the missing half was the protocol. Rot2Prog and Rot1Prog, over the controller's own COM port. The byte layout is in the package doc and pinned by table tests against Hamlib's spid.c, the reference implementation — including the one trap this protocol has: digits go out as ASCII and come back as raw bytes. Send raw and the controller ignores you; read as ASCII and every heading is wrong by a constant nobody would recognise as such. Two things the UI has to get right because they cannot be detected: the dialect (different reply length AND baud rate) and the baud list, which for a SPID is 600 or 1200 — offering the usual 4800-and-up would have left the controller permanently mute. Both are handled: picking SPID sets serial transport, 600 baud and Rot2Prog, and the baud dropdown changes to the rates these use. The connection test reads a status rather than moving anything, so a wrong dialect shows up there as a reply of the wrong length instead of as an antenna that behaves oddly an hour later. Untested against real hardware — I have none. The frames are pinned; the controller is the only thing that can confirm the rest. --- app.go | 57 ++++- changelog.json | 6 +- frontend/src/components/SettingsModal.tsx | 34 ++- frontend/src/lib/i18n.tsx | 2 + frontend/wailsjs/go/models.ts | 2 + internal/rotator/spid/spid.go | 257 ++++++++++++++++++++++ internal/rotator/spid/spid_test.go | 99 +++++++++ 7 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 internal/rotator/spid/spid.go create mode 100644 internal/rotator/spid/spid_test.go diff --git a/app.go b/app.go index d99dbfc..9e8cc4a 100644 --- a/app.go +++ b/app.go @@ -57,6 +57,7 @@ import ( "hamlog/internal/relaydev" "hamlog/internal/rigctld" "hamlog/internal/rotator/dcu1" + "hamlog/internal/rotator/spid" "hamlog/internal/rotator/gs232" "hamlog/internal/rotator/pst" "hamlog/internal/rotgenius" @@ -14186,6 +14187,10 @@ type RotatorDevice struct { Transport string `json:"transport"` // ARCO: "tcp" (LAN) | "serial" (USB COM) ComPort string `json:"com_port"` // GS-232 serial transport Baud int `json:"baud"` // GS-232 serial baud (an ERC needs it; an ARCO ignores it) + // SpidModel picks the SPID dialect: "rot2prog" (RAS/BIG-RAS/MD-01/MD-02, + // azimuth + elevation) or "rot1prog" (the older azimuth-only controller). + // They differ in reply length and baud rate, so guessing is not an option. + SpidModel string `json:"spid_model,omitempty"` } // logicalRotor is one addressable rotor. Flattening the device list expands a @@ -14198,7 +14203,7 @@ type logicalRotor struct { // normRotorType clamps a rotor type to a known backend. func normRotorType(t string) string { - if t == "rotgenius" || t == "arco" || t == "dcu1" { + if t == "rotgenius" || t == "arco" || t == "dcu1" || t == "spid" { return t } return "pst" @@ -14224,6 +14229,7 @@ func deviceLink(d RotatorDevice, sub int) rotorLink { l := rotorLink{ Type: normRotorType(d.Type), Host: d.Host, Port: d.Port, Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: d.HasElevation, + SpidModel: d.SpidModel, } if l.Host == "" { l.Host = "127.0.0.1" @@ -14232,7 +14238,12 @@ func deviceLink(d RotatorDevice, sub int) rotorLink { l.Port = rotatorDefaultPort(l.Type) } if l.Baud <= 0 { - l.Baud = 9600 + // A SPID runs at 600 or 1200 baud depending on the dialect; 0 lets its + // driver pick, and forcing 9600 here would have made every controller + // mute for a reason nobody would guess. + if l.Type != "spid" { + l.Baud = 9600 + } } if l.Transport != "serial" { l.Transport = "tcp" @@ -14368,6 +14379,7 @@ type rotorLink struct { ComPort string Baud int HasElevation bool + SpidModel string // SPID: "rot2prog" (default) | "rot1prog" } // activeRotorIndex returns the compass-selected rotor index, clamped to the @@ -14415,6 +14427,22 @@ func dcu1Client(l rotorLink) *dcu1.Client { return dcu1.New(l.Host, l.Port) } +// spidClient builds the SPID (AlfaSpid) client for a rotor. +// +// Serial only, and that is the point: these controllers have a COM port and +// nothing else. The request this answers was to drive them WITHOUT PstRotator +// sitting in between, so there is no network transport to offer. +// +// Baud 0 lets the driver take the dialect's documented default — 600 baud for +// Rot2Prog, 1200 for Rot1Prog. Those numbers look wrong and are not. +func spidClient(l rotorLink) *spid.Client { + m := spid.Rot2Prog + if l.SpidModel == string(spid.Rot1Prog) { + m = spid.Rot1Prog + } + return spid.New(l.ComPort, l.Baud, m) +} + // RotatorHeading is the live antenna heading for the status bar and compass. type RotatorHeading struct { Enabled bool `json:"enabled"` @@ -14481,6 +14509,16 @@ func (a *App) GetRotatorHeading() RotatorHeading { base.Azimuth = az base.Raw = raw return base + case "spid": + az, _, herr := spidClient(link).Heading() + if herr != nil { + base.Raw = herr.Error() + return base + } + base.OK = true + base.Azimuth = az + base.Raw = fmt.Sprintf("%d°", az) + return base case "dcu1": az, raw, herr := dcu1Client(link).Heading() if herr != nil { @@ -14531,6 +14569,8 @@ func (a *App) RotatorGoToPath(az int, el int, path string) error { return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az) case "arco": return arcoClient(link).GoTo(az) + case "spid": + return spidClient(link).GoTo(az, el) case "dcu1": return dcu1Client(link).GoTo(az) default: @@ -14550,6 +14590,8 @@ func (a *App) RotatorStop() error { return rotgenius.New(link.Host, link.Port).Stop() case "arco": return arcoClient(link).Stop() + case "spid": + return spidClient(link).Stop() case "dcu1": return dcu1Client(link).Stop() default: @@ -14570,6 +14612,8 @@ func (a *App) RotatorPark() error { return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius") case "arco": return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link") + case "spid": + return fmt.Errorf("park is a PstRotator feature; a SPID controller has no park command") case "dcu1": return fmt.Errorf("park is a PstRotator feature; not available over the DCU-1 link") default: @@ -14610,6 +14654,15 @@ func testRotorLink(l rotorLink) error { // GS-232 — without moving the antenna. _, _, err := arcoClient(l).Heading() return err + case "spid": + if strings.TrimSpace(l.ComPort) == "" { + return fmt.Errorf("select the SPID controller's COM port first") + } + // A status read proves the port, the baud rate and the dialect at once, + // without moving anything — and a wrong dialect shows up here as a reply + // of the wrong length rather than as an antenna that turns oddly later. + _, _, err := spidClient(l).Heading() + return err case "dcu1": if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" { return fmt.Errorf("select the DCU-1 controller's COM port first") diff --git a/changelog.json b/changelog.json index c489428..769ca2d 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,12 @@ "version": "0.25.3", "date": "", "en": [ - "Confirmations: a LoTW contact marked V (verified) counted for the awards but not for the band/mode matrix, the slot statistics or the row colours — they only accepted Y. One entity could read “validated” in Awards and “worked” beside it." + "Confirmations: a LoTW contact marked V (verified) counted for the awards but not for the band/mode matrix, the slot statistics or the row colours — they only accepted Y. One entity could read “validated” in Awards and “worked” beside it.", + "Rotators: SPID / AlfaSpid controllers are driven natively over their own COM port — Rot2Prog and Rot1Prog — so PstRotator is no longer needed in between. Two towers means two rotors, as before." ], "fr": [ - "Confirmations : un contact LoTW marqué V (vérifié) comptait pour les diplômes mais pas pour la matrice bande/mode, les statistiques de créneaux ni la coloration des lignes — elles n’acceptaient que Y. Une même entité pouvait être « validée » dans Diplômes et « travaillée » juste à côté." + "Confirmations : un contact LoTW marqué V (vérifié) comptait pour les diplômes mais pas pour la matrice bande/mode, les statistiques de créneaux ni la coloration des lignes — elles n’acceptaient que Y. Une même entité pouvait être « validée » dans Diplômes et « travaillée » juste à côté.", + "Rotors : les contrôleurs SPID / AlfaSpid sont pilotés nativement par leur propre port COM — Rot2Prog et Rot1Prog — sans passer par PstRotator. Deux pylônes restent deux rotors, comme avant." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 58185d1..75b4cfd 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3674,7 +3674,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const isRG = dev.type === 'rotgenius'; const isARCO = dev.type === 'arco'; const isDCU1 = dev.type === 'dcu1'; - const isSerialCap = isARCO || isDCU1; // COM-port or serial-over-IP controllers + // A SPID has a COM port and nothing else — no network transport to + // offer, which is the whole point of driving it without PstRotator. + const isSPID = dev.type === 'spid'; + const isSerialCap = isARCO || isDCU1 || isSPID; // COM-port or serial-over-IP controllers const transport = dev.transport ?? 'tcp'; return (
@@ -3693,13 +3696,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001 (placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
@@ -3716,8 +3720,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} + {/* SPID: pick the dialect. They differ in reply length AND baud + rate, so this cannot be detected — a wrong choice is a + controller that never answers. */} + {isSPID && ( +
+ + +
+ )} {/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */} - {isSerialCap && ( + {isSerialCap && !isSPID && (
patch(i, { baud: Number(v) })}> + {/* A SPID runs at 600 or 1200 baud — not a typo, a pulse + controller has nothing to say quickly. Offering only the + usual rates would have left it permanently mute. */} +