From 70ada497761cde0494f84cab34966c4d2d71feeb Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 6 Sep 2026 13:12:00 +0200 Subject: [PATCH] fix: CI-V address 00, simplex uploaded as split, and S/F spots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three field reports. Icom CI-V address 00 could not be kept: zero was read as "not configured" in all three places that validate it, so every save put the rig back to the IC-7610's 0x98 — and the model dropdown followed, since it is derived from the address rather than stored. Picking "Other (custom address)" also had no effect of its own: the list re-derived itself and snapped back to whatever rig matched. It now stays chosen. Cloudlog/Wavelog showed "17m/17m" on ordinary FT8 contacts (OE6CLD). Every QSO is stamped with a receive side equal to the transmit side, and the uploaded record carried it; Wavelog draws band/band_rx whenever both are there. In ADIF an absent BAND_RX means "same as transmit", so the uploaded record now writes the receive side only when it differs. The copy forwarded to another logger over UDP keeps writing it in full — that is why it was stamped in the first place (Log4OM reads BAND_RX) — through its own ForwardRecordADIF. "S/F" in a spot comment joins superfox / sfox / F-H as FT8. --- app.go | 10 +++-- changelog.json | 10 ++++- frontend/src/components/SettingsModal.tsx | 21 +++++++++-- frontend/src/lib/spot.ts | 11 ++++-- internal/adif/export.go | 45 ++++++++++++++++++++--- internal/cat/icomnet.go | 2 +- internal/cat/icomserial.go | 5 ++- rxdefaults_test.go | 17 ++++++++- 8 files changed, 99 insertions(+), 22 deletions(-) diff --git a/app.go b/app.go index e4e248c..c20cbd8 100644 --- a/app.go +++ b/app.go @@ -3154,7 +3154,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { // a few lines above and is not on the copy taken at insert time. a.syncPublishAsync(syncfolder.OpAdd, id, nil) if a.udp != nil { - rec := adif.SingleRecordADIF(qc) + rec := adif.ForwardRecordADIF(qc) a.udp.EmitLoggedADIF(rec) a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec) a.udpTriggerQSOLogged(qc) @@ -8475,7 +8475,11 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.IcomBaud <= 0 { s.IcomBaud = 115200 } - if s.IcomAddr <= 0 || s.IcomAddr > 0xFF { + // 0x00 IS an address. Rejecting it as "unset" is what sent an operator's + // custom-address rig straight back to the IC-7610's 0x98 every time the + // settings were saved — the model dropdown snapping back with it, since it + // reads the address rather than a stored model. + if s.IcomAddr < 0 || s.IcomAddr > 0xFF { s.IcomAddr = 0x98 } if s.PollMs < 50 || s.PollMs > 2000 { @@ -14319,7 +14323,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { // path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted // to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…). if a.udp != nil { - rec := adif.SingleRecordADIF(qc) + rec := adif.ForwardRecordADIF(qc) a.udp.EmitLoggedADIF(rec) a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec) a.udpTriggerQSOLogged(qc) diff --git a/changelog.json b/changelog.json index 53eff88..eb982ae 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,16 @@ "version": "0.27.14", "date": "", "en": [ - "Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it." + "Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.", + "Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610’s 98 — the model list following it, since it is derived from the address rather than stored.", + "Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).", + "Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA." ], "fr": [ - "L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble." + "L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.", + "Icom CI-V : l’adresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de l’IC-7610 — la liste des modèles suivant, puisqu’elle est déduite de l’adresse et non enregistrée.", + "Upload Cloudlog / Wavelog : un contact simplex n’est plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que s’ils diffèrent. L’enregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).", + "Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». C’est le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e7a13a4..0bd9b19 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1621,6 +1621,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged const [listenPref, setListenPref] = useState(true); const [activeRadio, setActiveRadioId] = useState(''); const [radioBusy, setRadioBusy] = useState(false); + // "Other (custom address)" has to STAY chosen. The dropdown is derived from + // the address, so picking Other while the address still matched a listed rig + // put the list straight back on that rig — the operator saw it jump back to + // IC-7610 the moment they chose Other. + const [icomCustom, setIcomCustom] = useState(false); const [catCfg, setCatCfg] = useState({ enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false, yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '', @@ -3628,8 +3633,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'} - onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}> + value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')} + onValueChange={(v) => { + const m = ICOM_MODELS.find((x) => x.name === v); + setIcomCustom(!m); + if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); + }}> {ICOM_MODELS.map((m) => {m.name})} diff --git a/frontend/src/lib/spot.ts b/frontend/src/lib/spot.ts index 565468c..29fd9be 100644 --- a/frontend/src/lib/spot.ts +++ b/frontend/src/lib/spot.ts @@ -15,12 +15,15 @@ export function cleanSpotter(s: string): string { // alone instead of guessing wrong. export function inferSpotMode(comment: string, freqHz: number): string { const c = (comment || '').toUpperCase(); - // SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit - // modes, not modes of their own. A spot commented "super fox" fell through to - // the band plan and came out DATA, and that verdict is not cosmetic: the + // SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit modes, + // not modes of their own, and they turn up in a comment written every way an + // operator can shorten them: "super fox", SFOX, S/F, F/H. + // + // A spot commented "super fox" fell through to the band plan and came out + // DATA, and that verdict is not cosmetic: the // band+mode status is computed from this answer, so a ZD8 on 21.071 read as a // new DATA slot rather than the new FT8 one it is. - if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8'; + if (/\bSUPER\s*FOX\b|\bSFOX\b|\bS\/F\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8'; if (/\bFT8\b/.test(c)) return 'FT8'; if (/\bFT4\b/.test(c)) return 'FT4'; if (/\bJS8\b/.test(c)) return 'JS8'; diff --git a/internal/adif/export.go b/internal/adif/export.go index ac5ac99..397b28f 100644 --- a/internal/adif/export.go +++ b/internal/adif/export.go @@ -120,12 +120,36 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in func SingleRecordADIF(q qso.QSO) string { var b strings.Builder bw := bufio.NewWriter(&b) - // Uploads target other services — keep it standard (no app-specific tags). + // Uploads target other services — keep it standard (no app-specific tags), + // and say nothing about the receive side when there is nothing to say: in + // ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger + // given both draws both. Wavelog reads a simplex FT8 contact uploaded with + // BAND_RX filled in as split and shows it as "17m/17m". writeRecord(bw, q, false, nil) bw.Flush() return b.String() } +// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link. +// +// The receive side is written even when it repeats the transmit side, which is +// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX +// and found nothing there for contacts logged by a path that left it blank. A +// logger on the same desk is being handed a copy of our record, not published +// to a service that will draw conclusions from every tag present. +func ForwardRecordADIF(q qso.QSO) string { + var b strings.Builder + bw := bufio.NewWriter(&b) + writeRecord(bw, q, false, nil, keepRX) + bw.Flush() + return b.String() +} + +// keepRX marks a record whose receive side must be written out in full. +type rxMode int + +const keepRX rxMode = 1 + // FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras — // so it can be written out and read back with nothing dropped. Used by the // offline queue: a QSO parked in the safety file must come back identical @@ -161,7 +185,18 @@ func BatchRecordsADIF(records []string) string { // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // mode (e.g. FT4 stored without a parent) is exported as the canonical // pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers. -func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) { +func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) { + // The receive side, unless it merely repeats the transmit side. See + // SingleRecordADIF and ForwardRecordADIF. + bandRX, freqRX := q.BandRX, q.FreqRXHz + if len(rx) == 0 || rx[0] != keepRX { + if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) { + bandRX = "" + } + if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz { + freqRX = nil + } + } // allow == nil → write every promoted field (standard/full behaviour). // Otherwise a promoted tag is written only when it's in the chosen set. // w/wi/wf wrap the raw writers with that gate so the ~150 field lines below @@ -194,7 +229,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string] w("TIME_OFF", q.QSODateOff.UTC().Format("150405")) } w("BAND", q.Band) - w("BAND_RX", q.BandRX) + w("BAND_RX", bandRX) mode, submode := modeForExport(q.Mode, q.Submode) w("MODE", mode) @@ -203,8 +238,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string] if q.FreqHz != nil && *q.FreqHz > 0 { w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64)) } - if q.FreqRXHz != nil && *q.FreqRXHz > 0 { - w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64)) + if freqRX != nil && *freqRX > 0 { + w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64)) } w("RST_SENT", q.RSTSent) diff --git a/internal/cat/icomnet.go b/internal/cat/icomnet.go index 53a0c0b..6826ac3 100644 --- a/internal/cat/icomnet.go +++ b/internal/cat/icomnet.go @@ -43,7 +43,7 @@ var icnBE = binary.BigEndian // = CI-V only (the proven default). The audio stream is fully separate from CAT, // so enabling it can't affect freq/mode/DSP control. func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial { - if civAddr <= 0 || civAddr > 0xFF { + if civAddr < 0 || civAddr > 0xFF { civAddr = 0x98 // IC-7610 } if digitalDefault == "" { diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 0be531a..43a8f5e 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -172,12 +172,13 @@ const ( ) // NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to -// 115200, rig address to the IC-7610's 0x98 when out of range. +// 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in +// range: it is a valid CI-V address that some rigs and interfaces are set to. func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial { if baud <= 0 { baud = 115200 } - if civAddr <= 0 || civAddr > 0xFF { + if civAddr < 0 || civAddr > 0xFF { civAddr = 0x98 // IC-7610 } if digitalDefault == "" { diff --git a/rxdefaults_test.go b/rxdefaults_test.go index 2038d70..300636b 100644 --- a/rxdefaults_test.go +++ b/rxdefaults_test.go @@ -10,6 +10,10 @@ import ( // A contact that was not split still has a receive side, and the record // forwarded to another logger has to carry it: Log4OM reads BAND_RX. +// +// The record UPLOADED to a service must not: in ADIF an absent BAND_RX means +// "same as transmit", and Wavelog shown both draws "17m/17m" on a simplex FT8 +// contact — reported by OE6CLD, who had never logged a split QSO in his life. func TestFillRXDefaults(t *testing.T) { hz := int64(14074000) q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz} @@ -22,7 +26,7 @@ func TestFillRXDefaults(t *testing.T) { } // Assert on the RECORD another logger reads, not merely on the struct: // both halves of the receive side have to reach it. - rec := strings.ToUpper(adif.SingleRecordADIF(q)) + rec := strings.ToUpper(adif.ForwardRecordADIF(q)) if !strings.Contains(rec, "20M") { t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec) } @@ -40,3 +44,14 @@ func TestFillRXDefaultsKeepsSplit(t *testing.T) { t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz) } } + +// A genuine split contact is uploaded AS split: the rule above is about a +// receive side that repeats the transmit side, not about dropping one. +func TestSplitIsUploadedWithItsReceiveSide(t *testing.T) { + tx, rx := int64(14195000), int64(18100000) + q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx} + up := strings.ToUpper(adif.SingleRecordADIF(q)) + if !strings.Contains(up, "17M") || !strings.Contains(up, "18.100000") { + t.Errorf("a cross-band contact lost its receive side:\n%s", up) + } +}