feat(rotator): native SPID / AlfaSpid, so PstRotator can go
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.
This commit is contained in:
@@ -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")
|
||||
|
||||
+4
-2
@@ -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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||
@@ -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. */}
|
||||
<Select value={dev.type ?? 'pst'}
|
||||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' ? { transport: 'serial' } : {}) })}>
|
||||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' || v === 'spid' ? { transport: 'serial' } : {}), ...(v === 'spid' ? { baud: 600, spid_model: 'rot2prog' } : {}) })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||
<SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
|
||||
<SelectItem value="spid">SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -3716,8 +3720,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.spidModel')}</Label>
|
||||
<Select value={dev.spid_model || 'rot2prog'}
|
||||
onValueChange={(v) => patch(i, { spid_model: v as any, baud: v === 'rot1prog' ? 1200 : 600 })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rot2prog">Rot2Prog (RAS, BIG-RAS/HR, MD-01, MD-02)</SelectItem>
|
||||
<SelectItem value="rot1prog">Rot1Prog (azimuth only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
|
||||
{isSerialCap && (
|
||||
{isSerialCap && !isSPID && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||
@@ -3751,10 +3771,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Select value={String(dev.baud || 9600)} onValueChange={(v) => 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. */}
|
||||
<Select value={String(dev.baud || (isSPID ? 600 : 9600))} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600]).map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -3784,6 +3807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
|
||||
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
|
||||
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||
{multi && (
|
||||
<div className="space-y-1 max-w-xs">
|
||||
|
||||
@@ -285,6 +285,7 @@ const en: Dict = {
|
||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'rot.spidHint': 'Native SPID protocol over the controller’s COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol',
|
||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.dcu1Hint': "Speaks the Hy-Gain DCU-1 command set (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connect over the controller's COM port (a DCU-1 is 4800 baud; RotorCard/Green Heron may differ — match the controller) or over TCP through a serial-over-IP bridge. Azimuth only, no elevation. New backend — please report if your controller needs a different command or baud.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
@@ -708,6 +709,7 @@ const fr: Dict = {
|
||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID',
|
||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.dcu1Hint': "Parle le jeu de commandes Hy-Gain DCU-1 (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connexion via le port COM du contrôleur (un DCU-1 est à 4800 bauds ; RotorCard/Green Heron peuvent différer — reprendre la vitesse du contrôleur) ou en TCP via un pont série-sur-IP. Azimut uniquement, pas d'élévation. Nouveau pilote — signalez si votre contrôleur nécessite une autre commande ou vitesse.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
|
||||
@@ -3033,6 +3033,7 @@ export namespace main {
|
||||
transport: string;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
spid_model?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorDevice(source);
|
||||
@@ -3054,6 +3055,7 @@ export namespace main {
|
||||
this.transport = source["transport"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
this.spid_model = source["spid_model"];
|
||||
}
|
||||
}
|
||||
export class RotatorHeading {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package spid
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The frames are pinned against Hamlib's spid.c, the reference implementation.
|
||||
// This protocol has one trap and it is here: digits go out as ASCII and come
|
||||
// back RAW. Getting that backwards points an antenna at a heading nobody asked
|
||||
// for, and nothing in the app would notice.
|
||||
func TestSetFrameMatchesTheReference(t *testing.T) {
|
||||
// Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII;
|
||||
// PH and PV raw; K = 0x2F.
|
||||
got := BuildSet(0, 0, 1) // 360 → "0360"
|
||||
want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20}
|
||||
assertBytes(t, "az 0 res 1", got, want)
|
||||
|
||||
// 90° at half-degree resolution: 2 × 450 = 900 → "0900".
|
||||
got = BuildSet(90, 0, 2)
|
||||
want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20}
|
||||
assertBytes(t, "az 90 res 2", got, want)
|
||||
|
||||
// A quarter-degree controller, 359°: 4 × 719 = 2876.
|
||||
got = BuildSet(359, 0, 4)
|
||||
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
|
||||
assertBytes(t, "az 359 res 4", got, want)
|
||||
}
|
||||
|
||||
// Status and stop carry no position: every data byte is zero, only K differs.
|
||||
func TestStatusAndStopFrames(t *testing.T) {
|
||||
assertBytes(t, "status", BuildStatus(),
|
||||
[]byte{0x57, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x1F, 0x20})
|
||||
assertBytes(t, "stop", BuildStop(),
|
||||
[]byte{0x57, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x0F, 0x20})
|
||||
}
|
||||
|
||||
// A reply's digits are RAW, and the 360 offset is what lets a pulse-counting
|
||||
// controller report a rotator that has turned past north — the whole reason
|
||||
// these rotators exist.
|
||||
func TestParseStatusRot2Prog(t *testing.T) {
|
||||
// 0x57 H1 H2 H3 H4 PH V1 V2 V3 V4 PV 0x20
|
||||
// az digits 4,5,1,5 → 451.5 − 360 = 91.5
|
||||
frame := []byte{0x57, 4, 5, 1, 5, 0x02, 3, 6, 0, 0, 0x02, 0x20}
|
||||
az, el, res, err := ParseStatus(frame, Rot2Prog)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStatus: %v", err)
|
||||
}
|
||||
if math.Abs(az-91.5) > 0.001 {
|
||||
t.Errorf("az = %v, want 91.5", az)
|
||||
}
|
||||
if math.Abs(el-0) > 0.001 {
|
||||
t.Errorf("el = %v, want 0", el)
|
||||
}
|
||||
if res != 2 {
|
||||
t.Errorf("resolution = %d, want 2 — the controller's own value must win", res)
|
||||
}
|
||||
}
|
||||
|
||||
// Rot1Prog: five bytes, three digits, no elevation.
|
||||
func TestParseStatusRot1Prog(t *testing.T) {
|
||||
az, el, res, err := ParseStatus([]byte{0x57, 4, 5, 1, 0x20}, Rot1Prog)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStatus: %v", err)
|
||||
}
|
||||
if math.Abs(az-91) > 0.001 {
|
||||
t.Errorf("az = %v, want 91", az)
|
||||
}
|
||||
if el != 0 || res != 1 {
|
||||
t.Errorf("el = %v, res = %d — Rot1Prog has neither", el, res)
|
||||
}
|
||||
}
|
||||
|
||||
// A truncated or foreign frame must be refused rather than decoded into a
|
||||
// heading: half a reply read as a position turns an antenna somewhere real.
|
||||
func TestParseStatusRefusesRubbish(t *testing.T) {
|
||||
for name, frame := range map[string][]byte{
|
||||
"short": {0x57, 4, 5, 1},
|
||||
"no start": {0x00, 4, 5, 1, 5, 1, 3, 6, 0, 0, 1, 0x20},
|
||||
"no end": {0x57, 4, 5, 1, 5, 1, 3, 6, 0, 0, 1, 0x00},
|
||||
"empty": {},
|
||||
} {
|
||||
if _, _, _, err := ParseStatus(frame, Rot2Prog); err == nil {
|
||||
t.Errorf("%s: decoded without complaint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertBytes(t *testing.T, what string, got, want []byte) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: % X\nwant % X", what, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s: % X\nwant % X", what, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user