feat: a typed watering hole carries its mode; Yaesu RTTY sideband

His log settles the 28.074 case: "SetCATFrequency 28.074 MHz" and the
state still reads mode=USB — nothing sent a mode, so the rig simply
stayed where it was. A spot click has always carried one; a frequency
typed by hand carried none. It now uses the same table and the same
tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), and only
towards the digital modes: tuning AWAY from one leaves the mode alone,
because there the frequency says nothing about what the operator means.

RTTY on Yaesu is a choice the log cannot make: ADIF records "RTTY" and
the rig has MD06 (RTTY-L) and MD09 (RTTY-U). The older lower sideband
stays the default and a station whose FSK controller wants the upper one
says so once in Settings → CAT.
This commit is contained in:
2026-09-06 23:01:08 +02:00
parent 3745d23339
commit 9ce7cf3b69
8 changed files with 97 additions and 13 deletions
+10 -2
View File
@@ -157,6 +157,7 @@ const (
// the rule since is that nothing a backend does may be steered by another
// backend's setting.
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
keyCATYaesuRTTYUSB = "cat.yaesu.rtty_usb" // set RTTY on USB rather than the older LSB
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
@@ -510,7 +511,11 @@ type CATSettings struct {
// Per-backend: deassert DTR and RTS after opening the CAT port, for
// interfaces that read either line as PTT. Off by default: lowering them
// stops some USB-serial interfaces transmitting at all.
YaesuLowLines bool `json:"yaesu_low_lines"`
YaesuLowLines bool `json:"yaesu_low_lines"`
// YaesuRTTYUSB: ADIF says "RTTY" and Yaesu has both sidebands, so the rig
// cannot be driven from the logged mode alone. Off = RTTY-L, the older
// convention.
YaesuRTTYUSB bool `json:"yaesu_rtty_usb"`
KenwoodLowLines bool `json:"kenwood_low_lines"`
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
@@ -8317,7 +8322,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
if err != nil {
return CATSettings{}, err
}
@@ -8343,6 +8348,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
KenwoodLink: kenwoodLinkOr(m[keyCATKenwoodLink], m[keyCATKenwoodHost]),
KenwoodBaud: 9600,
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
YaesuRTTYUSB: m[keyCATYaesuRTTYUSB] == "1",
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
KenwoodDataMode: m[keyCATKenwoodDataMode],
IcomPort: m[keyCATIcomPort],
@@ -8556,6 +8562,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATKenwoodLink: kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost),
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATYaesuLowLines: b01(s.YaesuLowLines),
keyCATYaesuRTTYUSB: b01(s.YaesuRTTYUSB),
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
@@ -16283,6 +16290,7 @@ func (a *App) reloadCAT() {
// (a rig file that hides the VFO, a Freq property meaning A on one model
// and B on another); talking to the radio directly removes it.
yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)
yz.SetRTTYUpper(s.YaesuRTTYUSB)
yz.SetLowerLines(s.YaesuLowLines)
a.cat.Start(yz)
case "kenwood", "elecraft":
+6 -2
View File
@@ -16,7 +16,9 @@
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable.",
"Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each services requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing.",
"FT map: the callsign, square and report show on hover again. The invisible circle that catches the clicks sits on top of the dot, so it takes the hover too — and the label was bound only to the dot underneath, which left the map silent from the moment the stations became clickable.",
"Yaesu CAT now drives the older radios. The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits; everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and answers a nine-digit command with a rejection, which is what an FTDX3000 owner saw: every FA refused and a radio that would not follow. The width is taken from the rigs own reply rather than from a table of models, so a set is in the format that radio speaks — including models this backend has never heard of."
"Yaesu CAT now drives the older radios. The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits; everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and answers a nine-digit command with a rejection, which is what an FTDX3000 owner saw: every FA refused and a radio that would not follow. The width is taken from the rigs own reply rather than from a table of models, so a set is in the format that radio speaks — including models this backend has never heard of.",
"Typing a digital watering hole sets the mode with it. A spot click has always carried one; a frequency typed by hand carried none, so the rig stayed in SSB on 28.074 while the operator waited for decodes. Same table and same tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), only towards the digital modes: tuning away from one leaves the mode alone, because there the frequency says nothing about what you mean to do.",
"Yaesu: RTTY can be set on USB (Settings → CAT). ADIF records only “RTTY” and the rig has both sidebands, so the log cannot answer for it — the older RTTY-L stays the default, and a station whose FSK controller wants the upper one says so once."
],
"fr": [
"[NEW] Les couleurs de mise en évidence WSJT-X / JTDX sont au choix (Réglages → UDP), une par verdict — watchlist, nouveau DXCC, nouvelle bande, contactée. Seul le fond se règle : la couleur du texte en est déduite, pour quune couleur choisie ne revienne jamais illisible dans la fenêtre du décodeur. Loption « griser les stations déjà contactées » garde sa raison d’être — elle décide SI les doublons sont marqués, pas de quelle couleur — et sappelle désormais « Marquer les stations déjà contactées ».",
@@ -32,7 +34,9 @@
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — quOpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (lADIF compte les octets) ; ils étaient illisibles.",
"Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne na jamais saisie — OpsLog embarque la sienne — et un opérateur dont lenvoi automatique fonctionnait depuis des mois se voyait éconduit au moment denvoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants.",
"Carte FTx : lindicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée quau point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.",
"Le CAT Yaesu pilote désormais les postes plus anciens. FTDX10, FT-991A, FT-891 et FT-710 écrivent une fréquence sur neuf chiffres ; tout ce qui précède — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — l’écrit sur huit et rejette une commande à neuf chiffres. Cest ce que voyait un possesseur de FTDX3000 : chaque FA refusée et une radio qui ne suivait pas. Le format est pris dans la réponse du poste plutôt que dans une table de modèles : lenvoi part donc dans la langue de cette radio-là, y compris pour des modèles que ce backend ne connaît pas."
"Le CAT Yaesu pilote désormais les postes plus anciens. FTDX10, FT-991A, FT-891 et FT-710 écrivent une fréquence sur neuf chiffres ; tout ce qui précède — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — l’écrit sur huit et rejette une commande à neuf chiffres. Cest ce que voyait un possesseur de FTDX3000 : chaque FA refusée et une radio qui ne suivait pas. Le format est pris dans la réponse du poste plutôt que dans une table de modèles : lenvoi part donc dans la langue de cette radio-là, y compris pour des modèles que ce backend ne connaît pas.",
"Taper une fréquence dappel numérique règle le mode avec elle. Un clic sur un spot en portait un depuis toujours ; une fréquence tapée à la main nen portait aucun, si bien que le poste restait en SSB sur 28.074 pendant quon attendait les décodages. Même table et même tolérance quun spot (±3 kHz dune fréquence FT8/FT4/JS8 connue), et seulement vers les modes numériques : en sen éloignant le mode nest pas touché, car là la fréquence ne dit rien de ce quon veut faire.",
"Yaesu : le RTTY peut être placé en USB (Réglages → CAT). LADIF nenregistre que « RTTY » et le poste a les deux bandes latérales : le log ne peut pas répondre à sa place. Le RTTY-L ancien reste par défaut, et une station dont linterface FSK veut la supérieure le dit une fois."
]
},
{
+24 -1
View File
@@ -5926,7 +5926,29 @@ export default function App() {
const mhz = parseFloat(mhzStr);
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
noteManualEdit();
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
const hz = Math.round(mhz * 1_000_000);
SetCATFrequency(hz).catch(() => {});
tuneModeForWateringHole(hz);
};
// 28.074 IS FT8, and typing it says so.
//
// A spot click has always carried a mode; a frequency typed by hand carried
// none, so the rig stayed in whatever it was — an FTDX3000 landing on 28.074
// in USB while the operator waited for decodes. Nobody tunes a watering hole
// to listen to it in SSB, and this is the same table and the same tolerance a
// spot click is judged with (±3 kHz of a known digital frequency).
//
// Only when it actually changes something, and only towards the digital
// modes: tuning away from 28.074 leaves the mode alone, because there the
// frequency says nothing about what the operator means to do.
const tuneModeForWateringHole = (hz: number) => {
const m = inferDigitalMode(hz);
if (!m || m === mode) return;
setMode(m);
applyModePreset(m);
if (catState.enabled && catState.connected && !locks.mode) SetCATMode(m).catch(() => {});
ConfigureDecoderMode(m).catch(() => {});
};
// Carry out what was typed in the call field. Bands go through the ordinary
// band change, so the antennas, the power table and the outbound integrations
@@ -5944,6 +5966,7 @@ export default function App() {
const b = bandForMHz(hz / 1_000_000);
if (b) setBand(b);
if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
tuneModeForWateringHole(hz);
showToast((hz / 1_000_000).toFixed(3) + ' MHz');
};
+12 -1
View File
@@ -1628,7 +1628,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [icomCustom, setIcomCustom] = useState(false);
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, yaesu_rtty_usb: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
@@ -3532,6 +3532,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
{/* ADIF records "RTTY" and nothing more, and the rig has two
sidebands to put it on so this is the operator's answer, not
something the log can supply. */}
<div className="col-span-2 space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!(catCfg as any).yaesu_rtty_usb}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_rtty_usb: !!c } as any))} />
{t('cat.yaesuRttyUsb')}
</label>
<span className="text-xs text-muted-foreground">{t('cat.yaesuRttyUsbHint')}</span>
</div>
</>
)}
{['icom', 'xiegu', 'kenwood', 'elecraft'].includes(catCfg.backend) && (
File diff suppressed because one or more lines are too long
+2
View File
@@ -2379,6 +2379,7 @@ export namespace main {
kenwood_baud: number;
kenwood_data_mode: string;
yaesu_low_lines: boolean;
yaesu_rtty_usb: boolean;
kenwood_low_lines: boolean;
icom_port: string;
icom_baud: number;
@@ -2432,6 +2433,7 @@ export namespace main {
this.kenwood_baud = source["kenwood_baud"];
this.kenwood_data_mode = source["kenwood_data_mode"];
this.yaesu_low_lines = source["yaesu_low_lines"];
this.yaesu_rtty_usb = source["yaesu_rtty_usb"];
this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
+21 -4
View File
@@ -108,8 +108,10 @@ type Yaesu struct {
// and VS is used — see ReadState.
rxVFOCmd string
// freqDigits is how many digits this rig writes a frequency in, LEARNED from
// its own replies. See setFreqWidth.
// its own replies. See learnFreqWidth.
freqDigits int
// rttyUpper picks RTTY-U over RTTY-L — see SetRTTYUpper.
rttyUpper bool
curFreq int64
curRXFreq int64
@@ -140,6 +142,18 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
// SetRTTYUpper chooses which sideband RTTY is set on.
//
// Yaesu has both — MD06 is RTTY-L, MD09 is RTTY-U — and ADIF has neither: it
// says "RTTY" and stops there, so the rig cannot be driven from the logged mode
// alone. LSB is the older convention and stays the default; an operator whose
// FSK controller or decoder wants the other one says so once here.
func (y *Yaesu) SetRTTYUpper(v bool) {
y.mu.Lock()
defer y.mu.Unlock()
y.rttyUpper = v
}
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (y *Yaesu) SetLowerLines(v bool) {
@@ -375,7 +389,7 @@ func (y *Yaesu) SetMode(mode string) error {
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
d := yaesuModeDigit(mode, y.curFreq)
d := yaesuModeDigit(mode, y.curFreq, y.rttyUpper)
if d == 0 {
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
}
@@ -608,7 +622,7 @@ func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64,
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
// which is why the current frequency is part of the decision.
func yaesuModeDigit(mode string, freqHz int64) byte {
func yaesuModeDigit(mode string, freqHz int64, rttyUpper bool) byte {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB":
if freqHz > 0 && freqHz < 10_000_000 {
@@ -626,7 +640,10 @@ func yaesuModeDigit(mode string, freqHz int64) byte {
case "AM":
return '5'
case "RTTY":
return '6'
if rttyUpper {
return '9' // RTTY-U
}
return '6' // RTTY-L, the older convention
case "":
return 0
default:
+20 -1
View File
@@ -123,7 +123,7 @@ func TestYaesuModeDigit(t *testing.T) {
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
if got := yaesuModeDigit(c.mode, c.hz, false); got != c.want {
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
}
}
@@ -406,3 +406,22 @@ func TestYaesuAntennaCommand(t *testing.T) {
}
}
}
// ADIF says "RTTY" and stops there, but Yaesu has both sidebands and the rig
// has to be told one. LSB is the older convention and the default; the other is
// a station's own choice, made once.
func TestYaesuRTTYSideband(t *testing.T) {
if got := yaesuModeDigit("RTTY", 14_080_000, false); got != '6' {
t.Errorf("RTTY = %q, want RTTY-L", got)
}
if got := yaesuModeDigit("RTTY", 14_080_000, true); got != '9' {
t.Errorf("RTTY (upper) = %q, want RTTY-U", got)
}
// The switch is about RTTY and nothing else.
if got := yaesuModeDigit("FT8", 28_074_000, true); got != 'C' {
t.Errorf("FT8 = %q, want DATA-U", got)
}
if got := yaesuModeDigit("CW", 14_030_000, true); got != '3' {
t.Errorf("CW = %q, want CW-U", got)
}
}