fix(spid): a Rot1Prog takes three digits, so every target went the wrong way

Field report from a tower: on a RAK/RAU in Rot1Prog, every commanded heading
made the antenna want to turn nearly a full circle ANTICLOCKWISE — 0°, 90°, any
of them — while the heading readout, the stop button and everything else worked.

BuildSet framed the four-digit Rot2Prog azimuth for both dialects. A Rot1Prog
reads three: its replies are three digits in a five-byte frame, and its command
field matches. So 90° went out as "0450" and was read as 045 — 45 − 360 = −315°.
Every target landed 360° low, which is why it was always anticlockwise and
always nearly a full turn. The operator's own guess, that OpsLog was in 720°
mode, was the right instinct in the wrong place: the fault is a decimal shift,
not a range.

The round-trip test added here is the one that would have caught it without a
tower — every degree of the circle through the command builder and back through
the reply parser, which must return the degree that went in. The frame tests
pinned the Rot2Prog form against the reference and said nothing about the other
dialect.

Two more from the same report. A rotator test that only READS the heading — SPID,
ARCO, DCU-1 — said "Packet sent, the antenna should swing to north, check
PstRotator's UDP listener", naming a program not in the path for a move never
commanded; it now says the controller answered and nothing was moved. And the
compass polled every three seconds, so a turning antenna moved the needle in
steps of about thirteen degrees; the heading now has its own 700 ms tick while
the relay boards and the antenna controller stay at three seconds.
This commit is contained in:
2026-08-16 13:31:00 +02:00
parent 0924062ced
commit f405e71d7d
7 changed files with 133 additions and 16 deletions
+6 -2
View File
@@ -8,7 +8,9 @@
"New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.", "New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.",
"Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.", "Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.",
"Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.", "Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.",
"New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts." "New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts.",
"SPID rotator: a Rot1Prog controller turned nearly a full circle the wrong way for every heading — its commands take three digits, not four.",
"The compass now follows a turning antenna smoothly, and a rotator test that only reads the heading says so instead of naming PstRotator."
], ],
"fr": [ "fr": [
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.", "Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
@@ -16,7 +18,9 @@
"Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.", "Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.",
"Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.", "Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.",
"Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».", "Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».",
"Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W." "Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W.",
"Rotator SPID : un contrôleur Rot1Prog tournait presque un tour complet à lenvers pour chaque azimut — ses commandes tiennent trois chiffres, pas quatre.",
"Le compas suit désormais une antenne en rotation sans à-coups, et un test de rotator qui ne fait que lire lazimut le dit au lieu de citer PstRotator."
] ]
}, },
{ {
+8 -3
View File
@@ -2390,15 +2390,20 @@ export default function App() {
}; };
}, []); }, []);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the // Poll the rotator for the live antenna heading (status bar and compass).
// rotator is disabled (the backend just reads settings and returns). // Cheap when it is disabled the backend just reads settings and returns.
//
// 700 ms, not three seconds: a turning antenna moved the needle in steps of
// about thirteen degrees, which reads as a compass that jumps rather than one
// that sweeps. A heading query is a few bytes on a serial controller or one
// short exchange over TCP.
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = async () => { const tick = async () => {
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {} try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
}; };
tick(); tick();
const id = window.setInterval(tick, 3000); const id = window.setInterval(tick, 700);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
+13 -1
View File
@@ -3086,7 +3086,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setRotatorTest(null); setRotatorTest(null);
try { try {
await TestRotatorDevice(dev as any, sub); await TestRotatorDevice(dev as any, sub);
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') }); // Say what the test ACTUALLY did, which is not the same on every backend.
//
// PstRotator and a Rotator Genius are sent a move to 0°; a SPID, an ARCO
// and a DCU-1 are asked their heading and nothing turns. They all used to
// report "Packet sent — the antenna should swing to north. If it didn't,
// check PstRotator's UDP listener" — which named a program that is not in
// the path, for a move that was never commanded. An operator on a tower
// read that as the test having failed.
const type = (dev as any).type;
const msg = type === 'rotgenius' ? t('rot.testOkRG')
: (type === 'spid' || type === 'arco' || type === 'dcu1') ? t('rot.testOkRead')
: t('cat.rotatorOk');
setRotatorTest({ ok: true, msg });
} catch (e: any) { } catch (e: any) {
setRotatorTest({ ok: false, msg: String(e?.message ?? e) }); setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
} finally { } finally {
@@ -557,8 +557,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { loadDevices(); }, [loadDevices]);
useEffect(() => { useEffect(() => {
poll(); pollRot(); pollAnt(); poll(); pollRot(); pollAnt();
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000); const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
return () => window.clearInterval(id); // The rotor gets its own, faster tick. On three seconds the compass moved in
// steps of about thirteen degrees while the antenna was turning, which reads
// as a needle that jumps rather than one that sweeps — and an operator
// watching a tower wants to see it move. One heading query is a few bytes on
// a slow serial line or one short TCP exchange; the relay boards and the
// antenna controller are the expensive polls, and they stay at three
// seconds.
const rotId = window.setInterval(() => { pollRot(); }, 700);
return () => { window.clearInterval(id); window.clearInterval(rotId); };
}, [poll, pollRot, pollAnt, devices.length]); }, [poll, pollRot, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
File diff suppressed because one or more lines are too long
+45 -3
View File
@@ -6,7 +6,7 @@
// just to turn an antenna. Two towers with a controller each is the ordinary // 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. // case; each one is a separate serial port and a separate rotor in OpsLog.
// //
// WIRE FORMAT // # WIRE FORMAT
// //
// Every command is 13 bytes: // Every command is 13 bytes:
// //
@@ -30,6 +30,13 @@
// //
// az = H1×100 + H2×10 + H3 + H4/10 360 // az = H1×100 + H2×10 + H3 + H4/10 360
// //
// ROT1PROG IS THREE DIGITS IN BOTH DIRECTIONS. Its reply carries three, and so
// does its command — the controller reads the azimuth from offsets 1, 2 and 3,
// with no resolution scaling (it is one pulse per degree). Sending it the
// four-digit Rot2Prog form shifts every target by a decimal place: 90° goes out
// as "0450" and is read as 045, which is 315°, so every command turns the
// antenna nearly a full circle the wrong way. Found on a tower, not here.
//
// The 360 offset is what lets the controller report a rotator that has turned // 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 // past north in either direction, which is the point of a pulse-counting
// rotator: 180…540 rather than 0…359. // rotator: 180…540 rather than 0…359.
@@ -111,7 +118,22 @@ func BuildStop() []byte { return buildCmd(0, 0, 0, 0, cmdStop) }
// Azimuth is offset by 360 before scaling, so a target of 10° and one of 350° // 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 // are different instructions: the first turns anticlockwise past north, the
// second does not. Feeding a 0…359 heading in is therefore always safe. // second does not. Feeding a 0…359 heading in is therefore always safe.
func BuildSet(az, el float64, resolution byte) []byte { //
// ROT1PROG SENDS THREE DIGITS, NOT FOUR, and that is the whole reason this
// takes a model. Its reply is three digits — a 5-byte frame — and its command
// field matches: the controller reads the azimuth from offsets 1, 2 and 3.
//
// Sending the four-digit Rot2Prog form to one shifts every target by a decimal
// place. "0450" for 90° was read as 045, i.e. 45 360 = 315°, so every
// command became a near-full turn ANTICLOCKWISE whatever was asked for — 0°,
// 90°, 180°, all of them. That is exactly how it was reported from a tower:
// every heading wanted to go the wrong way round, and a "point to 0°" test that
// did nothing useful.
func BuildSet(az, el float64, resolution byte, model Model) []byte {
if model == Rot1Prog {
// No scaling: a Rot1Prog is one pulse per degree and reports resolution 1.
return buildCmd3(int(360+az+0.5), int(360+el+0.5))
}
if resolution == 0 { if resolution == 0 {
resolution = 1 resolution = 1
} }
@@ -120,6 +142,26 @@ func BuildSet(az, el float64, resolution byte) []byte {
return buildCmd(uaz, uel, resolution, resolution, cmdSet) return buildCmd(uaz, uel, resolution, resolution, cmdSet)
} }
// buildCmd3 frames a Rot1Prog target: three ASCII digits per axis at the same
// offsets its replies use, the fourth digit position left as '0'.
func buildCmd3(uaz, uel int) []byte {
c := make([]byte, 13)
c[0] = frameStart
c[1] = '0' + byte(uaz/100%10)
c[2] = '0' + byte(uaz/10%10)
c[3] = '0' + byte(uaz%10)
c[4] = '0'
c[5] = 0x01
c[6] = '0' + byte(uel/100%10)
c[7] = '0' + byte(uel/10%10)
c[8] = '0' + byte(uel%10)
c[9] = '0'
c[10] = 0x01
c[11] = cmdSet
c[12] = frameEnd
return c
}
func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte { func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte {
c := make([]byte, 13) c := make([]byte, 13)
c[0] = frameStart c[0] = frameStart
@@ -175,7 +217,7 @@ func (c *Client) GoTo(az int, el int) error {
if el >= 0 && c.model == Rot2Prog { if el >= 0 && c.model == Rot2Prog {
e = float64(el) e = float64(el)
} }
_, err := c.exchange(BuildSet(float64(az), e, res), 0) _, err := c.exchange(BuildSet(float64(az), e, res, c.model), 0)
return err return err
} }
+49 -3
View File
@@ -12,21 +12,67 @@ import (
func TestSetFrameMatchesTheReference(t *testing.T) { func TestSetFrameMatchesTheReference(t *testing.T) {
// Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII; // Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII;
// PH and PV raw; K = 0x2F. // PH and PV raw; K = 0x2F.
got := BuildSet(0, 0, 1) // 360 → "0360" got := BuildSet(0, 0, 1, Rot2Prog) // 360 → "0360"
want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20} want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20}
assertBytes(t, "az 0 res 1", got, want) assertBytes(t, "az 0 res 1", got, want)
// 90° at half-degree resolution: 2 × 450 = 900 → "0900". // 90° at half-degree resolution: 2 × 450 = 900 → "0900".
got = BuildSet(90, 0, 2) got = BuildSet(90, 0, 2, Rot2Prog)
want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20} want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20}
assertBytes(t, "az 90 res 2", got, want) assertBytes(t, "az 90 res 2", got, want)
// A quarter-degree controller, 359°: 4 × 719 = 2876. // A quarter-degree controller, 359°: 4 × 719 = 2876.
got = BuildSet(359, 0, 4) got = BuildSet(359, 0, 4, Rot2Prog)
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20} want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
assertBytes(t, "az 359 res 4", got, want) assertBytes(t, "az 359 res 4", got, want)
} }
// A Rot1Prog reads its azimuth from offsets 1, 2 and 3 — three digits, the same
// field its five-byte replies use.
//
// This is field evidence, not a reading of the reference: sending the
// four-digit Rot2Prog form to a RAK/RAU made every command turn the antenna
// nearly a full circle ANTICLOCKWISE. "0450" for 90° was read as 045, which is
// 45 360 = 315°, and the same shift made 0°, 180° and every other target go
// the wrong way round too. The three cases below are the ones that were tried
// on the tower.
func TestRot1ProgSetFrameIsThreeDigits(t *testing.T) {
// 0° → 360 → "360". The "point to 0°" test that did nothing.
assertBytes(t, "rot1prog az 0", BuildSet(0, 0, 1, Rot1Prog),
[]byte{0x57, '3', '6', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
// 90° → 450 → "450". Sent as "0450" it read as 45 360 = 315°, which from
// 45° is a full turn the wrong way.
assertBytes(t, "rot1prog az 90", BuildSet(90, 0, 1, Rot1Prog),
[]byte{0x57, '4', '5', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
// 359° → 719 → "719": three digits still, at the top of the range.
assertBytes(t, "rot1prog az 359", BuildSet(359, 0, 1, Rot1Prog),
[]byte{0x57, '7', '1', '9', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
}
// What a Rot1Prog is SENT and what it REPORTS have to be the same number, or
// the antenna goes somewhere nobody asked for. Round-tripping every degree of
// the circle through the command builder and the reply parser is the cheapest
// way to say that, and it is the check that would have caught the four-digit
// frame before it reached a tower.
func TestRot1ProgCommandAndReplyAgree(t *testing.T) {
for deg := 0; deg < 360; deg++ {
cmd := BuildSet(float64(deg), 0, 1, Rot1Prog)
// The controller reads three ASCII digits and answers with the same value
// in raw bytes — the asymmetry this protocol is built on.
reply := []byte{0x57, cmd[1] - '0', cmd[2] - '0', cmd[3] - '0', 0x20}
az, _, _, err := ParseStatus(reply, Rot1Prog)
if err != nil {
t.Fatalf("%d°: %v", deg, err)
}
if int(az+0.5) != deg {
t.Fatalf("commanded %d°, the controller would report %v° — a %v° error",
deg, az, az-float64(deg))
}
}
}
// Status and stop carry no position: every data byte is zero, only K differs. // Status and stop carry no position: every data byte is zero, only K differs.
func TestStatusAndStopFrames(t *testing.T) { func TestStatusAndStopFrames(t *testing.T) {
assertBytes(t, "status", BuildStatus(), assertBytes(t, "status", BuildStatus(),