From ee148732dd8fdeb28bcaca635e714dbbc6c12a6f Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 23 Aug 2026 12:18:27 +0200 Subject: [PATCH] feat: HAMLOG.online upload and confirmations, Yaesu antenna, a named port holder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HAMLOG.online is a seventh external service: one API key, one ADIF record per QSO, immediate / delayed / on-close like the rest. They publish no API documentation, so the protocol is read from THEIR OWN client — the HAMLOG Agent (github.com/hamlogonline/Agent), which is the authoritative source short of asking them: POST https://hamlog.online/api/agent/ {"ADIFADD": {"APIKEY": k, "ADIFDATA": record}} → {"STATUS":"OK"} {"KEYSTATUS": {"APIKEY": k}} → {"STATUS":"OK","CALLSIGN":…} Success is STATUS == OK, not "no ERROR field": their failure carries ERROR and no STATUS, and reading an unknown reply — a proxy page, a maintenance notice — as an acceptance is how a contact goes missing without anyone noticing. KEYSTATUS buys something no other service here offers: the key can be checked BEFORE the first QSO, and the answer names the account. A key pasted from another callsign is caught in the settings panel rather than through a week of silent refusals. Their confirmations are also an award source now, ticked like LoTW rather than expressed through "custom". It reads the ADIF extras, not a column: the standard names a field for hamlog.EU and none for hamlog.ONLINE, and borrowing the other site's field would write a falsehood into every exported log. Three plausible key names from their own export are accepted too, so nobody has to rename a column by hand after an export. Yaesu gains antenna selection (AN), remembered per band — the antenna picked on a band comes back with it, with no table to fill in anywhere. Rigs with one socket never answer AN and never show the row; the log says which case it is. And a serial port that is refused now names its likely holder. OmniRig stays resident once activated and keeps the port of the rig configured in it, so a native backend never gets it — "Serial port busy" alone accused nobody, and an FTDX10 spent a morning being blamed for it. --- app.go | 80 +++++++++++++ changelog.json | 16 +++ frontend/src/App.tsx | 11 +- frontend/src/components/AwardEditor.tsx | 3 +- frontend/src/components/YaesuPanel.tsx | 20 +++- frontend/wailsjs/go/main/App.d.ts | 8 ++ frontend/wailsjs/go/main/App.js | 16 +++ frontend/wailsjs/go/models.ts | 4 + internal/award/award.go | 49 +++++++- internal/award/confirm_test.go | 31 +++++ internal/cat/icomserial.go | 2 +- internal/cat/kenwood.go | 2 +- internal/cat/portbusy.go | 39 +++++++ internal/cat/xiegu.go | 2 +- internal/cat/yaesu.go | 2 +- internal/cat/yaesu_panel.go | 44 ++++++- internal/cat/yaesu_test.go | 28 ++++- internal/extsvc/extsvc.go | 3 + internal/extsvc/hamlog.go | 149 ++++++++++++++++++++++++ internal/extsvc/hamlog_test.go | 87 ++++++++++++++ internal/extsvc/manager.go | 25 ++++ 21 files changed, 603 insertions(+), 18 deletions(-) create mode 100644 internal/cat/portbusy.go create mode 100644 internal/extsvc/hamlog.go create mode 100644 internal/extsvc/hamlog_test.go diff --git a/app.go b/app.go index 16ec7d5..7a94f65 100644 --- a/app.go +++ b/app.go @@ -19882,6 +19882,86 @@ func (a *App) SetYaesuPreamp(n int) error { return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPreamp(n) }) } +// SetYaesuAntenna selects an antenna jack on a Yaesu, and REMEMBERS it for the +// band the rig is on. +// +// No settings page for this, deliberately. A per-band antenna table is one more +// form to fill in and one more menu to find, for a fact the operator states +// perfectly well by choosing the antenna once on that band. So the choice is +// learnt where it is made, and re-applied on the next visit — see +// applyYaesuBandAntenna. +func (a *App) SetYaesuAntenna(n int) error { + if err := a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAntenna(n) }); err != nil { + return err + } + if band := strings.ToLower(strings.TrimSpace(a.cat.State().Band)); band != "" && n > 0 { + m := a.yaesuBandAntennas() + if m[band] != n { + m[band] = n + a.saveYaesuBandAntennas(m) + applog.Printf("yaesu: antenna %d remembered for %s", n, band) + } + } + return nil +} + +// keyYaesuBandAnt holds the learnt band → antenna map, as JSON. +const keyYaesuBandAnt = "yaesu.band_antennas" + +func (a *App) yaesuBandAntennas() map[string]int { + out := map[string]int{} + if raw := a.settingOr(keyYaesuBandAnt, ""); raw != "" { + _ = json.Unmarshal([]byte(raw), &out) + } + return out +} + +func (a *App) saveYaesuBandAntennas(m map[string]int) { + if b, err := json.Marshal(m); err == nil { + a.setSetting(keyYaesuBandAnt, string(b)) + } +} + +// GetYaesuBandAntennas exposes the learnt map, so the panel can show which band +// carries which antenna without the operator having to change band to find out. +func (a *App) GetYaesuBandAntennas() map[string]int { return a.yaesuBandAntennas() } + +// ForgetYaesuBandAntenna drops the memory for one band — the way to undo a +// choice made by mistake, without a table to edit. +func (a *App) ForgetYaesuBandAntenna(band string) { + m := a.yaesuBandAntennas() + delete(m, strings.ToLower(strings.TrimSpace(band))) + a.saveYaesuBandAntennas(m) +} + +// YaesuApplyBandAntenna is applyYaesuBandAntenna as a binding, called from the +// same place the Flex one is — a band change or a spot click. +func (a *App) YaesuApplyBandAntenna(band string) error { + a.applyYaesuBandAntenna(band) + return nil +} + +// applyYaesuBandAntenna puts the remembered antenna back when the rig changes +// band. Silent when nothing was ever learnt for it: an operator who has not +// expressed a preference for 17 m must not have their antenna moved. +func (a *App) applyYaesuBandAntenna(band string) { + b := strings.ToLower(strings.TrimSpace(band)) + if b == "" || a.cat == nil { + return + } + want := a.yaesuBandAntennas()[b] + if want <= 0 { + return + } + _ = a.cat.YaesuDo(func(y cat.YaesuController) error { + if st := y.YaesuState(); st.Antenna == want || st.Antenna == 0 { + return nil // already there, or a rig with a single socket + } + applog.Printf("yaesu: band %s → antenna %d (remembered)", b, want) + return y.SetYaesuAntenna(want) + }) +} + func (a *App) SetYaesuAtt(db int) error { return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) }) } diff --git a/changelog.json b/changelog.json index 224bf1d..b34d508 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,20 @@ [ + { + "version": "0.26.8", + "date": "", + "en": [ + "Yaesu: an ANT row in the rig panel selects the antenna jack, and the choice is remembered for the band it was made on — change band and the antenna follows. There is no page to configure: picking the antenna once on a band says it perfectly well. Rigs with a single socket never show the row.", + "HAMLOG.online: QSOs can be uploaded as they are logged, like QRZ.com or Club Log. The API key is checked in the settings before the first contact — the answer names the account it belongs to, so a key pasted from another callsign or an expired one is caught at once instead of after a week of silent refusals.", + "HAMLOG.online is also a confirmation source for awards, alongside LoTW, QSL, eQSL and QRZ.com — tick it under Confirmed, under Validated, or both.", + "A rig whose serial port is refused now says who is likely holding it — OmniRig stays resident and keeps the port of the rig configured in it, which is what a native backend then never gets. \"Serial port busy\" alone named nobody." + ], + "fr": [ + "Yaesu : une ligne ANT dans le panneau du poste sélectionne la prise d'antenne, et le choix est mémorisé pour la bande sur laquelle il a été fait — on change de bande, l'antenne suit. Aucune page à configurer : choisir l'antenne une fois sur une bande le dit très bien. Les postes à une seule prise n'affichent jamais la ligne.", + "HAMLOG.online : les QSO peuvent être envoyés au fil de leur enregistrement, comme QRZ.com ou Club Log. La clé API se vérifie dans les réglages avant le premier contact — la réponse nomme le compte auquel elle appartient, donc une clé d'un autre indicatif ou expirée se voit tout de suite au lieu d'après une semaine de refus silencieux.", + "HAMLOG.online est aussi une source de confirmation pour les diplômes, aux côtés de LoTW, QSL, eQSL et QRZ.com — à cocher dans Confirmé, dans Validé, ou les deux.", + "Un poste dont le port série est refusé indique maintenant qui le détient vraisemblablement — OmniRig reste résident et garde le port de la radio configurée chez lui, que le backend natif n'obtient alors jamais. « Serial port busy » ne désignait personne." + ] + }, { "version": "0.26.7", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bcd71ec..037fabd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,7 +18,7 @@ import { WorkedBefore, SetCompactMode, SetCompactHeight, RotatorGoToPath, - GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, + GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, YaesuApplyBandAntenna, GetSecretStatus, UnlockSecrets, RotatorGoTo, RotatorStop, SetActiveRotor, GetDBConnectionInfo, GetLogbookRevision, @@ -3297,11 +3297,16 @@ export default function App() { // backends. The backend no-ops if the band has no configured mapping. const lastAntBandRef = useRef(''); useEffect(() => { - if (catState.backend !== 'flex' || locks.band) return; + if (locks.band) return; const b = band.trim(); if (!b || b === lastAntBandRef.current) return; lastAntBandRef.current = b; - FlexApplyBandAntenna(b).catch(() => {}); + // Flex configures its antennas per band in Preferences; a Yaesu LEARNS + // them — the antenna picked on a band is remembered for that band. Two + // ways to say the same thing, and each backend keeps the one that suits + // it: a Flex names its antennas, a Yaesu has two sockets and a knob. + if (catState.backend === 'flex') FlexApplyBandAntenna(b).catch(() => {}); + if (catState.backend === 'yaesu') YaesuApplyBandAntenna(b).catch(() => {}); }, [band, catState.backend, locks.band]); // Per-band, per-mode TX power. Applied on band AND mode changes — the point is diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index c8de09a..1a57869 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -68,7 +68,8 @@ const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN']; const NO_FIELD = '__none__'; const CONFIRM_SRC = [ { id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }, - { id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' }, + { id: 'qrzcom', label: 'QRZ.com' }, { id: 'hamlog', label: 'HAMLOG.online' }, + { id: 'custom', label: 'Custom' }, ]; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm']; const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE']; diff --git a/frontend/src/components/YaesuPanel.tsx b/frontend/src/components/YaesuPanel.tsx index 2506c0b..d6022b5 100644 --- a/frontend/src/components/YaesuPanel.tsx +++ b/frontend/src/components/YaesuPanel.tsx @@ -3,7 +3,7 @@ import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lu import { GetYaesuState, RefreshYaesuPanel, SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch, - SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel, + SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuAntenna, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel, SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU, SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState, } from '../../wailsjs/go/main/App'; @@ -17,7 +17,7 @@ type YaesuState = { transmitting: boolean; split: boolean; s_meter: number; power_meter: number; swr_meter: number; rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number; - agc?: string; preamp: number; att: number; + agc?: string; preamp: number; att: number; antenna: number; nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean; split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number; }; @@ -26,7 +26,7 @@ const ZERO: YaesuState = { available: false, transmitting: false, split: false, s_meter: 0, power_meter: 0, swr_meter: 0, rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0, - preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false, + preamp: 0, att: 0, antenna: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false, }; // Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we @@ -74,6 +74,10 @@ function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null { // IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and // AMP2 add gain. Presenting it as a toggle would hide the middle position. const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }]; +// Three jacks is the most any of these rigs has (FTDX101); an FTDX10 answers +// with 1 or 2 and the third button simply never gets used. A rig with a single +// socket answers nothing at all and reports 0, and the row is not drawn. +const ANTENNAS = [{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }, { v: '3', l: 'ANT3' }]; const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }]; // The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle. const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]; @@ -424,6 +428,16 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: { push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} /> + {/* Only on a rig that HAS a choice: 0 means the AN command went + unanswered, which is what a single-socket FT-891 does. And the + choice is remembered for the band it was made on — no table to + fill in anywhere, the backend learns it here. */} + {view.antenna > 0 && ( + + push('antenna', parseInt(v, 10), () => SetYaesuAntenna(parseInt(v, 10)))} /> + + )} {/* Noise + filter */} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8743c5a..ffd50c2 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -371,6 +371,8 @@ export function FlexTune(arg1:boolean):Promise; export function FlexZoomForSpot(arg1:string,arg2:number):Promise; +export function ForgetYaesuBandAntenna(arg1:string):Promise; + export function GetACOMStatus():Promise; export function GetADIFMonitor():Promise; @@ -585,6 +587,8 @@ export function GetWinkeyerStatus():Promise; export function GetWorkedCallVariants():Promise; +export function GetYaesuBandAntennas():Promise>; + export function GetYaesuState():Promise; export function GridSquares(arg1:string):Promise>; @@ -1111,6 +1115,8 @@ export function SetYaesuAFGain(arg1:number):Promise; export function SetYaesuAGC(arg1:string):Promise; +export function SetYaesuAntenna(arg1:number):Promise; + export function SetYaesuAtt(arg1:number):Promise; export function SetYaesuBand(arg1:string):Promise; @@ -1239,6 +1245,8 @@ export function WinkeyerTraceEnabled():Promise; export function WorkedBefore(arg1:string,arg2:number):Promise; +export function YaesuApplyBandAntenna(arg1:string):Promise; + export function YaesuSendCW(arg1:string):Promise; export function YaesuStopCW():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 9626250..46d14bd 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -682,6 +682,10 @@ export function FlexZoomForSpot(arg1, arg2) { return window['go']['main']['App']['FlexZoomForSpot'](arg1, arg2); } +export function ForgetYaesuBandAntenna(arg1) { + return window['go']['main']['App']['ForgetYaesuBandAntenna'](arg1); +} + export function GetACOMStatus() { return window['go']['main']['App']['GetACOMStatus'](); } @@ -1110,6 +1114,10 @@ export function GetWorkedCallVariants() { return window['go']['main']['App']['GetWorkedCallVariants'](); } +export function GetYaesuBandAntennas() { + return window['go']['main']['App']['GetYaesuBandAntennas'](); +} + export function GetYaesuState() { return window['go']['main']['App']['GetYaesuState'](); } @@ -2162,6 +2170,10 @@ export function SetYaesuAGC(arg1) { return window['go']['main']['App']['SetYaesuAGC'](arg1); } +export function SetYaesuAntenna(arg1) { + return window['go']['main']['App']['SetYaesuAntenna'](arg1); +} + export function SetYaesuAtt(arg1) { return window['go']['main']['App']['SetYaesuAtt'](arg1); } @@ -2418,6 +2430,10 @@ export function WorkedBefore(arg1, arg2) { return window['go']['main']['App']['WorkedBefore'](arg1, arg2); } +export function YaesuApplyBandAntenna(arg1) { + return window['go']['main']['App']['YaesuApplyBandAntenna'](arg1); +} + export function YaesuSendCW(arg1) { return window['go']['main']['App']['YaesuSendCW'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ac89b8e..9b2e76d 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1160,6 +1160,7 @@ export namespace cat { squelch: number; agc?: string; preamp: number; + antenna: number; att: number; nb: boolean; nr: boolean; @@ -1194,6 +1195,7 @@ export namespace cat { this.squelch = source["squelch"]; this.agc = source["agc"]; this.preamp = source["preamp"]; + this.antenna = source["antenna"]; this.att = source["att"]; this.nb = source["nb"]; this.nr = source["nr"]; @@ -1367,6 +1369,7 @@ export namespace extsvc { hrdlog: ServiceConfig; eqsl: ServiceConfig; cloudlog: ServiceConfig; + hamlog: ServiceConfig; delete_remote: boolean; static createFrom(source: any = {}) { @@ -1381,6 +1384,7 @@ export namespace extsvc { this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); + this.hamlog = this.convertValues(source["hamlog"], ServiceConfig); this.delete_remote = source["delete_remote"]; } diff --git a/internal/award/award.go b/internal/award/award.go index e98befb..aec4ac0 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -109,7 +109,7 @@ type Def struct { Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all) // --- Confirmation --- - Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom + Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|hamlog|custom Validate []string `json:"validate,omitempty"` // validated/granted sources // The "custom" source, for confirmations OpsLog has no dedicated column for: // ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the @@ -1481,6 +1481,18 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool { if isYes(q.QRZComDownloadStatus) { return true } + case "hamlog": + // HAMLOG.online, a first-class source rather than something to express + // through "custom" — its confirmations feed its own award programme + // and operators reach for it the same way they reach for LoTW. + // + // Read from the ADIF EXTRAS, not a column: the ADIF standard names a + // field for hamlog.EU (HAMLOGEU_QSO_UPLOAD_STATUS) and none for + // hamlog.ONLINE, and borrowing the other site's field would write a + // falsehood into every exported log. + if hamlogConfirmed(q) { + return true + } case "custom": if customConfirmed(q, d) { return true @@ -1490,6 +1502,41 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool { return false } +// HamlogQSLKey is where a HAMLOG.online confirmation is recorded, as an ADIF +// extras key. Exported and re-imported like any other extra, so the state +// survives a move to another logger and back. +const HamlogQSLKey = "APP_OPSLOG_HAMLOG_QSL" + +// hamlogAltKeys are the shapes an ADIF exported BY hamlog.online might use. +// +// Their site publishes no field name, so rather than demand that an operator +// rename a column by hand after every export, the ones an export could +// plausibly carry are accepted too. Costs three map lookups; saves a support +// thread that would end in "edit your ADIF". +var hamlogAltKeys = []string{"APP_HAMLOG_QSL", "APP_HAMLOGONLINE_QSL", "HAMLOG_QSL_RCVD"} + +// hamlogConfirmed reports whether a QSO carries a HAMLOG.online confirmation. +// +// Any non-empty value counts, except an explicit "N": their export could carry +// a date, a Y, or a match id, and demanding one of them would silently confirm +// nothing on the two shapes we guessed wrong. +func hamlogConfirmed(q *qso.QSO) bool { + if q == nil || q.Extras == nil { + return false + } + for _, k := range append([]string{HamlogQSLKey}, hamlogAltKeys...) { + v := strings.TrimSpace(q.Extras[k]) + if v == "" { + continue + } + if strings.EqualFold(v, "N") || strings.EqualFold(v, "NO") { + continue + } + return true + } + return false +} + // customConfirmed answers the operator-defined confirmation source: the field // named by ConfirmField, optionally required to hold one of ConfirmValue's // comma-separated values. diff --git a/internal/award/confirm_test.go b/internal/award/confirm_test.go index 2719612..86b5696 100644 --- a/internal/award/confirm_test.go +++ b/internal/award/confirm_test.go @@ -64,3 +64,34 @@ func TestConfirmedSources(t *testing.T) { } } } + +// HAMLOG.online is a named confirmation source, not something to express through +// "custom": an award ticks it the way it ticks LoTW. +func TestHamlogConfirmationSource(t *testing.T) { + def := Def{Confirm: []string{"hamlog"}, Validate: []string{"hamlog"}} + yes := func(extras map[string]string) bool { + return Confirmed(&qso.QSO{Extras: extras}, def, def.Confirm) + } + if !yes(map[string]string{HamlogQSLKey: "Y"}) { + t.Error("our own key did not confirm") + } + // Their export's field name is unpublished, so the plausible shapes count too. + if !yes(map[string]string{"APP_HAMLOG_QSL": "20260823"}) { + t.Error("a date in an alternative key did not confirm") + } + // A value is not a confirmation when it says no. + if yes(map[string]string{HamlogQSLKey: "N"}) { + t.Error(`"N" was taken as a confirmation`) + } + if yes(map[string]string{HamlogQSLKey: " "}) { + t.Error("blank was taken as a confirmation") + } + if yes(nil) || yes(map[string]string{"SOMETHING_ELSE": "Y"}) { + t.Error("confirmed with nothing to confirm it") + } + // And it does not leak into an award that never asked for it. + other := Def{Confirm: []string{"lotw"}} + if Confirmed(&qso.QSO{Extras: map[string]string{HamlogQSLKey: "Y"}}, other, other.Confirm) { + t.Error("a hamlog confirmation counted for an award that only accepts LoTW") + } +} diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 68cb2d6..d44a604 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -189,7 +189,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I } p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) if err != nil { - return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err) + return nil, fmt.Errorf("open %s @ %d baud: %w%s", b.portName, b.baud, err, busyHint(b.portName, err)) } return p, nil } diff --git a/internal/cat/kenwood.go b/internal/cat/kenwood.go index 6d9642b..08aad23 100644 --- a/internal/cat/kenwood.go +++ b/internal/cat/kenwood.go @@ -210,7 +210,7 @@ func (k *Kenwood) Connect() error { if k.host != "" { return fmt.Errorf("kenwood: connect %s: %w", k.host, err) } - return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err) + return fmt.Errorf("kenwood: open %s @ %d baud: %w%s", k.portName, k.baud, err, busyHint(k.portName, err)) } p.SetReadTimeout(300 * time.Millisecond) k.port = p diff --git a/internal/cat/portbusy.go b/internal/cat/portbusy.go new file mode 100644 index 0000000..0c2f50a --- /dev/null +++ b/internal/cat/portbusy.go @@ -0,0 +1,39 @@ +package cat + +// "Serial port busy" is a true statement that helps nobody. +// +// A COM port has exactly one owner, and when a rig's port is refused the owner +// is almost always another program on the same desktop — most often OmniRig, +// which stays resident once any application has activated it and keeps the port +// of the rig configured in it. An operator switching from OmniRig to a native +// backend therefore hits this the moment they save: OpsLog is now asking for a +// port OmniRig never let go of. The message named none of that. +// +// It also happens the other way round, and the wording says so rather than +// accusing: a digital application (WSJT-X, JTDX, MSHV) configured on the rig's +// port directly, rather than through OpsLog's CAT sharing, holds it just as +// firmly. + +import "strings" + +// busyHint returns a sentence to append to an open error, or "" when the error +// is not about the port being taken. +// +// Deliberately not a wrapped error: this is advice for a human reading a log, +// and it must not change how any caller compares the error. +func busyHint(port string, err error) string { + if err == nil { + return "" + } + msg := strings.ToLower(err.Error()) + busy := strings.Contains(msg, "busy") || + strings.Contains(msg, "access is denied") || + strings.Contains(msg, "denied") || + strings.Contains(msg, "in use") + if !busy { + return "" + } + return " — another program holds " + port + + ". OmniRig is the usual one (it stays running and keeps the port of the rig configured in it, so a native backend can never have it);" + + " a digital application pointed straight at the rig instead of at OpsLog's CAT sharing does the same. Close it, then reconnect." +} diff --git a/internal/cat/xiegu.go b/internal/cat/xiegu.go index 3b69f4c..3c39365 100644 --- a/internal/cat/xiegu.go +++ b/internal/cat/xiegu.go @@ -95,7 +95,7 @@ func (x *Xiegu) Connect() error { } p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud}) if err != nil { - return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err) + return fmt.Errorf("xiegu: open %s @ %d baud: %w%s", x.portName, x.baud, err, busyHint(x.portName, err)) } p.SetReadTimeout(200 * time.Millisecond) // Deassert DTR and RTS. diff --git a/internal/cat/yaesu.go b/internal/cat/yaesu.go index 0d8b4ba..9b72955 100644 --- a/internal/cat/yaesu.go +++ b/internal/cat/yaesu.go @@ -159,7 +159,7 @@ func (y *Yaesu) Connect() error { } p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud}) if err != nil { - return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err) + return fmt.Errorf("yaesu: open %s @ %d baud: %w%s", y.portName, y.baud, err, busyHint(y.portName, err)) } // The modem lines are only touched when the operator asks for it — see the // note on Kenwood.lowerLines. Both defaults break somebody's station. diff --git a/internal/cat/yaesu_panel.go b/internal/cat/yaesu_panel.go index 5c8d496..2969039 100644 --- a/internal/cat/yaesu_panel.go +++ b/internal/cat/yaesu_panel.go @@ -50,11 +50,15 @@ type YaesuTXState struct { Squelch int `json:"squelch"` // 0-100 AGC string `json:"agc,omitempty"` Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2 - Att int `json:"att"` // 0=off, else dB - NB bool `json:"nb"` - NR bool `json:"nr"` - NRLevel int `json:"nr_level"` // 1-15 - Narrow bool `json:"narrow"` // NAR filter + // Antenna is the selected jack, 1-3, or 0 when the rig has no AN command — + // an FT-891 or FT-991A has a single socket and answers nothing. 0 is what + // tells the panel to draw no selector at all rather than a dead one. + Antenna int `json:"antenna"` + Att int `json:"att"` // 0=off, else dB + NB bool `json:"nb"` + NR bool `json:"nr"` + NRLevel int `json:"nr_level"` // 1-15 + Narrow bool `json:"narrow"` // NAR filter // SWR is the RATIO (1.0, 1.5…), computed from the reflection coefficient — // what an operator reads on the rig, not a percentage of meter travel. SWR float64 `json:"swr"` @@ -80,6 +84,7 @@ type YaesuController interface { SetYaesuSquelch(int) error SetYaesuAGC(string) error SetYaesuPreamp(int) error + SetYaesuAntenna(int) error SetYaesuAtt(int) error SetYaesuNB(bool) error SetYaesuNR(bool) error @@ -239,6 +244,24 @@ func (y *Yaesu) readPanelSettings() { if v, ok := y.askNum("PA0;", "PA0", 1); ok { y.panel.Preamp = v } + // ANTENNA. "AN0;" → "AN01;" — the first digit is the receiver (0 = main, + // 1 = sub on an FTDX101), the second the jack. A rig with one socket does + // not implement it and simply says nothing, which askNum reports as + // not-ok — and 0 then means "no antenna switching here". + if v, ok := y.askNum("AN0;", "AN0", 1); ok { + if y.panel.Antenna == 0 { + debugLog.Printf("yaesu: antenna select available (AN0; → %d)", v) + } + y.panel.Antenna = v + } else { + // Said once, at the first read: an operator who expects the ANT row and + // does not get it should find the reason in the log rather than wonder + // whether OpsLog forgot the feature. + if y.panel.Antenna != -1 { + debugLog.Printf("yaesu: no answer to AN0; — this rig has no antenna selection, the ANT row stays hidden") + } + y.panel.Antenna = -1 + } if v, ok := y.askNum("RA0;", "RA0", 1); ok { y.panel.Att = yaesuAttDB(v) } @@ -356,6 +379,17 @@ func (y *Yaesu) SetYaesuPreamp(n int) error { return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2))) } +// SetYaesuAntenna selects an antenna jack (1-3) on the main receiver. +// +// Verified against the FTDX10 command set; the FTDX101 adds a third jack and a +// sub receiver, which is the "0" in AN0 — the day someone drives a sub receiver +// from here it becomes a parameter rather than a constant. A rig without the +// command ignores it, and readPanelSettings then keeps Antenna at 0, so the +// control never appears in the first place. +func (y *Yaesu) SetYaesuAntenna(n int) error { + return y.setAndRefresh(fmt.Sprintf("AN0%d;", clampInt(n, 1, 3))) +} + func (y *Yaesu) SetYaesuAtt(db int) error { return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db))) } diff --git a/internal/cat/yaesu_test.go b/internal/cat/yaesu_test.go index 3cfff4e..f0099a3 100644 --- a/internal/cat/yaesu_test.go +++ b/internal/cat/yaesu_test.go @@ -1,6 +1,9 @@ package cat -import "testing" +import ( + "fmt" + "testing" +) func TestParseYaesuFreq(t *testing.T) { cases := []struct { @@ -380,3 +383,26 @@ func TestYaesuModeVFOSuffix(t *testing.T) { t.Errorf("setting CW on main sends %q, want MD03;", cmd) } } + +// The antenna command, as the FTDX10 reference gives it: AN + receiver + jack. +// The jack is clamped rather than trusted — a panel bug that sent AN09 would be +// answered by the rig with silence, and the operator would be left wondering +// which antenna they were on. +func TestYaesuAntennaCommand(t *testing.T) { + for _, tc := range []struct { + in int + want string + }{ + {1, "AN01;"}, + {2, "AN02;"}, + {3, "AN03;"}, + {0, "AN01;"}, // below range → the first jack + {9, "AN03;"}, // above range → the last + {-1, "AN01;"}, + } { + got := fmt.Sprintf("AN0%d;", clampInt(tc.in, 1, 3)) + if got != tc.want { + t.Errorf("antenna %d → %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index c0c2a64..77fd60e 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -36,6 +36,8 @@ const ( // ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract, // only the instance URL differs, so one service handles both. ServiceCloudlog Service = "cloudlog" + // ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO. + ServiceHamlog Service = "hamlog" ) // UploadMode selects when an auto-upload fires after a QSO is saved. @@ -130,6 +132,7 @@ type ExternalServices struct { HRDLog ServiceConfig `json:"hrdlog"` EQSL ServiceConfig `json:"eqsl"` Cloudlog ServiceConfig `json:"cloudlog"` + Hamlog ServiceConfig `json:"hamlog"` // DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when // it is deleted locally. Off unless the operator turns it on: neither diff --git a/internal/extsvc/hamlog.go b/internal/extsvc/hamlog.go new file mode 100644 index 0000000..3eade93 --- /dev/null +++ b/internal/extsvc/hamlog.go @@ -0,0 +1,149 @@ +package extsvc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// HAMLOG.online — a cloud logbook whose confirmations feed its own award +// programme, so operators want their contacts there as they make them. +// +// # Where this protocol comes from +// +// HAMLOG publishes no API documentation. What follows is read from THEIR OWN +// client, the HAMLOG Agent (github.com/hamlogonline/Agent, Hamlog/hamlog_api.py) +// — the authoritative source short of asking them, and the same code their own +// users run: +// +// POST https://hamlog.online/api/agent/ (JSON in, JSON out) +// +// {"KEYSTATUS": {"APIKEY": k}} → {"STATUS":"OK","CALLSIGN":…,"EXPIRES":…} +// {"ADIFADD": {"APIKEY": k, "ADIFDATA": rec}} → {"STATUS":"OK"} +// {"QSOADD": {"APIKEY": k, "DATA": {…}}} → field map, keys upper-cased +// {"LOGOUT": {"APIKEY": k}} +// +// A failure answers {"ERROR": "…"} with no STATUS, so success is "STATUS is +// exactly OK" rather than "no error field" — an unknown reply shape must not +// read as an accepted QSO. +// +// ADIFADD is the verb used here: OpsLog already builds a full ADIF record for +// every other service, and sending the same bytes keeps one representation of +// a contact instead of two. +// +// The operator gets their key from https://hamlog.online/account/agent.php. +const ( + hamlogAPIEndpoint = "https://hamlog.online/api/agent/" + hamlogKeyPage = "https://hamlog.online/account/agent.php" +) + +// hamlogReply is the shape both success and failure share. +type hamlogReply struct { + Status string `json:"STATUS"` + Error string `json:"ERROR"` + Callsign string `json:"CALLSIGN"` + Expires any `json:"EXPIRES"` // seconds since the epoch; string or number depending on the verb +} + +// hamlogPost sends one verb and decodes the reply. +func hamlogPost(ctx context.Context, client *http.Client, endpoint string, body map[string]any) (hamlogReply, error) { + buf, err := json.Marshal(body) + if err != nil { + return hamlogReply{}, fmt.Errorf("hamlog: encode request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf)) + if err != nil { + return hamlogReply{}, fmt.Errorf("hamlog: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return hamlogReply{}, fmt.Errorf("hamlog: request failed: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + var r hamlogReply + if jerr := json.Unmarshal(raw, &r); jerr != nil { + // Not JSON at all — a proxy error page, a maintenance notice. Report what + // arrived rather than "invalid character '<'", which tells an operator + // nothing about their own setup. + msg := strings.TrimSpace(string(raw)) + if len(msg) > 200 { + msg = msg[:200] + } + if msg == "" { + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return hamlogReply{}, fmt.Errorf("hamlog: unexpected reply: %s", msg) + } + return r, nil +} + +// hamlogFailure turns a reply into a human-readable reason, or "" on success. +func hamlogFailure(r hamlogReply) string { + if strings.EqualFold(strings.TrimSpace(r.Status), "OK") { + return "" + } + if e := strings.TrimSpace(r.Error); e != "" { + return e + } + return "rejected" +} + +// UploadHamlog pushes one ADIF record to HAMLOG.online. +func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) { + return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord) +} + +func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) { + key := strings.TrimSpace(cfg.APIKey) + if key == "" { + return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage) + } + rec := strings.TrimSpace(adifRecord) + if rec == "" { + return UploadResult{}, fmt.Errorf("hamlog: empty ADIF record") + } + r, err := hamlogPost(ctx, client, endpoint, map[string]any{ + "ADIFADD": map[string]any{"APIKEY": key, "ADIFDATA": rec}, + }) + if err != nil { + return UploadResult{}, err + } + if reason := hamlogFailure(r); reason != "" { + return UploadResult{OK: false, Message: reason}, nil + } + return UploadResult{OK: true}, nil +} + +// CheckHamlogKey validates an API key and reports the callsign it belongs to. +// +// Worth its own call because HAMLOG offers what no other service here does: the +// key can be checked BEFORE the first QSO, and the answer names the account. An +// operator who pasted the key of another callsign — or one that has expired — +// finds out in the settings panel rather than through a week of silent refusals. +func CheckHamlogKey(ctx context.Context, client *http.Client, key string) (callsign string, err error) { + key = strings.TrimSpace(key) + if key == "" { + return "", fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage) + } + r, perr := hamlogPost(ctx, client, hamlogAPIEndpoint, map[string]any{ + "KEYSTATUS": map[string]any{"APIKEY": key}, + }) + if perr != nil { + return "", perr + } + if reason := hamlogFailure(r); reason != "" { + return "", fmt.Errorf("hamlog: %s", reason) + } + return strings.ToUpper(strings.TrimSpace(r.Callsign)), nil +} diff --git a/internal/extsvc/hamlog_test.go b/internal/extsvc/hamlog_test.go new file mode 100644 index 0000000..0274e0f --- /dev/null +++ b/internal/extsvc/hamlog_test.go @@ -0,0 +1,87 @@ +package extsvc + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The verb, the key and the record must arrive in the shape HAMLOG's own agent +// sends — this is read from their client, not from documentation, so the test +// pins it rather than trusting a memory of it. +func TestUploadHamlogRequestShape(t *testing.T) { + var got map[string]map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method %s, want POST", r.Method) + } + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type %q", ct) + } + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &got) + _, _ = w.Write([]byte(`{"STATUS":"OK"}`)) + })) + defer srv.Close() + + res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "F4BPO ") + if err != nil { + t.Fatal(err) + } + if !res.OK { + t.Fatalf("upload not OK: %+v", res) + } + add, ok := got["ADIFADD"] + if !ok { + t.Fatalf("no ADIFADD verb in %v", got) + } + if add["APIKEY"] != "KEY123" { + t.Errorf("APIKEY = %v", add["APIKEY"]) + } + if add["ADIFDATA"] != "F4BPO " { + t.Errorf("ADIFDATA = %v", add["ADIFDATA"]) + } +} + +// A refusal must be reported as a refusal. Their failure shape carries ERROR +// and no STATUS, so "no error field" would have read an unknown reply as an +// accepted QSO — which is how a contact goes missing without anyone noticing. +func TestHamlogFailureIsNotSuccess(t *testing.T) { + for _, tc := range []struct { + body string + wantOK bool + wantSaid string + }{ + {`{"STATUS":"OK"}`, true, ""}, + {`{"ERROR":"Invalid API key"}`, false, "Invalid API key"}, + {`{"STATUS":"FAILED"}`, false, "rejected"}, + {`{}`, false, "rejected"}, // an empty object is not an acceptance + } { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(tc.body)) + })) + res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "") + srv.Close() + if err != nil { + t.Fatalf("%s: %v", tc.body, err) + } + if res.OK != tc.wantOK { + t.Errorf("%s → OK=%v, want %v", tc.body, res.OK, tc.wantOK) + } + if !tc.wantOK && res.Message != tc.wantSaid { + t.Errorf("%s → message %q, want %q", tc.body, res.Message, tc.wantSaid) + } + } +} + +// Nothing leaves without a key, and the message says where to get one. +func TestUploadHamlogNeedsAKey(t *testing.T) { + _, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "") + if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) { + t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage) + } +} diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index ec67baa..d7e4778 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -139,6 +139,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) { cfg.HRDLog = cfg.HRDLog.normalised() cfg.EQSL = cfg.EQSL.normalised() cfg.Cloudlog = cfg.Cloudlog.normalised() + cfg.Hamlog = cfg.Hamlog.normalised() m.cfg = cfg // Summary of what is armed, written at startup and on every settings save. @@ -152,6 +153,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) { }{ {"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW}, {"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog}, + {"hamlog", cfg.Hamlog}, } { if s.cfg.AutoUpload { on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode)) @@ -227,6 +229,14 @@ func (m *Manager) OnQSOLogged(id int64) { m.route(ServiceCloudlog, id, c) } } + // HAMLOG.online — one API key and nothing else to get wrong. + if h := cfg.Hamlog; h.AutoUpload { + if h.APIKey == "" { + m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id) + } else { + m.route(ServiceHamlog, id, h) + } + } } // route sends a logged QSO down the configured timing path: queue it for the @@ -277,6 +287,9 @@ func (m *Manager) onCloseServices() []Service { if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" { out = append(out, ServiceCloudlog) } + if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" { + out = append(out, ServiceHamlog) + } return out } @@ -323,6 +336,8 @@ func (m *Manager) FlushOnClose() int { uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog) case ServiceCloudlog: uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog) + case ServiceHamlog: + uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog) } } return uploaded @@ -644,6 +659,16 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret return false, false } res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record) + case ServiceHamlog: + // The station callsign is whatever the QSO carries: HAMLOG files the + // contact under the account the API key belongs to, and KEYSTATUS is how + // the operator checks that account is the right one. + record, ok := m.deps.BuildADIF(id, "") + if !ok { + m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) + return false, false + } + res, err = UploadHamlog(ctx, m.deps.Client, cfg, record) default: return false, false }