Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b59954ce0 | ||
|
|
70ada49776 | ||
|
|
e20f32c918 | ||
|
|
71adbfd8ff |
@@ -789,6 +789,11 @@ type App struct {
|
|||||||
acLastJudge time.Time
|
acLastJudge time.Time
|
||||||
// acTXPeriod is the last transmit period already counted — see autoCallNoteTX.
|
// acTXPeriod is the last transmit period already counted — see autoCallNoteTX.
|
||||||
acTXPeriod string
|
acTXPeriod string
|
||||||
|
// acJudged is the last period key each receiver has been judged on, and
|
||||||
|
// acDirty says decodes have arrived for it since. Together they let a period
|
||||||
|
// be judged AGAIN when a straggler turns up, with the whole period in hand.
|
||||||
|
acJudged map[string]string
|
||||||
|
acDirty map[string]bool
|
||||||
// What the decodes panel is SHOWING, and whether it is publishing at all.
|
// What the decodes panel is SHOWING, and whether it is publishing at all.
|
||||||
// The panel owns the filters; this is its answer, not a second copy of them.
|
// The panel owns the filters; this is its answer, not a second copy of them.
|
||||||
acVisible map[string]bool
|
acVisible map[string]bool
|
||||||
@@ -3149,7 +3154,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
// a few lines above and is not on the copy taken at insert time.
|
// a few lines above and is not on the copy taken at insert time.
|
||||||
a.syncPublishAsync(syncfolder.OpAdd, id, nil)
|
a.syncPublishAsync(syncfolder.OpAdd, id, nil)
|
||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
rec := adif.SingleRecordADIF(qc)
|
rec := adif.ForwardRecordADIF(qc)
|
||||||
a.udp.EmitLoggedADIF(rec)
|
a.udp.EmitLoggedADIF(rec)
|
||||||
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
|
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
|
||||||
a.udpTriggerQSOLogged(qc)
|
a.udpTriggerQSOLogged(qc)
|
||||||
@@ -8470,7 +8475,11 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.IcomBaud <= 0 {
|
if s.IcomBaud <= 0 {
|
||||||
s.IcomBaud = 115200
|
s.IcomBaud = 115200
|
||||||
}
|
}
|
||||||
if s.IcomAddr <= 0 || s.IcomAddr > 0xFF {
|
// 0x00 IS an address. Rejecting it as "unset" is what sent an operator's
|
||||||
|
// custom-address rig straight back to the IC-7610's 0x98 every time the
|
||||||
|
// settings were saved — the model dropdown snapping back with it, since it
|
||||||
|
// reads the address rather than a stored model.
|
||||||
|
if s.IcomAddr < 0 || s.IcomAddr > 0xFF {
|
||||||
s.IcomAddr = 0x98
|
s.IcomAddr = 0x98
|
||||||
}
|
}
|
||||||
if s.PollMs < 50 || s.PollMs > 2000 {
|
if s.PollMs < 50 || s.PollMs > 2000 {
|
||||||
@@ -11811,10 +11820,58 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
|||||||
return fmt.Errorf("unknown service %q", service)
|
return fmt.Errorf("unknown service %q", service)
|
||||||
}
|
}
|
||||||
cfg := a.loadExternalServices()
|
cfg := a.loadExternalServices()
|
||||||
|
// NOT CONFIGURED IS AN ANSWER, AND IT BELONGS HERE.
|
||||||
|
//
|
||||||
|
// The upload runs on its own goroutine and reports through the QSL Manager's
|
||||||
|
// console, which is not open when the command was given from a right-click in
|
||||||
|
// the QSO list: an upload to a service with no credentials looked exactly
|
||||||
|
// like one that worked. Refused synchronously instead, where the caller can
|
||||||
|
// show it.
|
||||||
|
if err := uploadConfigured(svc, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
go a.runManualUpload(svc, ids, cfg)
|
go a.runManualUpload(svc, ids, cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadConfigured reports whether a service has what it needs to be uploaded
|
||||||
|
// to at all — the credentials it cannot work without, not a guarantee they are
|
||||||
|
// correct. The service says whether they are; this says whether to ask.
|
||||||
|
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
|
||||||
|
has := func(v string) bool { return strings.TrimSpace(v) != "" }
|
||||||
|
switch svc {
|
||||||
|
case extsvc.ServiceCloudlog:
|
||||||
|
if !has(cfg.Cloudlog.URL) || !has(cfg.Cloudlog.APIKey) {
|
||||||
|
return fmt.Errorf("Cloudlog / Wavelog is not configured — set its URL and API key in Settings → External services")
|
||||||
|
}
|
||||||
|
case extsvc.ServiceQRZ:
|
||||||
|
if !has(cfg.QRZ.APIKey) {
|
||||||
|
return fmt.Errorf("QRZ.com is not configured — set the logbook API key in Settings → External services")
|
||||||
|
}
|
||||||
|
case extsvc.ServiceClublog:
|
||||||
|
if !has(cfg.Clublog.Email) || !has(cfg.Clublog.Password) || !has(cfg.Clublog.APIKey) {
|
||||||
|
return fmt.Errorf("Club Log is not configured — set the account email, password and API key in Settings → External services")
|
||||||
|
}
|
||||||
|
case extsvc.ServiceHRDLog:
|
||||||
|
if !has(cfg.HRDLog.Callsign) || !has(cfg.HRDLog.Code) {
|
||||||
|
return fmt.Errorf("HRDLog.net is not configured — set the callsign and upload code in Settings → External services")
|
||||||
|
}
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
if !has(cfg.EQSL.Username) || !has(cfg.EQSL.Password) {
|
||||||
|
return fmt.Errorf("eQSL.cc is not configured — set the username and password in Settings → External services")
|
||||||
|
}
|
||||||
|
case extsvc.ServiceHamQTH:
|
||||||
|
if !has(cfg.HamQTH.Username) || !has(cfg.HamQTH.Password) {
|
||||||
|
return fmt.Errorf("HamQTH is not configured — set the username and password in Settings → External services")
|
||||||
|
}
|
||||||
|
case extsvc.ServiceLoTW:
|
||||||
|
if !has(cfg.LoTW.StationLocation) {
|
||||||
|
return fmt.Errorf("LoTW is not configured — set the TQSL station location in Settings → External services")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
|
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
|
||||||
emit := func(line string) {
|
emit := func(line string) {
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
@@ -14314,7 +14371,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
|
// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
|
||||||
// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
|
// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
|
||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
rec := adif.SingleRecordADIF(qc)
|
rec := adif.ForwardRecordADIF(qc)
|
||||||
a.udp.EmitLoggedADIF(rec)
|
a.udp.EmitLoggedADIF(rec)
|
||||||
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
|
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
|
||||||
a.udpTriggerQSOLogged(qc)
|
a.udpTriggerQSOLogged(qc)
|
||||||
|
|||||||
+32
-4
@@ -160,6 +160,7 @@ func (a *App) applyAutoCall() {
|
|||||||
e.Reset()
|
e.Reset()
|
||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
a.acPeriod, a.acBuf = nil, nil
|
a.acPeriod, a.acBuf = nil, nil
|
||||||
|
a.acJudged, a.acDirty = nil, nil
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
}
|
}
|
||||||
a.emitAutoCall()
|
a.emitAutoCall()
|
||||||
@@ -279,17 +280,37 @@ func (a *App) autoCallFeed(d autocall.Decode) {
|
|||||||
if a.acPeriod == nil {
|
if a.acPeriod == nil {
|
||||||
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
|
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
|
||||||
a.acFed = map[string]time.Time{}
|
a.acFed = map[string]time.Time{}
|
||||||
|
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
|
||||||
}
|
}
|
||||||
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||||
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
||||||
|
// Only if something in it has NOT been judged. The buffer now outlives
|
||||||
|
// the judgement (see below), so without this the arrival of the next
|
||||||
|
// period would judge the previous one a second time on the very same
|
||||||
|
// decodes — and act on them, a slot late.
|
||||||
|
pending := a.acDirty[inst] || a.acJudged[inst] != prev
|
||||||
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
a.acBuf[inst] = []acDecode{{d: d}}
|
a.acBuf[inst] = []acDecode{{d: d}}
|
||||||
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
|
// The previous period ends here whatever the sweeper was going to do: a
|
||||||
|
// decode stamped with the next slot is proof the old one is over.
|
||||||
|
if pending {
|
||||||
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// APPENDED, never restarted. The buffer survives the period being judged,
|
||||||
|
// so a decode that arrives after the others belongs to the same period and
|
||||||
|
// is weighed against all of them.
|
||||||
|
//
|
||||||
|
// It used to be dropped and then judged on its own: the sweeper cleared the
|
||||||
|
// buffer, a straggler opened a "new" one under the same key, and the ladder
|
||||||
|
// was applied to whatever handful had come late — with the other thirty
|
||||||
|
// stations of that period nowhere in sight. A deep decode arriving a second
|
||||||
|
// after the burst is exactly the station worth calling.
|
||||||
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
a.acFed[inst] = time.Now()
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||||
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -344,9 +365,14 @@ func (a *App) autoCallSweep() {
|
|||||||
if time.Since(a.acFed[inst]) < acQuiet {
|
if time.Since(a.acFed[inst]) < acQuiet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Judged once per period, and again only when something new has come in
|
||||||
|
// for it. The period itself is kept open until a decode from the NEXT one
|
||||||
|
// arrives, so a straggler is judged with the whole period behind it.
|
||||||
|
if a.acJudged[inst] == key && !a.acDirty[inst] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||||
delete(a.acPeriod, inst)
|
a.acJudged[inst], a.acDirty[inst] = key, false
|
||||||
delete(a.acBuf, inst)
|
|
||||||
}
|
}
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
for _, d := range ready {
|
for _, d := range ready {
|
||||||
@@ -369,7 +395,9 @@ func (a *App) autoCallSilence() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second
|
// Nothing pending for this receiver, and nothing judged for a while: the
|
||||||
|
// band has gone quiet under it.
|
||||||
|
quiet := !a.acDirty[inst] && time.Since(a.acLastJudge) > 20*time.Second
|
||||||
tr := a.acTR[inst]
|
tr := a.acTR[inst]
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
if !quiet {
|
if !quiet {
|
||||||
|
|||||||
@@ -1,4 +1,24 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.14",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
|
||||||
|
"Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610’s 98 — the model list following it, since it is derived from the address rather than stored.",
|
||||||
|
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
|
||||||
|
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA.",
|
||||||
|
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
|
||||||
|
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Manager’s console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
||||||
|
"Icom CI-V : l’adresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de l’IC-7610 — la liste des modèles suivant, puisqu’elle est déduite de l’adresse et non enregistrée.",
|
||||||
|
"Upload Cloudlog / Wavelog : un contact simplex n’est plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que s’ils diffèrent. L’enregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
|
||||||
|
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». C’est le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA.",
|
||||||
|
"L’auto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries d’appels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et c’est précisément pour ça qu’il est sur la liste. Le repos entre séries s’applique toujours.",
|
||||||
|
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui n’est pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH s’annoncent aussi sous leur nom dans le message."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.13",
|
"version": "0.27.13",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -4559,11 +4559,16 @@ export default function App() {
|
|||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
|
qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
|
||||||
hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
|
hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
|
||||||
|
hamqth: 'HamQTH', cloudlog: 'Cloudlog / Wavelog',
|
||||||
};
|
};
|
||||||
const label = LABELS[service] ?? service;
|
const label = LABELS[service] ?? service;
|
||||||
|
try {
|
||||||
|
// Awaited BEFORE the toast: the backend refuses a service with no
|
||||||
|
// credentials, and announcing an upload that was never started is how an
|
||||||
|
// operator concludes their QSOs are on a site they never signed up to.
|
||||||
|
await UploadQSOsManual(service, ids as any);
|
||||||
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
|
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
|
||||||
try { await UploadQSOsManual(service, ids as any); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
||||||
}
|
}
|
||||||
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
||||||
// matches the current filter, bypassing the on-screen row threshold.
|
// matches the current filter, bypassing the on-screen row threshold.
|
||||||
|
|||||||
@@ -1621,6 +1621,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [listenPref, setListenPref] = useState(true);
|
const [listenPref, setListenPref] = useState(true);
|
||||||
const [activeRadio, setActiveRadioId] = useState('');
|
const [activeRadio, setActiveRadioId] = useState('');
|
||||||
const [radioBusy, setRadioBusy] = useState(false);
|
const [radioBusy, setRadioBusy] = useState(false);
|
||||||
|
// "Other (custom address)" has to STAY chosen. The dropdown is derived from
|
||||||
|
// the address, so picking Other while the address still matched a listed rig
|
||||||
|
// put the list straight back on that rig — the operator saw it jump back to
|
||||||
|
// IC-7610 the moment they chose Other.
|
||||||
|
const [icomCustom, setIcomCustom] = useState(false);
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||||
@@ -3628,8 +3633,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.icomModel')}</Label>
|
<Label>{t('cat.icomModel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
|
value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')}
|
||||||
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
|
onValueChange={(v) => {
|
||||||
|
const m = ICOM_MODELS.find((x) => x.name === v);
|
||||||
|
setIcomCustom(!m);
|
||||||
|
if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr }));
|
||||||
|
}}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
||||||
@@ -3655,8 +3664,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.icomModel')}</Label>
|
<Label>{t('cat.icomModel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
|
value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')}
|
||||||
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
|
onValueChange={(v) => {
|
||||||
|
const m = ICOM_MODELS.find((x) => x.name === v);
|
||||||
|
setIcomCustom(!m);
|
||||||
|
if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr }));
|
||||||
|
}}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ export function cleanSpotter(s: string): string {
|
|||||||
// alone instead of guessing wrong.
|
// alone instead of guessing wrong.
|
||||||
export function inferSpotMode(comment: string, freqHz: number): string {
|
export function inferSpotMode(comment: string, freqHz: number): string {
|
||||||
const c = (comment || '').toUpperCase();
|
const c = (comment || '').toUpperCase();
|
||||||
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit
|
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit modes,
|
||||||
// modes, not modes of their own. A spot commented "super fox" fell through to
|
// not modes of their own, and they turn up in a comment written every way an
|
||||||
// the band plan and came out DATA, and that verdict is not cosmetic: the
|
// operator can shorten them: "super fox", SFOX, S/F, F/H.
|
||||||
|
//
|
||||||
|
// A spot commented "super fox" fell through to the band plan and came out
|
||||||
|
// DATA, and that verdict is not cosmetic: the
|
||||||
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
|
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
|
||||||
// new DATA slot rather than the new FT8 one it is.
|
// new DATA slot rather than the new FT8 one it is.
|
||||||
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
|
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bS\/F\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
|
||||||
if (/\bFT8\b/.test(c)) return 'FT8';
|
if (/\bFT8\b/.test(c)) return 'FT8';
|
||||||
if (/\bFT4\b/.test(c)) return 'FT4';
|
if (/\bFT4\b/.test(c)) return 'FT4';
|
||||||
if (/\bJS8\b/.test(c)) return 'JS8';
|
if (/\bJS8\b/.test(c)) return 'JS8';
|
||||||
|
|||||||
+40
-5
@@ -120,12 +120,36 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
|||||||
func SingleRecordADIF(q qso.QSO) string {
|
func SingleRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
// Uploads target other services — keep it standard (no app-specific tags).
|
// Uploads target other services — keep it standard (no app-specific tags),
|
||||||
|
// and say nothing about the receive side when there is nothing to say: in
|
||||||
|
// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
|
||||||
|
// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
|
||||||
|
// BAND_RX filled in as split and shows it as "17m/17m".
|
||||||
writeRecord(bw, q, false, nil)
|
writeRecord(bw, q, false, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
|
||||||
|
//
|
||||||
|
// The receive side is written even when it repeats the transmit side, which is
|
||||||
|
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
|
||||||
|
// and found nothing there for contacts logged by a path that left it blank. A
|
||||||
|
// logger on the same desk is being handed a copy of our record, not published
|
||||||
|
// to a service that will draw conclusions from every tag present.
|
||||||
|
func ForwardRecordADIF(q qso.QSO) string {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeRecord(bw, q, false, nil, keepRX)
|
||||||
|
bw.Flush()
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepRX marks a record whose receive side must be written out in full.
|
||||||
|
type rxMode int
|
||||||
|
|
||||||
|
const keepRX rxMode = 1
|
||||||
|
|
||||||
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||||
// so it can be written out and read back with nothing dropped. Used by the
|
// so it can be written out and read back with nothing dropped. Used by the
|
||||||
// offline queue: a QSO parked in the safety file must come back identical
|
// offline queue: a QSO parked in the safety file must come back identical
|
||||||
@@ -161,7 +185,18 @@ func BatchRecordsADIF(records []string) string {
|
|||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
|
||||||
|
// The receive side, unless it merely repeats the transmit side. See
|
||||||
|
// SingleRecordADIF and ForwardRecordADIF.
|
||||||
|
bandRX, freqRX := q.BandRX, q.FreqRXHz
|
||||||
|
if len(rx) == 0 || rx[0] != keepRX {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
|
||||||
|
bandRX = ""
|
||||||
|
}
|
||||||
|
if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
|
||||||
|
freqRX = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
// allow == nil → write every promoted field (standard/full behaviour).
|
// allow == nil → write every promoted field (standard/full behaviour).
|
||||||
// Otherwise a promoted tag is written only when it's in the chosen set.
|
// Otherwise a promoted tag is written only when it's in the chosen set.
|
||||||
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
||||||
@@ -194,7 +229,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
w("BAND", q.Band)
|
w("BAND", q.Band)
|
||||||
w("BAND_RX", q.BandRX)
|
w("BAND_RX", bandRX)
|
||||||
|
|
||||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||||
w("MODE", mode)
|
w("MODE", mode)
|
||||||
@@ -203,8 +238,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
if freqRX != nil && *freqRX > 0 {
|
||||||
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
w("RST_SENT", q.RSTSent)
|
w("RST_SENT", q.RSTSent)
|
||||||
|
|||||||
@@ -1000,7 +1000,19 @@ func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
|
|||||||
if rank(c) == 0 {
|
if rank(c) == 0 {
|
||||||
return false, "nothing-needed"
|
return false, "nothing-needed"
|
||||||
}
|
}
|
||||||
if e.rounds[call] >= e.set.MaxRounds {
|
// A WATCHED callsign is never parked.
|
||||||
|
//
|
||||||
|
// Parking is the answer to "it will not answer, stop wasting the evening on
|
||||||
|
// it" — a fair verdict about a station the LOG picked out, and the wrong one
|
||||||
|
// about a station the OPERATOR did. A DXpedition running a pileup takes more
|
||||||
|
// than two series of calls to get through to, which is precisely why it was
|
||||||
|
// put on the list; watched ZD8GB was refused for the rest of the session
|
||||||
|
// after fourteen unanswered calls, while it went on transmitting six streams
|
||||||
|
// a period.
|
||||||
|
//
|
||||||
|
// The rest between series still applies, so it does not monopolise the
|
||||||
|
// transmitter — it simply never becomes ineligible.
|
||||||
|
if e.rounds[call] >= e.set.MaxRounds && !c.Watched {
|
||||||
return false, "parked" // its series are spent for the session
|
return false, "parked" // its series are spent for the session
|
||||||
}
|
}
|
||||||
// RESTING. A series that ended in a brake is followed by a real pause,
|
// RESTING. A series that ended in a brake is followed by a real pause,
|
||||||
|
|||||||
@@ -922,3 +922,27 @@ func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
|
|||||||
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
|
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parking answers "it will not answer, stop wasting the evening on it" — which
|
||||||
|
// is a verdict about a station the LOG picked out, not about one the OPERATOR
|
||||||
|
// did. From the air: a watched ZD8GB, six streams a period, refused for the
|
||||||
|
// rest of the session after two series of unanswered calls.
|
||||||
|
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
|
||||||
|
spent := func(c Candidate) *Engine {
|
||||||
|
e := New(Settings{Enabled: true, MaxRounds: 2, Rest: 0})
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := cq("PLAIN", NeedDXCC, 0)
|
||||||
|
if a := spent(plain).OnPeriod(period(0, plain)); a.Kind == DoReply {
|
||||||
|
t.Errorf("%+v — a station whose series are spent was called again", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
dx := cq("ZD8GB", NeedDXCC, 0, watched)
|
||||||
|
if a := spent(dx).OnPeriod(period(0, dx)); a.Kind != DoReply {
|
||||||
|
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ var icnBE = binary.BigEndian
|
|||||||
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
||||||
// so enabling it can't affect freq/mode/DSP control.
|
// so enabling it can't affect freq/mode/DSP control.
|
||||||
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr < 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
if digitalDefault == "" {
|
if digitalDefault == "" {
|
||||||
|
|||||||
@@ -172,12 +172,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
||||||
// 115200, rig address to the IC-7610's 0x98 when out of range.
|
// 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in
|
||||||
|
// range: it is a valid CI-V address that some rigs and interfaces are set to.
|
||||||
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
||||||
if baud <= 0 {
|
if baud <= 0 {
|
||||||
baud = 115200
|
baud = 115200
|
||||||
}
|
}
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr < 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
if digitalDefault == "" {
|
if digitalDefault == "" {
|
||||||
|
|||||||
+16
-1
@@ -10,6 +10,10 @@ import (
|
|||||||
|
|
||||||
// A contact that was not split still has a receive side, and the record
|
// A contact that was not split still has a receive side, and the record
|
||||||
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
|
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
|
||||||
|
//
|
||||||
|
// The record UPLOADED to a service must not: in ADIF an absent BAND_RX means
|
||||||
|
// "same as transmit", and Wavelog shown both draws "17m/17m" on a simplex FT8
|
||||||
|
// contact — reported by OE6CLD, who had never logged a split QSO in his life.
|
||||||
func TestFillRXDefaults(t *testing.T) {
|
func TestFillRXDefaults(t *testing.T) {
|
||||||
hz := int64(14074000)
|
hz := int64(14074000)
|
||||||
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
|
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
|
||||||
@@ -22,7 +26,7 @@ func TestFillRXDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Assert on the RECORD another logger reads, not merely on the struct:
|
// Assert on the RECORD another logger reads, not merely on the struct:
|
||||||
// both halves of the receive side have to reach it.
|
// both halves of the receive side have to reach it.
|
||||||
rec := strings.ToUpper(adif.SingleRecordADIF(q))
|
rec := strings.ToUpper(adif.ForwardRecordADIF(q))
|
||||||
if !strings.Contains(rec, "<BAND_RX:3>20M") {
|
if !strings.Contains(rec, "<BAND_RX:3>20M") {
|
||||||
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
|
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
|
||||||
}
|
}
|
||||||
@@ -40,3 +44,14 @@ func TestFillRXDefaultsKeepsSplit(t *testing.T) {
|
|||||||
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
|
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A genuine split contact is uploaded AS split: the rule above is about a
|
||||||
|
// receive side that repeats the transmit side, not about dropping one.
|
||||||
|
func TestSplitIsUploadedWithItsReceiveSide(t *testing.T) {
|
||||||
|
tx, rx := int64(14195000), int64(18100000)
|
||||||
|
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
|
||||||
|
up := strings.ToUpper(adif.SingleRecordADIF(q))
|
||||||
|
if !strings.Contains(up, "<BAND_RX:3>17M") || !strings.Contains(up, "<FREQ_RX:9>18.100000") {
|
||||||
|
t.Errorf("a cross-band contact lost its receive side:\n%s", up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/extsvc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An upload to a service with no credentials used to look exactly like one that
|
||||||
|
// worked: it ran on its own goroutine and reported into the QSL Manager's
|
||||||
|
// console, which is not open when the command came from the QSO list.
|
||||||
|
func TestUploadRefusesAnUnconfiguredService(t *testing.T) {
|
||||||
|
var empty extsvc.ExternalServices
|
||||||
|
for _, svc := range []extsvc.Service{
|
||||||
|
extsvc.ServiceCloudlog, extsvc.ServiceQRZ, extsvc.ServiceClublog,
|
||||||
|
extsvc.ServiceHRDLog, extsvc.ServiceEQSL, extsvc.ServiceHamQTH, extsvc.ServiceLoTW,
|
||||||
|
} {
|
||||||
|
err := uploadConfigured(svc, empty)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: an unconfigured service was accepted", svc)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Settings") {
|
||||||
|
t.Errorf("%s: %q does not say where to fix it", svc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured: nothing in the way.
|
||||||
|
cfg := extsvc.ExternalServices{}
|
||||||
|
cfg.Cloudlog.URL, cfg.Cloudlog.APIKey = "https://log.f4bpo.fr", "cl123"
|
||||||
|
if err := uploadConfigured(extsvc.ServiceCloudlog, cfg); err != nil {
|
||||||
|
t.Errorf("a configured Cloudlog was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user