fix(icom): sidebands by name, model-aware controls, address 0x00

From an IC-7300 report, four faults and one addition.

USB and LSB could not be commanded at all: modeCode knew 'SSB' — which
resolves the sideband from the band — and answered 'unsupported mode' to
the sideband names themselves. So an operator wanting USB on 40 m had no
way to say it, from the console or from anywhere else. They are separate
buttons now, and a rig reporting the folded ADIF 'SSB' still lights the
side its frequency implies.

The console offered controls the radio does not have: ANT1/ANT2 on a rig
with one socket, and a PSK button every non-7610-class Icom NAKs. Both
now follow the model, as the band buttons and attenuator steps already
did. Mic gain stops being phone-only — on USB-D it still sets what the
radio transmits at, so an operator who lives in FT8 had none.

CI-V address 0x00 was refused by a 'n > 0' test and silently replaced by
the IC-7610 default; an EMPTY setting is what means unconfigured, so the
parse error decides now, not the value. Plus the 60 m band button that
was missing.
This commit is contained in:
2026-09-02 00:00:21 +02:00
parent 3f97084246
commit ad69371f6a
4 changed files with 92 additions and 18 deletions
+65 -14
View File
@@ -53,7 +53,13 @@ const ZERO: IcomState = {
type Band = { l: string; hz: number };
const HF_BANDS: Band[] = [
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
// every 60 m-capable rig can display. Where the band is channelised (the US)
// the operator moves to their channel from here — the button is a way onto
// the band, not a claim about what may be transmitted on it.
{ l: '60', hz: 5_354_000 },
{ l: '40', hz: 7_100_000 },
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
];
@@ -78,9 +84,36 @@ function bandsFor(model?: string): Band[] {
return [...HF_BANDS, B6];
}
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
// SSB by frequency and the rig's data variant for digital modes.
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM', 'DATA'];
// Mode buttons for the console (like RS-BA1's row).
//
// LSB and USB by NAME, not one "SSB" button that resolves by band: the band
// convention is right for a logged mode and useless when the operator means
// "put this radio in USB on 40 m", which the console could not express at all.
//
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
// DATA, which every rig can do.
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
function hasNativePSK(model?: string): boolean {
const m = (model ?? '').toUpperCase();
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
m.includes('7800') || m.includes('7700');
}
function modesFor(model?: string): string[] {
if (!hasNativePSK(model)) return MODES_BASE;
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
}
// Which radios actually have an antenna selector on the CI-V command (0x12).
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
// only ever disagree with the front panel.
function hasAntennaSelector(model?: string): boolean {
const m = (model ?? '').toUpperCase();
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
m.includes('7800') || m.includes('7700') || m.includes('9700');
}
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
@@ -155,9 +188,21 @@ function icomWatts(pct: number): { w: number; defl: number } {
return { w: Math.round(w), defl };
}
function modeMatches(btn: string, cur?: string): boolean {
// Which sideband a bare "SSB" means at this frequency — the same convention the
// backend applies when it resolves the mode for the radio.
function sideForHz(hz?: number): string | null {
if (!hz || hz <= 0) return null;
return hz < 10_000_000 ? 'LSB' : 'USB';
}
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
if (!cur) return false;
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
// A rig that reports the folded ADIF "SSB" still lights the side its
// frequency implies, so the row is never blank on a phone contact.
if (btn === 'USB' || btn === 'LSB') {
if (cur === btn) return true;
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
}
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
// plain DATA — either way it is the DATA button that should light.
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
@@ -507,9 +552,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
</div>
</div>
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
<div className="grid grid-cols-7 border-t border-border/60 divide-x divide-border/60">
{MODES.map((m) => {
const on = modeMatches(m, curMode);
<div className="grid border-t border-border/60 divide-x divide-border/60"
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
{modesFor(st.model).map((m) => {
const on = modeMatches(m, curMode, mainHz);
return (
<button key={m} type="button" onClick={() => setMode(m)}
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
@@ -561,10 +607,12 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
);
})}
</div>
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
</Row>
{hasAntennaSelector(st.model) && (
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
</Row>
)}
</Card>
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
@@ -588,7 +636,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
</span>
</Row>
{isPhone && (
{/* Not phone-only: on USB-D the same control still sets what the radio
transmits at, and hiding it left an operator who lives in FT8 with
no mic gain at all. */}
{(
<Row label={t('icmp.mic')}>
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>