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 && (