fix: CI-V frames read at the wrong offset decoded as plausible frequencies

From F4JND's log: a rig sitting on 145.550 MHz FM produced status lines reading
16445550000, 8364550000, 5817408364 and 12640 Hz between good readings — the last
of those even resolved to a 6 cm band.

BCDToFreq treated the nibbles 0x0A..0x0F as the digits 10..15, so a byte stream
that had lost frame sync still decoded into a number. The same log shows why sync
was lost: PTT round trips timing out and a scope stream the rig kept rejecting
(0x27), both competing with the poll on one serial line.

Non-decimal nibbles are now refused. Each caller keeps its last good value rather
than publishing noise: the frequency reader returns an error, the sub-VFO reader
returns not-ok, and the scope keeps its previous edges. Garbage that LOOKS like a
frequency is worse than a refused frame — it reaches the display, the band slots
and eventually the log, and it is the operator who then has to disprove it.

The test builds its fixture with FreqToBCD rather than by hand, because my
hand-written one was byte-reversed and the test caught it.

This does NOT explain the white screen that was also reported, and I have not
claimed it does: it removes one source of absurd values reaching the UI, which is
worth doing on its own evidence.
This commit is contained in:
2026-07-30 14:05:30 +02:00
parent fbd6cf400f
commit ef84b04c99
6 changed files with 98 additions and 20 deletions
+4 -2
View File
@@ -7,14 +7,16 @@
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT."
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.",
"Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands."
],
"fr": [
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT."
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.",
"Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée."
]
},
{
+17 -4
View File
@@ -160,16 +160,29 @@ func FreqToBCD(hz int64) []byte {
}
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
func BCDToFreq(b []byte) int64 {
//
// It returns ok=false when a nibble is not a decimal digit. That check is the
// point of the function: a CI-V byte stream that has lost frame sync — a reply
// read at the wrong offset, a collision with the scope stream — still decodes
// into a number if 0x0A..0x0F are quietly treated as 10..15. An operator on a
// 2 m FM channel then saw entries like 16445550000 Hz appear in the status bar
// between good readings (F4JND, 2026-07-30). Garbage that LOOKS like a
// frequency is worse than a refused frame: it reaches the display, the band
// slots, and eventually the log.
func BCDToFreq(b []byte) (int64, bool) {
var hz int64
mult := int64(1)
for i := 0; i < len(b) && i < 5; i++ {
hz += int64(b[i]&0x0F) * mult
lo, hi := b[i]&0x0F, b[i]>>4
if lo > 9 || hi > 9 {
return 0, false
}
hz += int64(lo) * mult
mult *= 10
hz += int64(b[i]>>4) * mult
hz += int64(hi) * mult
mult *= 10
}
return hz
return hz, true
}
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
+45 -4
View File
@@ -12,8 +12,9 @@ func TestFreqBCDRoundTrip(t *testing.T) {
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
}
if got := BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
got, ok := BCDToFreq(b)
if !ok || got != hz {
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
}
}
}
@@ -49,8 +50,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
t.Errorf("addrs/cmd wrong: %+v", f)
}
if hz := BCDToFreq(f.Data); hz != 14250000 {
t.Errorf("decoded freq %d, want 14250000", hz)
if hz, ok := BCDToFreq(f.Data); !ok || hz != 14250000 {
t.Errorf("decoded freq %d (ok=%v), want 14250000", hz, ok)
}
}
@@ -163,3 +164,43 @@ func TestModelNameAddresses(t *testing.T) {
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
}
}
// A frame that is not decimal BCD is refused rather than decoded.
//
// From an operator's log (F4JND, 2026-07-30): a rig sitting on 145.550 MHz FM
// produced status lines reading 16445550000, 8364550000, 5817408364 and 12640 Hz
// between good readings. CI-V had lost frame sync — a reply read at the wrong
// offset, alongside PTT timeouts and a scope stream the rig was rejecting — and
// the decoder treated the nibbles 0x0A..0x0F as the digits 10..15, turning noise
// into a plausible-looking number.
//
// Garbage that looks like a frequency is worse than a refused frame: it reaches
// the status bar, the band slots and the log, and it is what an operator then
// has to disprove.
func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
// Valid: 145.550 MHz, encoded by the same package rather than by hand — my
// hand-written fixture was byte-reversed and this caught it.
good := FreqToBCD(145550000)
if hz, ok := BCDToFreq(good); !ok || hz != 145550000 {
t.Errorf("valid BCD % X decoded as %d (ok=%v), want 145550000", good, hz, ok)
}
// Every nibble value above 9 must be refused, in either half of the byte.
bad := [][]byte{
{0x0A, 0x00, 0x55, 0x45, 0x01}, // low nibble
{0x00, 0x00, 0xF5, 0x45, 0x01}, // high nibble
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // an idle line read as data
{0x00, 0x00, 0x00, 0x0B, 0x00},
}
for _, b := range bad {
if hz, ok := BCDToFreq(b); ok {
t.Errorf("% X was accepted as %d Hz — it is not decimal BCD", b, hz)
}
}
// A short frame still decodes what it holds: the caller decides whether a
// partial read is usable, and refusing it here would break the 4-byte forms.
if _, ok := BCDToFreq([]byte{0x00, 0x50}); !ok {
t.Error("a short but valid BCD frame must still decode")
}
}
+27 -6
View File
@@ -750,8 +750,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
// 21.000-42.070 span (low + a 21 MHz "span").
v1 := civ.BCDToFreq(region[1:6])
v2 := civ.BCDToFreq(region[6:11])
v1, ok1 := civ.BCDToFreq(region[1:6])
v2, ok2 := civ.BCDToFreq(region[6:11])
if !ok1 || !ok2 {
// Not decimal BCD — the frame was read at the wrong offset. Keep the
// edges we had; a wrong span is drawn, noticed, and mistrusted.
break
}
var low, high int64
if v2 > v1 {
low, high = v1, v2 // absolute low/high edges (fixed edge set)
@@ -788,8 +793,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
// high = span (e.g. 100 kHz), so high < low and the panadapter had
// no valid range to map the trace onto → blank scope. FIXED mode
// parsed fine, which is why only center mode was broken.
v1 := civ.BCDToFreq(region[1:6])
v2 := civ.BCDToFreq(region[6:11])
v1, ok1 := civ.BCDToFreq(region[1:6])
v2, ok2 := civ.BCDToFreq(region[6:11])
if !ok1 || !ok2 {
// Not decimal BCD — the frame was read at the wrong offset. Keep the
// edges we had; a wrong span is drawn, noticed, and mistrusted.
break
}
var low, high int64
if v2 > v1 {
low, high = v1, v2 // absolute low/high edges (fixed)
@@ -1142,7 +1152,14 @@ func (b *IcomSerial) readFreq() (int64, error) {
if err != nil {
return 0, err
}
return civ.BCDToFreq(f.Data), nil
hz, ok := civ.BCDToFreq(f.Data)
if !ok {
// A frame that is not decimal BCD is a desynchronised read, not a
// frequency. Reporting the error keeps the caller on its last good value
// instead of publishing a number like 16445550000 Hz to the display.
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
}
return hz, nil
}
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
@@ -1172,7 +1189,11 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
if err != nil {
return 0, false
}
return civ.BCDToFreq(f.Data[1:]), true
hz, ok := civ.BCDToFreq(f.Data[1:])
if !ok {
return 0, false
}
return hz, true
}
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
+2 -2
View File
@@ -247,8 +247,8 @@ func (x *Xiegu) readFreq() (int64, error) {
if err != nil {
return 0, err
}
hz := civ.BCDToFreq(d.Data)
if hz <= 0 {
hz, ok := civ.BCDToFreq(d.Data)
if !ok || hz <= 0 {
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
}
return hz, nil
+3 -2
View File
@@ -76,8 +76,9 @@ func TestXieguFrequencyEncoding(t *testing.T) {
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
}
if got := civ.BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
got, ok := civ.BCDToFreq(b)
if !ok || got != hz {
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
}
}
}