diff --git a/app.go b/app.go index e958077..6cbd373 100644 --- a/app.go +++ b/app.go @@ -14986,6 +14986,14 @@ type UltrabeamStatusInfo struct { Band int `json:"band"` Moving bool `json:"moving"` Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported + // Follow and StepKHz are mirrored here so the Station Control widget can show + // and change tracking without loading the whole settings block for a poll. + Follow bool `json:"follow"` + StepKHz int `json:"step_khz"` + // Bands the antenna is configured to cover — the widget offers exactly these + // as buttons rather than inventing its own list, so a band dropped in Settings + // cannot be clicked here. + Bands []string `json:"bands"` } // GetUltrabeamStatus returns the antenna's current state for the UI poll. @@ -14994,6 +15002,9 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo { s, _ := a.GetUltrabeamSettings() out.Enabled = s.Enabled out.Type = s.Type + out.Follow = s.Follow + out.StepKHz = s.StepKHz + out.Bands = append(out.Bands, s.Bands...) if a.motorAnt == nil { return out } @@ -15051,6 +15062,71 @@ func (a *App) SetUltrabeamDirection(direction int) error { return a.motorAnt.SetDirection(direction) } +// MotorTuneKHz points the antenna at a frequency, in kHz. +// +// The direction is carried over from the antenna's current state rather than +// reset: both controllers set frequency and pattern in the same command, so +// sending a bare frequency would silently drop a 180° or bidirectional pattern +// the operator had chosen — a beam quietly turning round is not an acceptable +// side effect of clicking a band. +func (a *App) MotorTuneKHz(khz int) error { + if a.motorAnt == nil { + return fmt.Errorf("antenna not connected — enable it in Settings → Antenna") + } + // A generous envelope rather than a band table: the antennas differ, some + // carry extensions, and refusing a frequency the hardware would have accepted + // is worse than letting the controller say no itself. + if khz < 1000 || khz > 60000 { + return fmt.Errorf("frequency %d kHz is outside anything these antennas tune", khz) + } + a.noteMotorMoveCommanded() + return a.motorAnt.SetFrequency(khz, a.motorAnt.Status().Direction) +} + +// MotorNudgeKHz moves the antenna's commanded frequency by a step, up or down. +// +// Works from the ANTENNA's frequency, not the rig's: this exists so an operator +// can walk the antenna across a band while the rig stays where it is, and +// starting from the rig would undo that on the first press. Falls back to the +// rig only when the antenna has not reported a frequency yet. +func (a *App) MotorNudgeKHz(deltaKHz int) error { + if a.motorAnt == nil { + return fmt.Errorf("antenna not connected — enable it in Settings → Antenna") + } + cur := a.motorAnt.Status().Frequency + if cur <= 0 && a.cat != nil { + if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 { + cur = int(rs.FreqHz / 1000) + } + } + if cur <= 0 { + return fmt.Errorf("the antenna has not reported a frequency yet") + } + return a.MotorTuneKHz(cur + deltaKHz) +} + +// SetMotorFollow turns frequency tracking on or off and sets its step, without +// opening Settings. Both are ordinary operating decisions — an operator turns +// tracking off to park the antenna and back on to resume — and a preferences +// dialog is the wrong place for something changed that often. +func (a *App) SetMotorFollow(on bool, stepKHz int) error { + s, err := a.GetUltrabeamSettings() + if err != nil { + return err + } + switch stepKHz { + case 25, 50, 100: + s.StepKHz = stepKHz + case 0: // leave the step alone + default: + return fmt.Errorf("step must be 25, 50 or 100 kHz") + } + s.Follow = on + // SaveUltrabeamSettings restarts the client, which is what actually starts or + // stops the follow loop — the setting alone would not. + return a.SaveUltrabeamSettings(s) +} + // UltrabeamRetract retracts all elements (storage / safe position). func (a *App) UltrabeamRetract() error { if a.motorAnt == nil { diff --git a/changelog.json b/changelog.json index 9e1124d..ca39c9b 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,8 @@ "Shared CAT is steadier and now diagnoses itself. It survives a rig answering \"busy\" just after transmit instead of dropping the link, stops repeating a PTT state the client never changed, and logs a plain explanation when another program has taken its port or when a client is set to a rig model instead of Hamlib NET rigctl. It also answers the lock-mode and stop-morse commands some clients send around every transmit, instead of refusing them.", "Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — from a searchable dropdown, with the chosen columns listed above it in publication order. Choose carefully: the page is public and the list includes addresses and e-mail.", "Band-opening detection now works on the bands it was meant for. Switching on \"Watch for band openings\" (Settings › DX Cluster) subscribes to the PSK Reporter feed and adds the two RBN nodes it needs — it was reading a handful of VHF cluster spots while thousands of stations were reporting. It watches 10, 6, 4 and 2 m, counts only reports collected within 300 km of you so an opening means an opening HERE, no longer throws away paths beyond 2400 km, and looks for the busiest direction instead of demanding that every station heard sit in one sector. An opening under way shows as a blinking badge beside the clock until the band goes quiet, and the direction it names is right — a sector crossing north used to be announced as its opposite.", - "Test connection now actually tests. Club Log checked that the three fields were not empty and reported success without contacting anyone, so a wrong password looked exactly like a right one; it now signs in for real, through the read-only endpoint so a test can never add a record. LoTW reports its two credentials separately: TQSL signs uploads and never uses the website password, so a wrong one used to break nothing until the day confirmations were downloaded." + "Test connection now actually tests. Club Log checked that the three fields were not empty and reported success without contacting anyone, so a wrong password looked exactly like a right one; it now signs in for real, through the read-only endpoint so a test can never add a record. LoTW reports its two credentials separately: TQSL signs uploads and never uses the website password, so a wrong one used to break nothing until the day confirmations were downloaded.", + "The Ultrabeam / SteppIR widget in Station Control can now drive the antenna, not just describe it: a button per configured band tunes it there, up and down move it 25 kHz at a time, and tracking can be switched on or off with its 25 / 50 / 100 kHz threshold beside it. The band you are on lights up, and the commanded frequency is shown in the header." ], "fr": [ "Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.", @@ -16,7 +17,8 @@ "Le CAT partagé est plus solide et se diagnostique tout seul. Il survit à un rig qui répond « occupé » juste après une émission au lieu de lâcher le lien, cesse de répéter un état PTT que le client n a pas changé, et écrit une explication claire quand un autre programme lui a pris son port ou qu un client est réglé sur un modèle de rig au lieu de Hamlib NET rigctl. Il répond aussi aux commandes de verrouillage et d arrêt du morse que certains logiciels envoient à chaque émission, au lieu de les refuser.", "La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — depuis une liste déroulante cherchable, les colonnes choisies étant listées au-dessus dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails.", "La détection d ouverture de bande fonctionne enfin sur les bandes visées. Activer « Surveiller les ouvertures de bande » (Paramètres › Cluster DX) souscrit au flux PSK Reporter et ajoute les deux nœuds RBN nécessaires — elle lisait quelques spots VHF du cluster alors que des milliers de stations rapportaient. Elle surveille le 10, 6, 4 et 2 m, ne retient que les reports collectés à moins de 300 km de toi pour qu une ouverture veuille dire une ouverture ICI, ne jette plus les chemins au-delà de 2400 km, et cherche la direction la plus dense au lieu d exiger que toutes les stations entendues soient dans un même secteur. Une ouverture en cours s affiche en pastille clignotante à côté de l heure jusqu à ce que la bande se taise, et la direction annoncée est juste — un secteur traversant le nord était auparavant annoncé à l opposé.", - "Le bouton Tester la connexion teste vraiment. Club Log vérifiait que les trois champs n étaient pas vides et annonçait la réussite sans contacter personne : un mauvais mot de passe ressemblait exactement à un bon. Il s authentifie maintenant pour de vrai, via le point d accès en lecture seule pour qu un test ne puisse jamais ajouter un enregistrement. LoTW annonce ses deux identifiants séparément : TQSL signe les envois et n utilise jamais le mot de passe du site, donc un mauvais ne cassait rien jusqu au jour du téléchargement des confirmations." + "Le bouton Tester la connexion teste vraiment. Club Log vérifiait que les trois champs n étaient pas vides et annonçait la réussite sans contacter personne : un mauvais mot de passe ressemblait exactement à un bon. Il s authentifie maintenant pour de vrai, via le point d accès en lecture seule pour qu un test ne puisse jamais ajouter un enregistrement. LoTW annonce ses deux identifiants séparément : TQSL signe les envois et n utilise jamais le mot de passe du site, donc un mauvais ne cassait rien jusqu au jour du téléchargement des confirmations.", + "Le widget Ultrabeam / SteppIR de Station Control pilote enfin l antenne au lieu de seulement la décrire : un bouton par bande configurée l accorde dessus, haut et bas la déplacent par pas de 25 kHz, et le suivi s active ou se coupe avec son seuil de 25 / 50 / 100 kHz à côté. La bande courante s allume, et la fréquence commandée s affiche dans l en-tête." ] }, { diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 6104948..31932de 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react'; +import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -15,6 +15,7 @@ import { GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, + MotorTuneKHz, MotorNudgeKHz, SetMotorFollow, ListDenkoviDevices, ListSerialPorts, TestStationDevice, GetAmpStatuses, GetFlexState, GetTunerGeniusStatus, GetTunerGeniusSettings, @@ -115,7 +116,37 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato ); } -type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[] }; +type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; bands?: string[] }; + +// Where each band button points the antenna. +// +// Not the arithmetic centre of the band. An antenna is tuned for where people +// actually work: 20 m centres on 14175 but nobody lives there, and 10 m spans +// 1.7 MHz of which the top half is empty. These are the points that leave the +// elements closest to right for the whole band, and one nudge away from the rest. +const ANT_BAND_KHZ: Record = { + '40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110, + '15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150, +}; + +// NUDGE_KHZ is the up/down step. 25 kHz because that is also the finest tracking +// threshold: a nudge smaller than the tracking step would be undone by the next +// poll while tracking is on. +const NUDGE_KHZ = 25; + +// bandOfKHz names the band a frequency sits in, so a band button can light up +// when the ANTENNA is already there. Deliberately generous at the edges: the +// antenna's reported frequency is where it was commanded, which can sit slightly +// outside the allocation. +function bandOfKHz(khz: number): string { + const edges: [number, number, string][] = [ + [6900, 7300, '40m'], [10050, 10200, '30m'], [13900, 14400, '20m'], + [18000, 18200, '17m'], [20900, 21500, '15m'], [24800, 25000, '12m'], + [27900, 29800, '10m'], [49900, 50600, '6m'], + ]; + for (const [lo, hi, b] of edges) if (khz >= lo && khz <= hi) return b; + return ''; +} // MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the // Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam @@ -203,6 +234,70 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () = ))} + {/* Bands, then a nudge, then tracking — in the order an operator uses + them: get to the band, fine-tune inside it, decide whether the + antenna should follow the rig from here. */} +
+
{t('station.bands')}
+
+ {(ant.bands ?? []).map((b: string) => { + const khz = ANT_BAND_KHZ[b]; + if (!khz) return null; + // "On this band" from the antenna's own frequency, not the rig's: + // the widget must show where the ANTENNA is, which is the whole + // reason for tuning it by hand. + const here = ant.frequency > 0 && bandOfKHz(ant.frequency) === b; + return ( + + ); + })} +
+
+ +
+ + +
+ + {/* Tracking. Here rather than only in Settings because it is an operating + decision — off to park the antenna, on to resume — not something set + up once. The step only shows when tracking is on: a threshold for + something switched off is a question the operator cannot act on. */} +
+ + {ant.follow && ( + + )} +
+