From f405e71d7dfcecfd58f2b2bb19e5c44035667256 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sun, 16 Aug 2026 13:31:00 +0200 Subject: [PATCH] fix(spid): a Rot1Prog takes three digits, so every target went the wrong way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.json | 8 ++- frontend/src/App.tsx | 11 ++-- frontend/src/components/SettingsModal.tsx | 14 ++++- .../src/components/StationControlPanel.tsx | 12 ++++- frontend/src/lib/i18n.tsx | 4 +- internal/rotator/spid/spid.go | 48 +++++++++++++++-- internal/rotator/spid/spid_test.go | 52 +++++++++++++++++-- 7 files changed, 133 insertions(+), 16 deletions(-) diff --git a/changelog.json b/changelog.json index 09629d6..fa43548 100644 --- a/changelog.json +++ b/changelog.json @@ -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.", "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.", - "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": [ "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.", "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 ».", - "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 à l’envers 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 l’azimut le dit au lieu de citer PstRotator." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8701c7f..8179595 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2390,15 +2390,20 @@ export default function App() { }; }, []); - // Poll PstRotator for the live antenna heading (status bar). Cheap when the - // rotator is disabled (the backend just reads settings and returns). + // Poll the rotator for the live antenna heading (status bar and compass). + // 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(() => { let alive = true; const tick = async () => { try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {} }; tick(); - const id = window.setInterval(tick, 3000); + const id = window.setInterval(tick, 700); return () => { alive = false; window.clearInterval(id); }; }, []); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index af7c7b8..fabed11 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3086,7 +3086,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setRotatorTest(null); try { 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) { setRotatorTest({ ok: false, msg: String(e?.message ?? e) }); } finally { diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 3d1899d..062c4c8 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -557,8 +557,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { poll(); pollRot(); pollAnt(); - const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000); - return () => window.clearInterval(id); + const id = window.setInterval(() => { poll(); pollAnt(); }, 3000); + // 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]); const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 0c8a559..86eabab 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -289,7 +289,7 @@ const en: Dict = { '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.", + 'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.testOkRead': 'Connected — the controller answered with its heading. Nothing was moved: this test only reads the position.', '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', // CAT panel body @@ -717,7 +717,7 @@ const fr: Dict = { '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.", + 'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.testOkRead': 'Connecté — le contrôleur a répondu avec son azimut. Rien n’a bougé : ce test ne fait que lire la position.', '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', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, réseau)', 'cat.elecraftHint': 'Les modes numériques passent automatiquement en DATA A (MD6+DT0) — le sous-mode dont l’audio FT8 a besoin.', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Si votre radio est toujours en émission, cochez ceci.', 'cat.kwDataMode': 'Modes data (FT8/PSK…)', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Ne pas changer le mode de la radio', 'cat.kwDataHint': "Ce qu'OpsLog règle sur la radio pour un mode data. Aucune commande unique ne convient à toutes : un Elecraft K3/K4 veut DATA (MD6) ; sur un TS-590SG/TS-990S le mode data est un modificateur d'USB réglé sur la radio, choisissez donc USB ou, plus sûr, « Ne pas changer » et passez la radio en DATA vous-même. En MD6 un Kenwood classique (TS-590/990) tomberait en FSK/RTTY — à ne pas utiliser là.", 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9', diff --git a/internal/rotator/spid/spid.go b/internal/rotator/spid/spid.go index 97834e9..544a8c1 100644 --- a/internal/rotator/spid/spid.go +++ b/internal/rotator/spid/spid.go @@ -6,7 +6,7 @@ // 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 +// # WIRE FORMAT // // Every command is 13 bytes: // @@ -30,6 +30,13 @@ // // 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 // past north in either direction, which is the point of a pulse-counting // 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° // 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 { +// +// 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 { resolution = 1 } @@ -120,6 +142,26 @@ func BuildSet(az, el float64, resolution byte) []byte { 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 { c := make([]byte, 13) c[0] = frameStart @@ -175,7 +217,7 @@ func (c *Client) GoTo(az int, el int) error { if el >= 0 && c.model == Rot2Prog { 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 } diff --git a/internal/rotator/spid/spid_test.go b/internal/rotator/spid/spid_test.go index 5be487a..bd17d72 100644 --- a/internal/rotator/spid/spid_test.go +++ b/internal/rotator/spid/spid_test.go @@ -12,21 +12,67 @@ import ( 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" + got := BuildSet(0, 0, 1, Rot2Prog) // 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) + got = BuildSet(90, 0, 2, Rot2Prog) 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) + got = BuildSet(359, 0, 4, Rot2Prog) want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20} 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. func TestStatusAndStopFrames(t *testing.T) { assertBytes(t, "status", BuildStatus(),