diff --git a/app.go b/app.go index 4aab9cf..1da3e95 100644 --- a/app.go +++ b/app.go @@ -234,6 +234,7 @@ const ( keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band" + keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150" keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam) keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp) keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0) @@ -14684,6 +14685,11 @@ type UltrabeamSettings struct { // range so a single band can be dropped (e.g. 30 m without its extension) while // its neighbours stay. Applies to BOTH the Ultrabeam and the SteppIR. Bands []string `json:"bands"` + // Per-band tune frequency (kHz) — where a band button in Station Control + // sends the antenna. Sparse: a band with no entry uses its default, so an + // operator sets only the bands he cares about and an existing config needs no + // migration. + BandFreqs map[string]int `json:"band_freqs"` // Legacy tunable range (MHz). Superseded by Bands; kept so an older config // migrates cleanly (the range is converted to a band set on load) and so the // value round-trips. Not used by the follow filter once Bands is set. @@ -14723,7 +14729,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) { } m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep, keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands, - keyMotorTrackMode) + keyMotorTrackMode, keyMotorBandFreqs) if err != nil { return out, err } @@ -14748,6 +14754,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) { out.StepKHz = st } out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode]) + out.BandFreqs = decodeMotorBandFreqs(m[keyMotorBandFreqs]) out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin]) out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax]) // Bands is the follow filter. If it was saved, use it verbatim. Otherwise this @@ -14814,6 +14821,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error { keyUltrabeamFollow: boolStr(s.Follow), keyUltrabeamStep: strconv.Itoa(s.StepKHz), keyMotorTrackMode: normMotorTrackMode(s.TrackMode), + keyMotorBandFreqs: encodeMotorBandFreqs(normMotorBandFreqs(s.BandFreqs)), keyMotorType: s.Type, keyMotorTransport: s.Transport, keyMotorCOM: strings.TrimSpace(s.COM), @@ -14899,12 +14907,91 @@ func (a *App) startUltrabeam() { // fitted) while keeping its neighbours — something a contiguous min/max range // can't express. nomMHz is a representative in-band frequency, used only to // migrate a legacy FreqMin/FreqMax range into a band set. +// defKHz is where a band button tunes the antenna when the operator has not +// chosen a frequency for that band — roughly mid-band, where a beam's pattern is +// usable across the whole allocation. It is only a default: an operator who +// lives in the CW segment sets his own, exactly as the SteppIR controller's own +// "Frequency (KHz)" column does. var motorBands = []struct { name string nomMHz int + defKHz int }{ - {"40m", 7}, {"30m", 10}, {"20m", 14}, {"17m", 18}, - {"15m", 21}, {"12m", 24}, {"10m", 28}, {"6m", 50}, + {"40m", 7, 7100}, {"30m", 10, 10125}, {"20m", 14, 14150}, {"17m", 18, 18110}, + {"15m", 21, 21150}, {"12m", 24, 24930}, {"10m", 28, 28400}, {"6m", 50, 50150}, +} + +// motorBandDefaultKHz is the fallback tune frequency for a band, 0 if unknown. +func motorBandDefaultKHz(band string) int { + band = strings.ToLower(strings.TrimSpace(band)) + for _, b := range motorBands { + if b.name == band { + return b.defKHz + } + } + return 0 +} + +// normMotorBandFreqs keeps only entries that name a real motor band AND whose +// frequency actually falls in that band. +// +// The check matters: this value is fed straight to the antenna as a tune +// command. A slip of one digit — 1450 for 20 m, or kHz typed as MHz — would send +// the elements travelling to a length that is wrong for the band the operator is +// on, and on a SteppIR that is a long, transmit-inhibited journey to a position +// nobody asked for. An entry that fails the check is dropped, so the band falls +// back to its default rather than to nonsense. +func normMotorBandFreqs(in map[string]int) map[string]int { + out := map[string]int{} + for _, b := range motorBands { + khz, ok := in[b.name] + if !ok || khz <= 0 { + continue + } + if bandForHz(int64(khz)*1000) != b.name { + applog.Printf("motor-antenna: ignoring %d kHz for %s — not in that band", khz, b.name) + continue + } + out[b.name] = khz + } + return out +} + +// encodeMotorBandFreqs / decodeMotorBandFreqs store the map as "40m=7100,20m=14150". +// A flat string rather than JSON so the settings row stays readable, and so a +// value corrupted by hand degrades one band instead of the whole set. +func encodeMotorBandFreqs(m map[string]int) string { + parts := []string{} + for _, b := range motorBands { // canonical order, not map order + if khz := m[b.name]; khz > 0 { + parts = append(parts, fmt.Sprintf("%s=%d", b.name, khz)) + } + } + return strings.Join(parts, ",") +} + +func decodeMotorBandFreqs(s string) map[string]int { + out := map[string]int{} + for _, kv := range strings.Split(s, ",") { + name, val, ok := strings.Cut(strings.TrimSpace(kv), "=") + if !ok { + continue + } + if khz, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && khz > 0 { + out[strings.ToLower(strings.TrimSpace(name))] = khz + } + } + return normMotorBandFreqs(out) +} + +// motorTuneKHzForBand is the frequency a band button commands: the operator's +// choice when set, the default otherwise. +func motorTuneKHzForBand(m map[string]int, band string) int { + band = strings.ToLower(strings.TrimSpace(band)) + if khz := m[band]; khz > 0 { + return khz + } + return motorBandDefaultKHz(band) } // motorBandNames is the full ordered set (all bands enabled). @@ -15180,6 +15267,10 @@ type UltrabeamStatusInfo struct { // as buttons rather than inventing its own list, so a band dropped in Settings // cannot be clicked here. Bands []string `json:"bands"` + // Where each band button tunes. Resolved here — operator's choice or the + // default — so the widget never has to carry its own copy of the band table + // and cannot drift from what Settings shows. + BandFreqs map[string]int `json:"band_freqs"` } // GetUltrabeamStatus returns the antenna's current state for the UI poll. @@ -15191,6 +15282,12 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo { out.Follow = s.Follow out.StepKHz = s.StepKHz out.TrackMode = normMotorTrackMode(s.TrackMode) + out.BandFreqs = map[string]int{} + for _, b := range s.Bands { + if khz := motorTuneKHzForBand(s.BandFreqs, b); khz > 0 { + out.BandFreqs[b] = khz + } + } out.Bands = append(out.Bands, s.Bands...) if a.motorAnt == nil { return out diff --git a/changelog.json b/changelog.json index 9db58dd..c1b66c3 100644 --- a/changelog.json +++ b/changelog.json @@ -7,14 +7,16 @@ "Band openings: EME contacts are no longer mistaken for an opening. A 2 m opening was announced at 9650 km towards Japan on stations working moonbounce — real contacts, but the moon says nothing about the band, and an antenna pointed that way finds nothing. Each band now has the longest path the atmosphere can actually carry: 3500 km on 2 m, 4000 on 4 m, and no limit at all on 6 and 10 m where multi-hop really does go round the world.", "Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency. A frequency set while transmitting was not recorded, so WSJT-X was told the radio was already back on the receive frequency and never restored it.", "PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio. The meters were only ever drawn from the radio's stream, so a station on any other rig got an empty card while the amplifier was reporting all four over its own link.", - "Motorized antennas (Ultrabeam and SteppIR): tracking now offers the three modes the SteppIR controller software has — every frequency change, past a step of 25/50/100 kHz, or only when the band changes. Existing setups keep the step mode they already had." + "Motorized antennas (Ultrabeam and SteppIR): tracking now offers the three modes the SteppIR controller software has — every frequency change, past a step of 25/50/100 kHz, or only when the band changes. Existing setups keep the step mode they already had.", + "Motorized antennas: each covered band now has its own tune frequency, set in a box under the band in Settings, and that is where the band button in Station Control sends the antenna. Left empty a band keeps its default, and a frequency that is not in its band is refused rather than sent to the elements." ], "fr": [ "Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.", "Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Une ouverture 2 m était annoncée à 9650 km vers le Japon sur des stations en rebond lunaire — de vrais contacts, mais la Lune ne dit rien de la bande, et une antenne pointée par là ne trouve rien. Chaque bande a désormais la distance maximale que l atmosphère peut réellement porter : 3500 km en 2 m, 4000 en 4 m, et aucune limite en 6 et 10 m où les sauts multiples font vraiment le tour du monde.", "Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission. Un changement de fréquence pendant l émission n était pas enregistré, WSJT-X croyait donc la radio déjà revenue sur la fréquence de réception et ne la remettait jamais en place.", "PowerGenius XL : la carte du Contrôle station affiche désormais puissance, courant, ROS et température sans FlexRadio. Les mesures n étaient tirées que du flux de la radio, si bien qu une station sur une autre radio n avait qu une carte vide alors que l amplificateur remontait les quatre sur sa propre liaison.", - "Antennes motorisées (Ultrabeam et SteppIR) : le suivi propose désormais les trois modes du logiciel du contrôleur SteppIR — à chaque changement de fréquence, au-delà d un pas de 25/50/100 kHz, ou seulement au changement de bande. Les installations existantes conservent le mode par pas qu elles avaient déjà." + "Antennes motorisées (Ultrabeam et SteppIR) : le suivi propose désormais les trois modes du logiciel du contrôleur SteppIR — à chaque changement de fréquence, au-delà d un pas de 25/50/100 kHz, ou seulement au changement de bande. Les installations existantes conservent le mode par pas qu elles avaient déjà.", + "Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, saisie dans une case sous la bande dans les Réglages, et c est là que le bouton de bande du Contrôle station envoie l antenne. Laissée vide, une bande garde son défaut, et une fréquence hors de sa bande est refusée plutôt qu envoyée aux éléments." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 079d5e6..e901c1a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -689,6 +689,13 @@ const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '1 // Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow // filter is a subset of these. Must match motorBands in app.go, low → high. const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m']; +// Shown as placeholders only — the backend owns these values (motorBands in +// app.go) and resolves what a band button actually commands. Duplicated here +// purely so an empty box can say what leaving it empty will do. +const MOTOR_BAND_DEFAULT_KHZ: Record = { + '40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110, + '15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150, +}; const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5); // Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config @@ -1250,8 +1257,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings. - const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({ - enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54, + const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({ + enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54, }); const [ubTesting, setUbTesting] = useState(false); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -3181,25 +3188,55 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {(isSteppir || ultrabeam.type === 'ultrabeam') && (
-
+ {/* Band, and under it the frequency its button tunes to — the + layout of the SteppIR controller's own Bands and Frequencies + table, which is where operators expect to find this. The box + only appears on a selected band: a tune frequency for a band the + antenna is not allowed on is a setting with no effect. Left + empty it shows the default as placeholder, so the field is + self-documenting and clearing it is how you go back. */} +
{MOTOR_BANDS.map((b) => { const on = ultrabeam.bands.includes(b); return ( - +
+ + {on && ( + { + // Keep only digits, and store nothing for an empty box + // so it round-trips to "use the default" rather than + // to a zero the backend would have to interpret. + const digits = e.target.value.replace(/[^0-9]/g, ''); + setUltrabeam((s) => { + const next = { ...(s.band_freqs || {}) }; + if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10); + return { ...s, band_freqs: next }; + }); + }} + className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary" + /> + )} +
); })}
+

{t('hw.motorBandFreqHint')}

)}
diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 948b011..02259d9 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -116,7 +116,7 @@ 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[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[] }; +type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record }; // Where each band button points the antenna. // @@ -241,7 +241,10 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
{t('station.bands')}
{(ant.bands ?? []).map((b: string) => { - const khz = ANT_BAND_KHZ[b]; + // Where this band tunes is resolved by the backend — the operator's + // per-band choice from Settings, or the default. ANT_BAND_KHZ is only + // the floor for a status poll that hasn't landed yet. + const khz = ant.band_freqs?.[b] || 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 diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 406c511..064822d 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -285,7 +285,7 @@ const en: Dict = { '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.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.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', + '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 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'In the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, network)', 'cat.elecraftHint': 'Digital modes automatically use DATA A (MD6+DT0) — the sub-mode FT8 audio needs.', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'If your radio is always on TX, tick this.', 'cat.kwDataMode': 'Data modes (FT8/PSK…) use', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Leave the rig’s mode unchanged', 'cat.kwDataHint': 'What OpsLog sets on the rig for a data mode. No single command fits every rig: an Elecraft K3/K4 wants DATA (MD6); a TS-590SG/TS-990S data mode is a USB modifier set on the rig, so pick USB or, safest, "Leave unchanged" and switch the rig to DATA yourself. On MD6 a plain Kenwood (TS-590/990) would land on FSK/RTTY — do not use it there.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', @@ -703,7 +703,7 @@ const fr: Dict = { '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.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.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', + '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', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 09324fe..aaa9223 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3286,6 +3286,7 @@ export namespace main { track_mode: string; tx_inhibit: boolean; bands: string[]; + band_freqs: Record; freq_min_mhz: number; freq_max_mhz: number; @@ -3307,6 +3308,7 @@ export namespace main { this.track_mode = source["track_mode"]; this.tx_inhibit = source["tx_inhibit"]; this.bands = source["bands"]; + this.band_freqs = source["band_freqs"]; this.freq_min_mhz = source["freq_min_mhz"]; this.freq_max_mhz = source["freq_max_mhz"]; } @@ -3324,6 +3326,7 @@ export namespace main { step_khz: number; track_mode: string; bands: string[]; + band_freqs: Record; static createFrom(source: any = {}) { return new UltrabeamStatusInfo(source); @@ -3343,6 +3346,7 @@ export namespace main { this.step_khz = source["step_khz"]; this.track_mode = source["track_mode"]; this.bands = source["bands"]; + this.band_freqs = source["band_freqs"]; } } export class UpdateInfo { diff --git a/motor_bandfreq_test.go b/motor_bandfreq_test.go new file mode 100644 index 0000000..6c0b59d --- /dev/null +++ b/motor_bandfreq_test.go @@ -0,0 +1,78 @@ +package main + +import "testing" + +// A per-band tune frequency goes straight to the antenna as a command, so a +// value that is not actually in that band has to be refused rather than obeyed. +// One wrong digit sends the elements travelling to a length that is wrong for +// the band the operator is on — and on a SteppIR that journey inhibits transmit +// the whole way. +func TestNormMotorBandFreqsRefusesOutOfBand(t *testing.T) { + in := map[string]int{ + "20m": 14050, // fine, CW end + "40m": 7005, // fine + "6m": 50313, // fine, FT8 + "15m": 1450, // a digit lost — lands in the broadcast band + "10m": 28400000, // Hz typed where kHz was asked + "17m": 14100, // right number, wrong band + "30m": 0, // not set + "80m": 3750, // not a band this antenna covers at all + "bogus": 14100, // not a band + } + got := normMotorBandFreqs(in) + want := map[string]int{"40m": 7005, "20m": 14050, "6m": 50313} + if len(got) != len(want) { + t.Fatalf("kept %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %d, want %d", k, got[k], v) + } + } +} + +// The stored form round-trips, in canonical band order rather than map order so +// the settings row does not churn between saves. +func TestMotorBandFreqsRoundTrip(t *testing.T) { + m := map[string]int{"20m": 14050, "40m": 7005, "6m": 50313} + enc := encodeMotorBandFreqs(m) + if enc != "40m=7005,20m=14050,6m=50313" { + t.Errorf("encoded %q — want canonical low→high order", enc) + } + back := decodeMotorBandFreqs(enc) + for k, v := range m { + if back[k] != v { + t.Errorf("round trip lost %s: %d → %d", k, v, back[k]) + } + } + // Garbage in one entry must cost only that entry. + part := decodeMotorBandFreqs("40m=7005,20m=oops,6m=50313") + if part["40m"] != 7005 || part["6m"] != 50313 { + t.Errorf("one bad entry took the others down: %v", part) + } + if _, ok := part["20m"]; ok { + t.Errorf("kept an unparseable entry: %v", part) + } +} + +// An unset band falls back to its default, which is what makes the Settings box +// safe to leave empty. +func TestMotorTuneKHzForBandFallsBack(t *testing.T) { + m := map[string]int{"20m": 14050} + if got := motorTuneKHzForBand(m, "20m"); got != 14050 { + t.Errorf("chosen frequency ignored: %d", got) + } + if got := motorTuneKHzForBand(m, "15m"); got != 21150 { + t.Errorf("15m = %d, want the 21150 default", got) + } + if got := motorTuneKHzForBand(m, "80m"); got != 0 { + t.Errorf("80m = %d, want 0 — not a motor band", got) + } + // Every default must itself be in its band, or the fallback ships the very + // fault normMotorBandFreqs exists to catch. + for _, b := range motorBands { + if got := bandForHz(int64(b.defKHz) * 1000); got != b.name { + t.Errorf("default %d kHz for %s reads as %q", b.defKHz, b.name, got) + } + } +}