fix(icom): say when a radio has no scope stream instead of drawing nothing

The CI-V trace settles it: the IC-7851 answers 27 10 01 with FB and 27 14 00
with its mode, but refuses 27 11 in BOTH shapes — it controls its scope over
CI-V and never streams it. Its last firmware is from 2016, older than the
waveform stream itself; Hamlib's caps claim otherwise, the radio is the
authority.

Rejected both ways is a permanent answer, so it is remembered and reported:
the panadapter says the radio does not send its scope, which is the one thing a
black rectangle could not say.
This commit is contained in:
2026-08-27 16:30:39 +02:00
parent d2cfad490a
commit fa1c41388d
6 changed files with 45 additions and 14 deletions
+2 -2
View File
@@ -5,12 +5,12 @@
"en": [ "en": [
"Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.", "Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.",
"Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.", "Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.",
"Icom spectrum scope: the IC-7851 (and any radio that wants the main/sub selector on its scope commands) now works over CI-V — the command shape is worked out from the radios own answer instead of a list of models." "Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radios own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle."
], ],
"fr": [ "fr": [
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
"Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.", "Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.",
"Scope Icom : l'IC-7851 (et toute radio qui attend le sélecteur main/sub sur les commandes de scope) fonctionne maintenant en CI-V — la forme de la commande est déduite de la réponse de la radio au lieu d'une liste de modèles." "Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir."
] ]
}, },
{ {
+8 -1
View File
@@ -292,6 +292,9 @@ function ScopePanadapter() {
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef<number[]>([]); // per-bin peak-hold line const holdRef = useRef<number[]>([]); // per-bin peak-hold line
// Some radios control their scope over CI-V but never stream it (IC-7851).
// Saying so beats a black rectangle, which reads as a bug in OpsLog.
const [unsupported, setUnsupported] = useState(false);
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶) const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
@@ -333,6 +336,7 @@ function ScopePanadapter() {
if (!alive) return; if (!alive) return;
try { try {
const sw = await IcomScopeData(); const sw = await IcomScopeData();
if (sw?.unsupported) setUnsupported(true);
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) { if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq; lastSeq = sw.seq;
setFixed(sw.fixed); setFixed(sw.fixed);
@@ -543,7 +547,10 @@ function ScopePanadapter() {
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} /> <Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div> </div>
</div> </div>
{on && ( {on && unsupported && (
<div className="px-3 py-2 text-xs text-muted-foreground">{t('icmp.scopeNoStream')}</div>
)}
{on && !unsupported && (
<div className="p-3"> <div className="p-3">
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]"> <div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<canvas ref={canvasRef} onDoubleClick={onDblClick} <canvas ref={canvasRef} onDoubleClick={onDblClick}
+2
View File
@@ -127,6 +127,7 @@ const en: Dict = {
'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)', 'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)',
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)', 'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs', 'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
'mx.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed', 'mx.markWork': 'This callsign worked', 'mx.markConf': 'This callsign confirmed',
'mx.tipThisCall': 'already worked with this callsign', 'mx.tipThisCall': 'already worked with this callsign',
'mx.tipThisCallConf': 'already confirmed with this callsign', 'mx.tipThisCallConf': 'already confirmed with this callsign',
@@ -616,6 +617,7 @@ const fr: Dict = {
'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)', 'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)',
'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)', 'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO', 'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
'mx.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé', 'mx.markWork': 'Cet indicatif contacté', 'mx.markConf': 'Cet indicatif confirmé',
'mx.tipThisCall': 'déjà contacté avec cet indicatif', 'mx.tipThisCall': 'déjà contacté avec cet indicatif',
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif', 'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
+2
View File
@@ -1196,6 +1196,7 @@ export namespace cat {
low_hz: number; low_hz: number;
high_hz: number; high_hz: number;
fixed: boolean; fixed: boolean;
unsupported: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ScopeSweep(source); return new ScopeSweep(source);
@@ -1208,6 +1209,7 @@ export namespace cat {
this.low_hz = source["low_hz"]; this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"]; this.high_hz = source["high_hz"];
this.fixed = source["fixed"]; this.fixed = source["fixed"];
this.unsupported = source["unsupported"];
} }
} }
export class TCIPanelState { export class TCIPanelState {
+6
View File
@@ -674,6 +674,12 @@ type ScopeSweep struct {
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown) LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown) HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
// Unsupported: this radio refuses the waveform-output command, so there will
// never be a sweep. The IC-7851 does — its last firmware is from 2016, older
// than the CI-V waveform stream — while still answering the scope's other
// commands. Reported so the panadapter can say so instead of showing a black
// rectangle that looks like a bug in OpsLog.
Unsupported bool `json:"unsupported"`
} }
// IcomState returns the current Icom DSP state, or (zero, false) when the active // IcomState returns the current Icom DSP state, or (zero, false) when the active
+25 -11
View File
@@ -93,15 +93,18 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest // leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read // reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine). // via ScopeData from the binding goroutine).
dualScope bool dualScope bool
scopeMu sync.Mutex // Set when the rig rejects the waveform-output command in both shapes: it has
scopeAmp []byte // no stream to give, and asking again on every enable is noise.
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame) scopeUnsupported bool
scopeHigh int64 // spectrum right-edge frequency scopeMu sync.Mutex
scopeSeq int scopeAmp []byte
scopeOn bool scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeFixed bool // true = fixed-span mode (tracked optimistically) scopeHigh int64 // spectrum right-edge frequency
scopeSeen bool // logged the first sweep's structure once (on-rig verification) scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice) curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send) curModeByte byte // last raw Icom mode byte (for filter re-send)
@@ -1058,7 +1061,17 @@ func (b *IcomSerial) SetScope(on bool) error {
// the ONLY thing we switch off on disable, so the radio's own scope display is // the ONLY thing we switch off on disable, so the radio's own scope display is
// left exactly as the operator had it. // left exactly as the operator had it.
if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil { if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v — turn the CI-V trace on (Settings → CAT) and send the log", on, err) applog.Printf("icom scope: output on=%v ack: %v", on, err)
// Rejected in both shapes = the command does not exist on this rig, which
// is a permanent answer and not a bad guess on our part. Remember it: the
// panel can then say so, and we stop asking a radio that has already
// said no.
if strings.Contains(err.Error(), "rejected") {
applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
b.scopeMu.Lock()
b.scopeUnsupported = true
b.scopeMu.Unlock()
}
} }
b.scopeMu.Lock() b.scopeMu.Lock()
b.scopeOn = on b.scopeOn = on
@@ -1302,7 +1315,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
for i, v := range b.scopeAmp { for i, v := range b.scopeAmp {
amp[i] = int(v) amp[i] = int(v)
} }
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed} return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
Unsupported: b.scopeUnsupported}
} }
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack. // exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.