diff --git a/changelog.json b/changelog.json index ee05e01..04650cb 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go index b7b1b6e..0bdae5f 100644 --- a/internal/cat/civ/civ.go +++ b/internal/cat/civ/civ.go @@ -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 diff --git a/internal/cat/civ/civ_test.go b/internal/cat/civ/civ_test.go index e82cfd6..3041306 100644 --- a/internal/cat/civ/civ_test.go +++ b/internal/cat/civ/civ_test.go @@ -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") + } +} diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index f9cb1c4..f00dc3b 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -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. diff --git a/internal/cat/xiegu.go b/internal/cat/xiegu.go index 38d6a02..25526a7 100644 --- a/internal/cat/xiegu.go +++ b/internal/cat/xiegu.go @@ -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 diff --git a/internal/cat/xiegu_test.go b/internal/cat/xiegu_test.go index 00fafbb..32d2f7d 100644 --- a/internal/cat/xiegu_test.go +++ b/internal/cat/xiegu_test.go @@ -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) } } }