fix(yaesu): mic gain scale, 200 W models, and a NAR button that did nothing

Reported from a real FTDX101, and each one is a different kind of wrong.

MIC GAIN was read and written through the 0-255 scale the audio gains
use, but the CAT reference gives MG000-100. A rig set to 80 therefore
showed 38, and moving the slider sent 204 — outside the range the radio
accepts, so it refused the command and the slider sprang back. That
snap-back was the symptom; the scale was the cause.

POWER was capped at 100 W by the slider, not by the radio. There is no
CAT command for 'how much power can you make', so the ceiling comes from
the model name, with the rig believed if it ever reports more than the
table expects — it has just proved what it can do.

NAR is the narrow IF filter, and on a rig that does not implement NA the
button showed a state the radio never gave and did nothing when pressed,
which reads as a fault in the radio. Now shown only when the rig answers,
with a tooltip saying what it is.

Also: the frequency readout steps the Hz digits under the wheel, not just
the kHz ones. Zero-beating a CW signal is a few tens of Hz and it was the
one move the display would not make. And the split chaser is CW-only —
its marker comes from a CW skimmer.
This commit is contained in:
2026-08-24 17:59:13 +02:00
parent ea0789b191
commit 0848c275d2
7 changed files with 91 additions and 15 deletions
+12 -2
View File
@@ -5,12 +5,22 @@
"en": [
"When TQSL refuses an upload, the log now carries the exact ADIF record it was given and the station location it was told to sign with. 'No QSOs processed' covers several unrelated causes and the temp file is deleted the moment TQSL returns, so the one piece of evidence that mattered was the one nobody could see.",
"An Elecraft console for the K3/K4, in a tab of its own when the CAT backend is Elecraft or Kenwood: power, volume, RF and mic gain, squelch, preamp, attenuator, NB, NR, AGC, filter width, antenna, RIT/XIT with a clear, keyer speed, S-meter, SWR, MOX and an ATU tune. The meter scaling is marked provisional and the raw readings go to the log — it was written from the K3 Programmer's Reference without a radio to hand, and a wrongly scaled SWR bar reports a good match on a bad antenna.",
"Type a number in the callsign field and press Enter to move the radio: kHz for a frequency (28500 → 28.500 MHz), or a band on its own (20, 160, 6…). It is where the hands already are — reading a spot and reaching for the frequency box is what loses the station. Ambiguous numbers are read as bands, since 40 kHz and 160 kHz are nowhere anyone tunes."
"Type a number in the callsign field and press Enter to move the radio: kHz for a frequency (28500 → 28.500 MHz), or a band on its own (20, 160, 6…). It is where the hands already are — reading a spot and reaching for the frequency box is what loses the station. Ambiguous numbers are read as bands, since 40 kHz and 160 kHz are nowhere anyone tunes.",
"Yaesu console: the mic gain is read and written on the rig's own 0-100 scale. It was treated as 0-255, so an FTDX101 set to 80 showed 38 and every attempt to move the slider was refused by the radio and sprang back.",
"Yaesu console: the power slider goes to 200 W on the models that make it (FTDX101MP and the like), and follows the radio if it ever reports more than expected. The slider, not the rig, was the 100 W limit.",
"Yaesu console: the NAR button (narrow IF filter) is shown only when the radio answers the command, and says what it is. A button that did nothing when pressed read as a fault in the rig.",
"The frequency readout now steps the Hz digits under the mouse wheel too, not just the kHz ones — 100, 10 and 1 Hz. Zero-beating a CW signal is a few tens of Hz, and it was the one move the display would not make.",
"The split-pile-up chaser only appears in CW: the marker comes from a skimmer decoding a report, so on SSB or FT8 there is nothing for it to chase."
],
"fr": [
"Quand TQSL refuse un envoi, le journal contient désormais l'enregistrement ADIF exact qui lui a été remis et l'emplacement de station demandé pour la signature. « No QSOs processed » recouvre plusieurs causes sans rapport et le fichier temporaire est supprimé dès que TQSL rend la main : la seule pièce à conviction utile était justement invisible.",
"Une console Elecraft pour les K3/K4, dans un onglet dédié quand le CAT est réglé sur Elecraft ou Kenwood : puissance, volume, gain HF et micro, squelch, préampli, atténuateur, NB, NR, AGC, largeur de filtre, antenne, RIT/XIT avec effacement, vitesse du manipulateur, S-mètre, ROS, MOX et accord de l'ATU. L'échelle des mesures est signalée comme provisoire et les valeurs brutes partent dans le journal — la console a été écrite d'après le manuel de programmation du K3, sans radio sous la main, et une barre de ROS mal calibrée annonce un bon accord sur une mauvaise antenne.",
"Taper un nombre dans le champ indicatif puis Entrée déplace la radio : en kHz pour une fréquence (28500 → 28.500 MHz), ou une bande seule (20, 160, 6…). C'est là que sont déjà les mains — lire un spot puis aller chercher la case fréquence, c'est ce qui fait perdre la station. Les nombres ambigus sont lus comme des bandes : 40 kHz ou 160 kHz ne sont nulle part où l'on s'accorde."
"Taper un nombre dans le champ indicatif puis Entrée déplace la radio : en kHz pour une fréquence (28500 → 28.500 MHz), ou une bande seule (20, 160, 6…). C'est là que sont déjà les mains — lire un spot puis aller chercher la case fréquence, c'est ce qui fait perdre la station. Les nombres ambigus sont lus comme des bandes : 40 kHz ou 160 kHz ne sont nulle part où l'on s'accorde.",
"Console Yaesu : le gain micro est lu et écrit sur l'échelle 0-100 de la radio. Il était traité en 0-255 : un FTDX101 réglé sur 80 affichait 38, et toute tentative de bouger le curseur était refusée par la radio et revenait en place.",
"Console Yaesu : le curseur de puissance monte à 200 W sur les modèles qui la sortent (FTDX101MP et consorts), et suit la radio si elle annonce davantage. C'était le curseur, pas la radio, qui plafonnait à 100 W.",
"Console Yaesu : le bouton NAR (filtre FI étroit) n'apparaît que si la radio répond à la commande, et indique ce qu'il fait. Un bouton sans effet passait pour une panne de la radio.",
"L'affichage de fréquence fait maintenant défiler aussi les chiffres des Hz à la molette, pas seulement ceux des kHz — 100, 10 et 1 Hz. Se caler au zéro-beat sur un signal CW se joue à quelques dizaines de Hz, et c'était le seul geste que l'affichage refusait.",
"Le chasseur de pile-up en split n'apparaît qu'en CW : le marqueur vient d'un skimmer qui décode un report, donc en SSB ou en FT8 il n'a rien à chasser."
]
},
{
+19 -5
View File
@@ -244,10 +244,15 @@ function fmtFreqDots(mhzStr: string): string {
}
// FreqWheelDisplay renders a frequency (MHz string) like fmtFreqDots — MHz.kHz.Hz
// — but makes the three kHz digits scroll-sensitive: rolling the mouse wheel over
// the hundreds / tens / units-of-kHz digit steps the frequency by 100 / 10 / 1 kHz
// (up = wheel up). onNudge receives the delta in Hz. The MHz and Hz digits are
// static (only kHz stepping was requested). Used in the header + compact top bar.
// — and makes the kHz AND Hz digits scroll-sensitive: rolling the wheel over a
// digit steps the frequency by that digit (100/10/1 kHz, then 100/10/1 Hz; up =
// wheel up). onNudge receives the delta in Hz.
//
// The Hz digits were static at first, on the grounds that only kHz stepping had
// been asked for. An FTDX101 operator put it plainly: the fine tuning is exactly
// what a mouse is wanted for — zero-beating a CW signal or nudging onto an SSB
// voice is a few tens of Hz, and it was the one move the display would not make.
// The MHz digits stay static: a wheel notch that changes band is not fine tuning.
function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.———.———' }: {
mhz: string; onNudge: (deltaHz: number) => void; className?: string; placeholder?: string;
}) {
@@ -257,6 +262,7 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
const khz = frac.slice(0, 3); // [hundreds, tens, units] of kHz
const hz = frac.slice(3, 6);
const stepHz = [100_000, 10_000, 1_000]; // per kHz digit: 100 / 10 / 1 kHz
const stepHzFine = [100, 10, 1]; // per Hz digit
return (
<span className={className}>
{intPart}.
@@ -268,7 +274,15 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
{d}
</span>
))}
.{hz}
.
{hz.split('').map((d, i) => (
<span key={'h' + i}
onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHzFine[i] : -stepHzFine[i]); }}
className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
title="Scroll to change frequency">
{d}
</span>
))}
</span>
);
}
+5
View File
@@ -677,6 +677,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
in the log, which is done mid-QSO with one hand. The offset
sits beside it because it is the one number retuned while
chasing — everything else about the feature is configured once. */}
{/* CW ONLY. The marker comes from a skimmer decoding a CW report,
so on SSB or FT8 there is nothing to chase — and a switch that
cannot fire is an invitation to wonder why it does nothing. */}
{isCW && (<>
<button type="button" disabled={off}
title={t('flxp.rstChaseHint', { m: rstChase.markers })}
onContextMenu={(e) => { e.preventDefault(); setRstChaseEditing(true); }}
@@ -725,6 +729,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
Hz
</label>
)}
</>)}
{st.split && !!st.tx_freq_hz && (
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
+10 -3
View File
@@ -18,7 +18,8 @@ type YaesuState = {
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
agc?: string; preamp: number; att: number; antenna: number;
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; narrow_supported?: boolean; vox: boolean;
max_power?: number;
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
};
@@ -445,7 +446,13 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
<div className="flex items-center gap-2 flex-wrap">
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
{/* NAR is the narrow IF filter. Shown only when the rig ANSWERED NA:
a button that does nothing when pressed reads as a fault in the
radio, which is how it was reported from an FTDX101. */}
{view.narrow_supported && (
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))}
label="NAR" title={t('yaesu.narrowHint')} />
)}
</div>
<Row label="DNR">
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
@@ -462,7 +469,7 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
<Row label="PWR">
{/* Watts, not a percentage: the rig reports and takes watts, and a
percentage would be a second unit to reconcile every time. */}
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
<Slider value={view.rf_power || 5} min={5} max={Math.max(100, view.max_power || 100)} accent="var(--destructive)"
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
</Row>
+2 -2
View File
@@ -109,7 +109,7 @@ const en: Dict = {
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', 'settings.pane.decodes': 'FT decodes',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.narrowHint': 'Narrow IF filter (NAR) — tightens the receive bandwidth. Only shown when the radio answers the command.', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode; click again to switch sideband (U/L)', 'yaesu.splitUpHint': 'Transmit this far above the receive frequency, and turn split on', 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': 'Break-in: the rig switches to receive between characters', 'yaesu.zinHint': 'Zero-in: retune so the station you hear lands on your CW pitch',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.light-nordic': 'Nordic light', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Ocean', 'theme.dark-plum': 'Plum', 'theme.high-contrast': 'High contrast',
@@ -596,7 +596,7 @@ const fr: Dict = {
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net', 'settings.pane.decodes': 'Decodes FT',
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW",
'settings.pane.flex': 'Flex Console', 'settings.pane.icom': 'Icom Console', 'settings.pane.yaesu': 'Yaesu Console', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.narrowHint': "Filtre FI étroit (NAR) — resserre la bande passante de réception. N'apparaît que si la radio répond à la commande.", 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode ; recliquer pour changer de bande latérale (U/L)', 'yaesu.splitUpHint': "Émettre à cette distance au-dessus de la fréquence de réception, et activer le split", 'yaesu.txOn': 'TX', 'yaesu.cw': 'CW', 'yaesu.breakInHint': "Break-in : la radio repasse en réception entre les caractères", 'yaesu.zinHint': "Zéro-in : réaccorde pour que la station entendue tombe sur votre note CW",
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.light-nordic': 'Clair nordique', 'theme.sahara': 'Sahara', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.dark-indigo': 'Indigo', 'theme.dark-teal': 'Océan', 'theme.dark-plum': 'Prune', 'theme.high-contrast': 'Contraste élevé',
+4
View File
@@ -1220,6 +1220,8 @@ export namespace cat {
power_meter: number;
swr_meter: number;
rf_power: number;
max_power: number;
narrow_supported: boolean;
mic_gain: number;
af_gain: number;
rf_gain: number;
@@ -1255,6 +1257,8 @@ export namespace cat {
this.power_meter = source["power_meter"];
this.swr_meter = source["swr_meter"];
this.rf_power = source["rf_power"];
this.max_power = source["max_power"];
this.narrow_supported = source["narrow_supported"];
this.mic_gain = source["mic_gain"];
this.af_gain = source["af_gain"];
this.rf_gain = source["rf_gain"];
+38 -2
View File
@@ -44,6 +44,14 @@ type YaesuTXState struct {
SWRMeter int `json:"swr_meter"` // 0-100, TX only
RFPower int `json:"rf_power"` // watts
// MaxPower is what this model can actually be set to, so the slider stops
// where the radio does. An FTDX101MP is a 200 W transmitter and its operator
// could not ask for more than 100 W — the slider, not the rig, was the limit.
MaxPower int `json:"max_power"`
// NarrowSupported says the rig answered NA at all. A button that reports a
// state the radio never gave, and does nothing when pressed, is worse than
// an absent one: it looks like a fault in the radio.
NarrowSupported bool `json:"narrow_supported"`
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
@@ -110,9 +118,29 @@ func (y *Yaesu) YaesuState() YaesuTXState {
st := y.panel
st.Available = y.port != nil
st.Model = y.model
st.MaxPower = yaesuMaxPower(y.model, st.RFPower)
return st
}
// yaesuMaxPower is how far the power slider may go on this model.
//
// The rig itself is never asked — there is no CAT command for "how much power
// can you make" — so it comes from the model name, with one safeguard: a rig
// REPORTING more than the table expects is believed, because it has just proved
// what it can do. Anything unknown gets 100 W, which is the family default and
// errs towards asking for too little.
func yaesuMaxPower(model string, reported int) int {
max := 100
switch {
case strings.Contains(model, "101MP"), strings.Contains(model, "5000"), strings.Contains(model, "9000"):
max = 200
}
if reported > max {
max = reported
}
return max
}
// readPanel refreshes the meters, and the settings on the slower beat. Called
// from ReadState with the mutex HELD, so it shares the same serialised link.
func (y *Yaesu) readPanel(mode string, split bool, txHz int64) {
@@ -226,8 +254,15 @@ func (y *Yaesu) readPanelSettings() {
if v, ok := y.askNum("PC;", "PC", 3); ok {
y.panel.RFPower = v // watts, not a 0-255 scale
}
// MIC GAIN IS 0-100, not 0-255.
//
// It was read through the 0-255 scale like the audio gains, so an FTDX101
// set to 80 showed 38 — and worse, writing sent 80 → 204, outside the range
// the rig accepts, so the setting was refused and the slider sprang back to
// where it had been. Reported from a real FTDX101; the CAT reference gives
// MG000-100 for the whole current family.
if v, ok := y.askNum("MG;", "MG", 3); ok {
y.panel.MicGain = scale255(v)
y.panel.MicGain = clampInt(v, 0, 100)
}
if v, ok := y.askNum("AG0;", "AG0", 3); ok {
y.panel.AFGain = scale255(v)
@@ -276,6 +311,7 @@ func (y *Yaesu) readPanelSettings() {
}
if v, ok := y.askNum("NA0;", "NA0", 1); ok {
y.panel.Narrow = v != 0
y.panel.NarrowSupported = true
}
if v, ok := y.askNum("VX;", "VX", 1); ok {
y.panel.VOX = v != 0
@@ -356,7 +392,7 @@ func (y *Yaesu) SetYaesuPower(w int) error {
}
func (y *Yaesu) SetYaesuMicGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("MG%03d;", from100(p)))
return y.setAndRefresh(fmt.Sprintf("MG%03d;", clampInt(p, 0, 100)))
}
func (y *Yaesu) SetYaesuAFGain(p int) error {