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:
@@ -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) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Vendored
+8
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ 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
|
||||||
Att int `json:"att"` // 0=off, else dB
|
// Antenna is the selected jack, 1-3, or 0 when the rig has no AN command —
|
||||||
NB bool `json:"nb"`
|
// an FT-891 or FT-991A has a single socket and answers nothing. 0 is what
|
||||||
NR bool `json:"nr"`
|
// tells the panel to draw no selector at all rather than a dead one.
|
||||||
NRLevel int `json:"nr_level"` // 1-15
|
Antenna int `json:"antenna"`
|
||||||
Narrow bool `json:"narrow"` // NAR filter
|
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 —
|
// 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.
|
// what an operator reads on the rig, not a percentage of meter travel.
|
||||||
SWR float64 `json:"swr"`
|
SWR float64 `json:"swr"`
|
||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user