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": [
"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.",
"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": [
"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.",
"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 peakRef = useRef(160); // running amplitude ceiling for auto-scale
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 vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
@@ -333,6 +336,7 @@ function ScopePanadapter() {
if (!alive) return;
try {
const sw = await IcomScopeData();
if (sw?.unsupported) setUnsupported(true);
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq;
setFixed(sw.fixed);
@@ -543,7 +547,10 @@ function ScopePanadapter() {
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</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="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<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.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
'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.tipThisCall': 'already worked 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.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
'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.tipThisCall': 'déjà contacté avec cet indicatif',
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
+2
View File
@@ -1196,6 +1196,7 @@ export namespace cat {
low_hz: number;
high_hz: number;
fixed: boolean;
unsupported: boolean;
static createFrom(source: any = {}) {
return new ScopeSweep(source);
@@ -1208,6 +1209,7 @@ export namespace cat {
this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"];
this.fixed = source["fixed"];
this.unsupported = source["unsupported"];
}
}
export class TCIPanelState {
+6
View File
@@ -674,6 +674,12 @@ type ScopeSweep struct {
LowHz int64 `json:"low_hz"` // left 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
// 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
+25 -11
View File
@@ -93,15 +93,18 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
dualScope bool
// Set when the rig rejects the waveform-output command in both shapes: it has
// no stream to give, and asking again on every enable is noise.
scopeUnsupported bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
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)
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
// left exactly as the operator had it.
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.scopeOn = on
@@ -1302,7 +1315,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
for i, v := range b.scopeAmp {
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.