feat(steppir): a Calibrate button, and Retract explains itself

Calibrate was in the protocol notes at the top of the package ('V') and nowhere
else. It drives every element to its end stop so the controller re-learns where
zero is, and it is the cure for the one SteppIR fault an operator cannot reason
about: an antenna that tunes to the wrong length. The controller counts steps
from a remembered position, so once that memory is wrong — a power cut in
mid-move, elements pushed by hand, a motor that slipped — every frequency after
it is wrong by the same amount, silently.

It asks before it runs. Minutes, every element travelling its full length, and
the antenna unusable until it finishes: right answer to a mistuned beam, wrong
answer to a stray click during a contest.

It sits beside Retract as a two-column row rather than a second full-width
button. The docked widget is narrow and already tall, and the pair reads as what
it is — the two commands that move every element at once.

Retract (Home) was already there and worked on a SteppIR; it now carries a
tooltip saying what it is for, because "retract elements" does not tell an
operator that this is the storage position and that the next tune brings them
back out unaided.

An Ultrabeam controller has no calibration command, and the adapter says so
instead of pretending — the antenna interface is shared, and a button that
silently does nothing is worse than one that explains itself.

Untested on hardware: the byte is from SteppIR's own Transceiver Interface note,
which is the same source the rest of this package was cross-checked against.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-10 08:00:22 +02:00
co-authored by Claude Opus 5
parent ce7b3686f5
commit a88e871640
7 changed files with 99 additions and 14 deletions
+25 -7
View File
@@ -5,10 +5,10 @@
// how one of them quietly stops matching the other — a pattern button that sets
// a different direction, a band list that tunes somewhere else.
import { useCallback, useEffect, useState } from 'react';
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Ruler, Antenna as AntennaIcon, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
SetUltrabeamDirection, UltrabeamRetract, MotorCalibrate, MotorSetElement, MotorReadElements,
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
} from '../../wailsjs/go/main/App';
@@ -235,11 +235,29 @@ export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }:
</div>
)}
<button type="button" disabled={!ant.connected}
onClick={() => run(UltrabeamRetract())}
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
</button>
{/* Home, and — on a SteppIR — calibrate beside it. Two buttons on one
row rather than a second full-width one: the widget is narrow and
already tall, and the pair reads as "the two things that move every
element at once", which is what they are. */}
<div className={cn('grid gap-1.5', isUB ? 'grid-cols-1' : 'grid-cols-2')}>
<button type="button" disabled={!ant.connected}
onClick={() => run(UltrabeamRetract())}
title={t('station.retractTip')}
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
</button>
{/* Asked first, always. Calibration runs every element to its end
stop and takes minutes — it is the right answer to an antenna that
tunes wrong, and the wrong answer to a stray click mid-contest. */}
{!isUB && (
<button type="button" disabled={!ant.connected}
onClick={() => { if (window.confirm(t('station.calibrateConfirm'))) run(MotorCalibrate()); }}
title={t('station.calibrateTip')}
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
<Ruler className="size-3.5" /> {t('station.calibrate')}
</button>
)}
</div>
{isUB && !essentialsOnly && (
<div>
+8
View File
@@ -271,6 +271,10 @@ const en: Dict = {
'station.rigOff': 'CAT is switched off', 'station.noPort': 'no port configured',
'station.connect': 'Connect', 'station.noVoiceMsg': 'No message recorded — Settings ▸ Audio.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Motorised antenna · click to show', 'station.motorWidgetHide': 'Motorised antenna — shown · click to hide', 'station.hideWidget': 'Hide this widget', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the boards HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'station.retractTip': 'Home the elements into the hubs — the storage position, for a gale or a winter. The next time the antenna is tuned it comes back out on its own.',
'station.calibrate': 'Calibrate',
'station.calibrateTip': 'SteppIR: drive every element to its end stop so the controller re-learns where zero is. The cure for an antenna that tunes to the wrong length — after a power cut mid-move, after the elements were pushed by hand, after a motor slipped.',
'station.calibrateConfirm': 'Calibrate the antenna?\n\nEvery element travels its full length and this takes several minutes. Do not start it during a contest — the antenna is unusable until it finishes.',
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.psu': 'Power supply',
@@ -908,6 +912,10 @@ const fr: Dict = {
'station.rigOff': 'le CAT est désactivé', 'station.noPort': 'aucun port configuré',
'station.connect': 'Connecter', 'station.noVoiceMsg': 'Aucun message enregistré — Réglages ▸ Audio.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Antenne motorisée · cliquer pour afficher', 'station.motorWidgetHide': 'Antenne motorisée — affichée · cliquer pour masquer', 'station.hideWidget': 'Masquer ce widget', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'station.retractTip': 'Rentrer les éléments dans les moyeux — la position de rangement, pour un coup de vent ou pour lhiver. Au prochain accord, lantenne ressort delle-même.',
'station.calibrate': 'Calibrer',
'station.calibrateTip': 'SteppIR : amener chaque élément en butée pour que le contrôleur retrouve son zéro. Le remède à une antenne qui saccorde à la mauvaise longueur — après une coupure en pleine course, après avoir poussé les éléments à la main, après un moteur qui a glissé.',
'station.calibrateConfirm': 'Calibrer lantenne ?\n\nChaque élément parcourt toute sa course et cela prend plusieurs minutes. À ne pas lancer pendant un concours — lantenne est inutilisable jusqu’à la fin.',
'awards.followHint': 'Diplômes affichés dans longlet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour linstant — longlet Awards les montre tous.',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.psu': 'Alimentation',
+2
View File
@@ -853,6 +853,8 @@ export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
export function MotorCalibrate():Promise<void>;
export function MotorNudgeKHz(arg1:number):Promise<void>;
export function MotorReadElements():Promise<Array<number>>;
+4
View File
@@ -1638,6 +1638,10 @@ export function LookupCallsignFresh(arg1, arg2) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
}
export function MotorCalibrate() {
return window['go']['main']['App']['MotorCalibrate']();
}
export function MotorNudgeKHz(arg1) {
return window['go']['main']['App']['MotorNudgeKHz'](arg1);
}