feat: HAMLOG.online upload and confirmations, Yaesu antenna, a named port holder

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.
This commit is contained in:
2026-08-23 12:18:27 +02:00
parent 3014796c1d
commit ee148732dd
21 changed files with 603 additions and 18 deletions
+80
View File
@@ -19882,6 +19882,86 @@ func (a *App) SetYaesuPreamp(n int) error {
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPreamp(n) }) 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 { func (a *App) SetYaesuAtt(db int) error {
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) }) return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) })
} }
+16
View File
@@ -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", "version": "0.26.7",
"date": "", "date": "",
+8 -3
View File
@@ -18,7 +18,7 @@ import {
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactHeight, SetCompactMode, SetCompactHeight,
RotatorGoToPath, RotatorGoToPath,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower, YaesuApplyBandAntenna,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RotatorGoTo, RotatorStop, SetActiveRotor, RotatorGoTo, RotatorStop, SetActiveRotor,
GetDBConnectionInfo, GetLogbookRevision, GetDBConnectionInfo, GetLogbookRevision,
@@ -3297,11 +3297,16 @@ export default function App() {
// backends. The backend no-ops if the band has no configured mapping. // backends. The backend no-ops if the band has no configured mapping.
const lastAntBandRef = useRef(''); const lastAntBandRef = useRef('');
useEffect(() => { useEffect(() => {
if (catState.backend !== 'flex' || locks.band) return; if (locks.band) return;
const b = band.trim(); const b = band.trim();
if (!b || b === lastAntBandRef.current) return; if (!b || b === lastAntBandRef.current) return;
lastAntBandRef.current = b; 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]); }, [band, catState.backend, locks.band]);
// Per-band, per-mode TX power. Applied on band AND mode changes — the point is // Per-band, per-mode TX power. Applied on band AND mode changes — the point is
+2 -1
View File
@@ -68,7 +68,8 @@ const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
const NO_FIELD = '__none__'; const NO_FIELD = '__none__';
const CONFIRM_SRC = [ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }, { 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 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']; const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
+17 -3
View File
@@ -3,7 +3,7 @@ import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lu
import { import {
GetYaesuState, RefreshYaesuPanel, GetYaesuState, RefreshYaesuPanel,
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch, SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel, SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuAntenna, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU, SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState, SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -17,7 +17,7 @@ type YaesuState = {
transmitting: boolean; split: boolean; transmitting: boolean; split: boolean;
s_meter: number; power_meter: number; swr_meter: number; s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: 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; 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; 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, available: false, transmitting: false, split: false,
s_meter: 0, power_meter: 0, swr_meter: 0, s_meter: 0, power_meter: 0, swr_meter: 0,
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 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 // 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 // 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. // 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' }]; 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' }]; 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. // 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' }]; 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 }: {
<Row label="ATT"> <Row label="ATT">
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} /> <Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
</Row> </Row>
{/* 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 && (
<Row label="ANT">
<Segmented value={String(view.antenna)} options={ANTENNAS}
onChange={(v) => push('antenna', parseInt(v, 10), () => SetYaesuAntenna(parseInt(v, 10)))} />
</Row>
)}
</Card> </Card>
{/* Noise + filter */} {/* Noise + filter */}
+8
View File
@@ -371,6 +371,8 @@ export function FlexTune(arg1:boolean):Promise<void>;
export function FlexZoomForSpot(arg1:string,arg2:number):Promise<void>; export function FlexZoomForSpot(arg1:string,arg2:number):Promise<void>;
export function ForgetYaesuBandAntenna(arg1:string):Promise<void>;
export function GetACOMStatus():Promise<acom.Status>; export function GetACOMStatus():Promise<acom.Status>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>; export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
@@ -585,6 +587,8 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetWorkedCallVariants():Promise<boolean>; export function GetWorkedCallVariants():Promise<boolean>;
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>; export function GetYaesuState():Promise<cat.YaesuTXState>;
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>; export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
@@ -1111,6 +1115,8 @@ export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>; export function SetYaesuAGC(arg1:string):Promise<void>;
export function SetYaesuAntenna(arg1:number):Promise<void>;
export function SetYaesuAtt(arg1:number):Promise<void>; export function SetYaesuAtt(arg1:number):Promise<void>;
export function SetYaesuBand(arg1:string):Promise<void>; export function SetYaesuBand(arg1:string):Promise<void>;
@@ -1239,6 +1245,8 @@ export function WinkeyerTraceEnabled():Promise<boolean>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>; export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
export function YaesuApplyBandAntenna(arg1:string):Promise<void>;
export function YaesuSendCW(arg1:string):Promise<void>; export function YaesuSendCW(arg1:string):Promise<void>;
export function YaesuStopCW():Promise<void>; export function YaesuStopCW():Promise<void>;
+16
View File
@@ -682,6 +682,10 @@ export function FlexZoomForSpot(arg1, arg2) {
return window['go']['main']['App']['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() { export function GetACOMStatus() {
return window['go']['main']['App']['GetACOMStatus'](); return window['go']['main']['App']['GetACOMStatus']();
} }
@@ -1110,6 +1114,10 @@ export function GetWorkedCallVariants() {
return window['go']['main']['App']['GetWorkedCallVariants'](); return window['go']['main']['App']['GetWorkedCallVariants']();
} }
export function GetYaesuBandAntennas() {
return window['go']['main']['App']['GetYaesuBandAntennas']();
}
export function GetYaesuState() { export function GetYaesuState() {
return window['go']['main']['App']['GetYaesuState'](); return window['go']['main']['App']['GetYaesuState']();
} }
@@ -2162,6 +2170,10 @@ export function SetYaesuAGC(arg1) {
return window['go']['main']['App']['SetYaesuAGC'](arg1); return window['go']['main']['App']['SetYaesuAGC'](arg1);
} }
export function SetYaesuAntenna(arg1) {
return window['go']['main']['App']['SetYaesuAntenna'](arg1);
}
export function SetYaesuAtt(arg1) { export function SetYaesuAtt(arg1) {
return window['go']['main']['App']['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); return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
} }
export function YaesuApplyBandAntenna(arg1) {
return window['go']['main']['App']['YaesuApplyBandAntenna'](arg1);
}
export function YaesuSendCW(arg1) { export function YaesuSendCW(arg1) {
return window['go']['main']['App']['YaesuSendCW'](arg1); return window['go']['main']['App']['YaesuSendCW'](arg1);
} }
+4
View File
@@ -1160,6 +1160,7 @@ export namespace cat {
squelch: number; squelch: number;
agc?: string; agc?: string;
preamp: number; preamp: number;
antenna: number;
att: number; att: number;
nb: boolean; nb: boolean;
nr: boolean; nr: boolean;
@@ -1194,6 +1195,7 @@ export namespace cat {
this.squelch = source["squelch"]; this.squelch = source["squelch"];
this.agc = source["agc"]; this.agc = source["agc"];
this.preamp = source["preamp"]; this.preamp = source["preamp"];
this.antenna = source["antenna"];
this.att = source["att"]; this.att = source["att"];
this.nb = source["nb"]; this.nb = source["nb"];
this.nr = source["nr"]; this.nr = source["nr"];
@@ -1367,6 +1369,7 @@ export namespace extsvc {
hrdlog: ServiceConfig; hrdlog: ServiceConfig;
eqsl: ServiceConfig; eqsl: ServiceConfig;
cloudlog: ServiceConfig; cloudlog: ServiceConfig;
hamlog: ServiceConfig;
delete_remote: boolean; delete_remote: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1381,6 +1384,7 @@ export namespace extsvc {
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig); this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
this.delete_remote = source["delete_remote"]; this.delete_remote = source["delete_remote"];
} }
+48 -1
View File
@@ -109,7 +109,7 @@ type Def struct {
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all) Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
// --- Confirmation --- // --- 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 Validate []string `json:"validate,omitempty"` // validated/granted sources
// The "custom" source, for confirmations OpsLog has no dedicated column for: // The "custom" source, for confirmations OpsLog has no dedicated column for:
// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the // 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) { if isYes(q.QRZComDownloadStatus) {
return true 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": case "custom":
if customConfirmed(q, d) { if customConfirmed(q, d) {
return true return true
@@ -1490,6 +1502,41 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool {
return false 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 // customConfirmed answers the operator-defined confirmation source: the field
// named by ConfirmField, optionally required to hold one of ConfirmValue's // named by ConfirmField, optionally required to hold one of ConfirmValue's
// comma-separated values. // comma-separated values.
+31
View File
@@ -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")
}
}
+1 -1
View File
@@ -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}) p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
if err != nil { 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 return p, nil
} }
+1 -1
View File
@@ -210,7 +210,7 @@ func (k *Kenwood) Connect() error {
if k.host != "" { if k.host != "" {
return fmt.Errorf("kenwood: connect %s: %w", k.host, err) 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) p.SetReadTimeout(300 * time.Millisecond)
k.port = p k.port = p
+39
View File
@@ -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."
}
+1 -1
View File
@@ -95,7 +95,7 @@ func (x *Xiegu) Connect() error {
} }
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud}) p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
if err != nil { 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) p.SetReadTimeout(200 * time.Millisecond)
// Deassert DTR and RTS. // Deassert DTR and RTS.
+1 -1
View File
@@ -159,7 +159,7 @@ func (y *Yaesu) Connect() error {
} }
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud}) p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
if err != nil { 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 // The modem lines are only touched when the operator asks for it — see the
// note on Kenwood.lowerLines. Both defaults break somebody's station. // note on Kenwood.lowerLines. Both defaults break somebody's station.
+34
View File
@@ -50,6 +50,10 @@ type YaesuTXState struct {
Squelch int `json:"squelch"` // 0-100 Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"` AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2 Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
// 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 Att int `json:"att"` // 0=off, else dB
NB bool `json:"nb"` NB bool `json:"nb"`
NR bool `json:"nr"` NR bool `json:"nr"`
@@ -80,6 +84,7 @@ type YaesuController interface {
SetYaesuSquelch(int) error SetYaesuSquelch(int) error
SetYaesuAGC(string) error SetYaesuAGC(string) error
SetYaesuPreamp(int) error SetYaesuPreamp(int) error
SetYaesuAntenna(int) error
SetYaesuAtt(int) error SetYaesuAtt(int) error
SetYaesuNB(bool) error SetYaesuNB(bool) error
SetYaesuNR(bool) error SetYaesuNR(bool) error
@@ -239,6 +244,24 @@ func (y *Yaesu) readPanelSettings() {
if v, ok := y.askNum("PA0;", "PA0", 1); ok { if v, ok := y.askNum("PA0;", "PA0", 1); ok {
y.panel.Preamp = v 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 { if v, ok := y.askNum("RA0;", "RA0", 1); ok {
y.panel.Att = yaesuAttDB(v) 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))) 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 { func (y *Yaesu) SetYaesuAtt(db int) error {
return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db))) return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
} }
+27 -1
View File
@@ -1,6 +1,9 @@
package cat package cat
import "testing" import (
"fmt"
"testing"
)
func TestParseYaesuFreq(t *testing.T) { func TestParseYaesuFreq(t *testing.T) {
cases := []struct { cases := []struct {
@@ -380,3 +383,26 @@ func TestYaesuModeVFOSuffix(t *testing.T) {
t.Errorf("setting CW on main sends %q, want MD03;", cmd) 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)
}
}
}
+3
View File
@@ -36,6 +36,8 @@ const (
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract, // ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
// only the instance URL differs, so one service handles both. // only the instance URL differs, so one service handles both.
ServiceCloudlog Service = "cloudlog" 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. // UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -130,6 +132,7 @@ type ExternalServices struct {
HRDLog ServiceConfig `json:"hrdlog"` HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"` EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"` Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when // 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 // it is deleted locally. Off unless the operator turns it on: neither
+149
View File
@@ -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
}
+87
View File
@@ -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"}, "<call:5>F4BPO <eor>")
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"] != "<call:5>F4BPO <eor>" {
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"}, "<eor>")
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{}, "<eor>")
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
}
}
+25
View File
@@ -139,6 +139,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.HRDLog = cfg.HRDLog.normalised() cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised() cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised() cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised()
m.cfg = cfg m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save. // 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}, {"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog}, {"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
{"hamlog", cfg.Hamlog},
} { } {
if s.cfg.AutoUpload { if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode)) 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) 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 // 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 != "" { if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog) out = append(out, ServiceCloudlog)
} }
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog)
}
return out return out
} }
@@ -323,6 +336,8 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog) uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog: case ServiceCloudlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog) uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
} }
} }
return uploaded return uploaded
@@ -644,6 +659,16 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false return false, false
} }
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record) 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: default:
return false, false return false, false
} }