Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f93e1c5898 | ||
|
|
2d67e3d57f | ||
|
|
1a32e4a228 | ||
|
|
b318aa66cc | ||
|
|
6cbe29fef1 | ||
|
|
96b5f2d91f | ||
|
|
ee004c1c62 | ||
|
|
b00552f617 | ||
|
|
4dbc773343 | ||
|
|
ec347e1b7a | ||
|
|
b0bbe3e402 | ||
|
|
85061ab673 | ||
|
|
ab68e4a84e | ||
|
|
f6532b2e85 | ||
|
|
8685dbd6cf | ||
|
|
b7c87def5b |
@@ -195,6 +195,7 @@ const (
|
||||
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioPTTData = "audio.ptt_data" // keyer audio arrives on the rig DATA/USB input
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
@@ -8529,6 +8530,10 @@ type AudioSettings struct {
|
||||
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
||||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||||
// PTTData: the keyer's audio reaches the radio on its DATA/USB input, not
|
||||
// the microphone socket. CAT keying only — it changes which transmit
|
||||
// command is sent (a Kenwood TS-590 takes TX1 instead of TX).
|
||||
PTTData bool `json:"ptt_data"`
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||
@@ -8590,7 +8595,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
|
||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -8602,6 +8607,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out.PTTMethod = v
|
||||
}
|
||||
out.PTTPort = m[keyAudioPTTPort]
|
||||
out.PTTData = m[keyAudioPTTData] == "1"
|
||||
out.FromRadio = m[keyAudioFromRadio]
|
||||
out.ToRadio = m[keyAudioToRadio]
|
||||
out.RecordingDevice = m[keyAudioRecDevice]
|
||||
@@ -8672,6 +8678,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
||||
keyAudioPTTMethod: pttMethod,
|
||||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||||
keyAudioPTTData: boolStr(s.PTTData),
|
||||
keyAudioFormat: format,
|
||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||
@@ -10242,14 +10249,26 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
||||
}
|
||||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||||
if !ok {
|
||||
// No exception COVERS this QSO's date. If the call nonetheless HAS a
|
||||
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then
|
||||
// cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older
|
||||
// QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 →
|
||||
// England for a 2012 contact). Ordinary calls (no exception) are left to
|
||||
// cty.dat.
|
||||
if a.clublog.HasException(q.Callsign) {
|
||||
// No exception covers this QSO's date, so the question becomes which
|
||||
// country file knows the PREFIX better.
|
||||
//
|
||||
// ClubLog's prefix table is date-ranged and cty.dat's is not, and that
|
||||
// is not a detail: cty.dat describes the world as it is TODAY. Niue
|
||||
// moved to E6, so ZK2 went back to New Zealand there — and every ZK2
|
||||
// contact ever made was silently relabelled New Zealand, taking a
|
||||
// confirmed entity out of an operator's DXCC with it. ZK1 loses the
|
||||
// Cook Islands the same way. ClubLog still knows both.
|
||||
//
|
||||
// It only speaks when it HAS an answer (E6, TO5A and their like are not
|
||||
// in its prefix table at all), and never over an exact "=CALLSIGN" entry
|
||||
// in cty.dat: that is somebody having looked at this very callsign, and
|
||||
// a prefix rule does not overrule it.
|
||||
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
|
||||
ctyExact := false
|
||||
if m, mok := a.dxcc.Lookup(q.Callsign); mok {
|
||||
ctyExact = m.Exact
|
||||
}
|
||||
if !ctyExact {
|
||||
e, ok = pe, true
|
||||
}
|
||||
}
|
||||
@@ -10599,7 +10618,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
if err := a.cat.SetPTT(true); err != nil {
|
||||
if err := a.cat.SetPTTSource(true, cfg.PTTData); err != nil {
|
||||
applog.Printf("ptt: CAT SetPTT failed: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -11712,7 +11731,14 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
svc := extsvc.Service(service)
|
||||
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH {
|
||||
// Cloudlog/Wavelog has no sent-status column ON PURPOSE — it dedupes
|
||||
// server-side, which is exactly why an explicit selection needs no column to
|
||||
// be safe. HAMLOG.online and HamQTH keep theirs in ADIF extras.
|
||||
if svc == extsvc.ServiceHamlog {
|
||||
return extsvc.ErrHamlogClosed
|
||||
}
|
||||
if uploadColumnFor(service) == "" &&
|
||||
svc != extsvc.ServiceHamQTH && svc != extsvc.ServiceCloudlog {
|
||||
return fmt.Errorf("unknown service %q", service)
|
||||
}
|
||||
cfg := a.loadExternalServices()
|
||||
@@ -11936,7 +11962,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
}
|
||||
flush()
|
||||
}
|
||||
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH {
|
||||
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH || svc == extsvc.ServiceCloudlog {
|
||||
// One record per request, extras-stamped services. Hamlog used to fall
|
||||
// through to the QRZ branch below and got uploaded to the WRONG service
|
||||
// with the QRZ key — the context menu offered what the loop never handled.
|
||||
@@ -11956,10 +11982,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
}
|
||||
var res extsvc.UploadResult
|
||||
var err error
|
||||
if svc == extsvc.ServiceHamlog {
|
||||
switch svc {
|
||||
case extsvc.ServiceHamlog:
|
||||
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
|
||||
} else {
|
||||
case extsvc.ServiceHamQTH:
|
||||
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
|
||||
default:
|
||||
res, err = extsvc.UploadCloudlog(ctx, nil, cfg.Cloudlog, rec)
|
||||
}
|
||||
if err == nil && res.OK {
|
||||
a.markExtUploaded(svc, id, res.LogID)
|
||||
@@ -12015,6 +12044,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
|
||||
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
|
||||
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
|
||||
extsvc.ServiceCloudlog: "Cloudlog / Wavelog",
|
||||
}[svc]
|
||||
if label == "" {
|
||||
label = string(svc)
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.11",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operator’s DXCC with it. ZK1 lost the Cook Islands the same way. ClubLog’s prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
|
||||
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.com’s per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operator’s real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
|
||||
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
|
||||
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
|
||||
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back.",
|
||||
"Yaesu console: the power slider no longer springs back to 100 W on a 200 W radio. The console already knew an FTDX101MP could do 200 — that is what it drew the slider to — but the command that sets it was clamped to 100, so the rig was politely given half of what was asked for and the next poll showed it. FTDX101MP, FT-DX5000 and FTDX9000 reach 200 W now, and a rig reporting more than expected is still believed.",
|
||||
"Chase new: clicking a row now does what clicking a cluster spot does — including telling WSJT-X, JTDX or MSHV to change mode, so picking an FT4 station while the decoder sits in FT8 actually moves it. It also brings across the mode and its RST preset, and any park or summit reference. It used to do a hand-picked half of that, which left the one thing the window exists for — jumping onto a station — decoding the wrong mode.",
|
||||
"FT decodes: JTDX on FT4 no longer reads as Q65. A decode carries a one-character mode marker, and the two programs disagree about “:” — Q65 in WSJT-X, FT4 in JTDX — so the character alone cannot answer, and the wrong answer poisoned every verdict behind it: new mode, new slot, the mode filter. The sending program’s own status names the mode in full and now settles it; the markers both forks agree on are untouched."
|
||||
],
|
||||
"fr": [
|
||||
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel qu’il est AUJOURD’HUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de l’opérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès qu’aucune exception par indicatif ne s’applique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle n’a pas de réponse c’est cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
|
||||
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QU’UNE paire représentative par entité, et OpsLog l’imposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). C’est un crédit WAZ pour une zone jamais travaillée. L’entité vient toujours de cty.dat, qui fait autorité sur ce qu’un indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
|
||||
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook l’a rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec l’apparence de ce qu’a dit la page — c’est ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
|
||||
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu d’envoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut d’envoi par QSO — volontairement, puisqu’il dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite n’a besoin d’aucun statut pour être sûre : c’est précisément pourquoi cette absence cesse de compter ici.",
|
||||
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface d’envoi n’accepte rien d’autre : la case d’envoi automatique et l’entrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations s’importent toujours depuis un fichier (ce qui n’a jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code d’envoi est conservé intact pour le jour où les clés reviendraient.",
|
||||
"Console Yaesu : le curseur de puissance ne revient plus à 100 W sur une radio de 200 W. La console savait déjà qu’un FTDX101MP peut sortir 200 — c’est à cela qu’elle dimensionnait son curseur — mais la commande d’envoi était bornée à 100 : le poste recevait donc poliment la moitié de ce qu’on lui demandait, et le sondage suivant l’affichait. FTDX101MP, FT-DX5000 et FTDX9000 atteignent désormais 200 W, et une radio qui annonce plus que prévu reste crue sur parole.",
|
||||
"Chase new : cliquer une ligne fait désormais ce que fait un clic sur un spot du cluster — y compris demander à WSJT-X, JTDX ou MSHV de changer de mode, si bien que choisir une station FT4 alors que le décodeur est en FT8 l’y amène vraiment. Le mode et son RST par défaut suivent aussi, de même que toute référence de parc ou de sommet. La fenêtre n’en faisait qu’une moitié choisie à la main, ce qui laissait la seule chose pour laquelle elle existe — sauter sur une station — décoder dans le mauvais mode.",
|
||||
"FT decodes : JTDX en FT4 ne s’affiche plus en Q65. Un décodage porte un marqueur de mode d’un seul caractère, et les deux logiciels ne s’accordent pas sur « : » — Q65 pour WSJT-X, FT4 pour JTDX : le caractère seul ne peut donc pas trancher, et sa mauvaise réponse contaminait tout ce qui en découle — nouveau mode, nouveau slot, filtre de mode. Le statut envoyé par le logiciel lui-même nomme le mode en entier et tranche désormais ; les marqueurs sur lesquels les deux s’accordent ne changent pas."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.10",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
|
||||
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NG’s firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
|
||||
],
|
||||
"fr": [
|
||||
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous l’aviez laissé, et la vue principale suit pendant que vous glissez.",
|
||||
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base d’Arduino — le firmware K3NG, la famille ERC — REDÉMARRE à l’ouverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels d’un échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
|
||||
"Voice keyer with CAT keying: an option saying the keyer’s audio arrives on the radio’s DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rig’s own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
|
||||
],
|
||||
"fr": [
|
||||
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — la signature d’une liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait s’en apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte qu’une ligne à ignorer.",
|
||||
"Voice keyer avec PTT CAT : une option indiquant que l’audio du keyer arrive sur l’entrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 l’ACC2/USB — si bien qu’un keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.8",
|
||||
"date": "",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// Reported from a real import: every ZK2 contact came back as New Zealand and a
|
||||
// confirmed entity — Niue — disappeared from the operator's DXCC.
|
||||
//
|
||||
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
|
||||
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
|
||||
// reason for enabling its country file.
|
||||
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
|
||||
dir := filepath.Join("build", "bin", "data")
|
||||
dm := dxcc.NewManager(dir)
|
||||
if err := dm.LoadFromDisk(); err != nil {
|
||||
t.Skipf("cty.dat not available here: %v", err)
|
||||
}
|
||||
cm := clublog.NewManager("", dir)
|
||||
if err := cm.EnsureLoaded(); err != nil {
|
||||
t.Skipf("ClubLog country file not available here: %v", err)
|
||||
}
|
||||
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
|
||||
|
||||
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
call string
|
||||
want int // ADIF entity
|
||||
why string
|
||||
}{
|
||||
{"ZK2KK", 188, "Niue — the reported case"},
|
||||
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
q := qso.QSO{Callsign: c.call, QSODate: when}
|
||||
if !a.applyClublogException(&q, true) {
|
||||
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
|
||||
continue
|
||||
}
|
||||
if q.DXCC == nil || *q.DXCC != c.want {
|
||||
got := 0
|
||||
if q.DXCC != nil {
|
||||
got = *q.DXCC
|
||||
}
|
||||
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign ClubLog's prefix table does not know must be left to cty.dat
|
||||
// rather than blanked: silence is not an answer.
|
||||
q := qso.QSO{Callsign: "E6AG", QSODate: when}
|
||||
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
|
||||
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
|
||||
}
|
||||
}
|
||||
+62
-17
@@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||
import { formatDistance } from '@/lib/units';
|
||||
import { WIDGET_KEYS } from '@/components/AppearancePanel';
|
||||
import { bandForMHz } from '@/lib/bandplan';
|
||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
@@ -761,6 +762,32 @@ export default function App() {
|
||||
// Several amps side by side make a wide widget, so an operator running two
|
||||
// picks the one he watches while transmitting. Declared here because the poll
|
||||
// below runs faster while the widget is open.
|
||||
// Widget order (Settings → Appearance). The row is a flex container, so the
|
||||
// ORDER property moves a widget without touching the tree: every condition,
|
||||
// every ref and every hook stays where it was, and a widget that is switched
|
||||
// off simply is not there to be ordered.
|
||||
//
|
||||
// QSO entry and the F1-F5 panel are not in this list on purpose — they are
|
||||
// not in this row at all, and an operator cannot be allowed to push the thing
|
||||
// they type into behind a rotator dial.
|
||||
const [widgetOrder, setWidgetOrder] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||
const arr = raw ? JSON.parse(raw) : null;
|
||||
if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
|
||||
} catch { /* corrupt pref → the default order */ }
|
||||
return [...WIDGET_KEYS];
|
||||
});
|
||||
useEffect(() => EventsOn('widgets:order', (keys: any) => {
|
||||
if (Array.isArray(keys)) setWidgetOrder(keys.filter((k: any) => typeof k === 'string'));
|
||||
}), []);
|
||||
// A key the saved order has never heard of (a widget added since) goes to the
|
||||
// end rather than to the front, where it would jump the queue on every
|
||||
// upgrade.
|
||||
const wOrder = (k: string) => {
|
||||
const i = widgetOrder.indexOf(k);
|
||||
return i < 0 ? WIDGET_KEYS.length + (WIDGET_KEYS as readonly string[]).indexOf(k) : i;
|
||||
};
|
||||
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
||||
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
||||
// Poll fast only while the amplifier widget is open: its meters must track TX
|
||||
@@ -6328,6 +6355,9 @@ export default function App() {
|
||||
txState={txState}
|
||||
txStates={txStates}
|
||||
spotStatus={spotStatus as any}
|
||||
// Only while CAT is actually connected: an empty band means "nothing to
|
||||
// compare with", never "the rig is on no band".
|
||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||
myCall={station.callsign}
|
||||
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
||||
// a Reply, which is the same thing as double-clicking the line in their
|
||||
@@ -7437,7 +7467,7 @@ export default function App() {
|
||||
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||
their freq/mode (colour-coded) and OpsLog version. */}
|
||||
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||
<div className="w-[248px] shrink-0 min-h-0 relative">
|
||||
<div className="w-[248px] shrink-0 min-h-0 relative" style={{ order: wOrder('livestations') }}>
|
||||
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
|
||||
<Radio className="size-3.5 text-primary" />
|
||||
@@ -7481,7 +7511,7 @@ export default function App() {
|
||||
// relative + absolute inner: the chat takes the row height (set by the
|
||||
// entry strip) WITHOUT its message list growing the row, like the
|
||||
// Stats panel. The list scrolls inside this fixed height.
|
||||
<div className="w-[280px] shrink-0 min-h-0 relative">
|
||||
<div className="w-[280px] shrink-0 min-h-0 relative" style={{ order: wOrder('chat') }}>
|
||||
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
||||
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
||||
@@ -7493,7 +7523,7 @@ export default function App() {
|
||||
controls column, so the widget is just the dial and needs only its
|
||||
width. */}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}>
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
|
||||
<RotorCompass
|
||||
presets={rotorCompact ? undefined : rotorPresets}
|
||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
@@ -7513,7 +7543,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showMotorAnt && ubStatus.enabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
|
||||
<MotorAntennaWidget
|
||||
ant={ubStatus}
|
||||
refetch={pokeUbStatus}
|
||||
@@ -7524,7 +7554,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showAntGenius && agEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('antgenius') }}>
|
||||
<AntGeniusPanel
|
||||
status={agStatus}
|
||||
onActivate={agActivate}
|
||||
@@ -7537,7 +7567,7 @@ export default function App() {
|
||||
// One column per amplifier shown, so two amps stand side by side
|
||||
// rather than making the widget twice as tall as the dock row.
|
||||
<div className="shrink-0 min-h-0"
|
||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
|
||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}>
|
||||
<AmpWidget
|
||||
amps={ampSts}
|
||||
flex={flexAmp}
|
||||
@@ -7547,7 +7577,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showTuner && tgEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('tuner') }}>
|
||||
<TunerGeniusPanel
|
||||
status={tgStatus}
|
||||
onTune={tgTune}
|
||||
@@ -7559,7 +7589,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showScp && scpEnabled && (
|
||||
<div className="w-[240px] shrink-0 min-h-0">
|
||||
<div className="w-[240px] shrink-0 min-h-0" style={{ order: wOrder('scp') }}>
|
||||
<ScpPanel
|
||||
result={scpResult}
|
||||
currentCall={callsign}
|
||||
@@ -7570,20 +7600,35 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{chaseNewOn && showChaseNew && (
|
||||
<div className="w-[420px] shrink-0 min-h-0">
|
||||
<div className="w-[420px] shrink-0 min-h-0" style={{ order: wOrder('chasenew') }}>
|
||||
{/* Same reflex as clicking a cluster spot: the callsign into the
|
||||
entry, and the rig onto the frequency it was decoded on. */}
|
||||
<ChaseNewPanel onPick={(sp) => {
|
||||
onCallsignInput(sp.call, { force: true });
|
||||
// And the panadapter width too — after the tune settles, for the
|
||||
// same SmartSDR pan-follow reason as handleSpotClick.
|
||||
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode).then(() =>
|
||||
window.setTimeout(() => FlexZoomForSpot(sp.mode ?? '', sp.freq_hz ?? 0).catch(() => {}), 300));
|
||||
// A row here is a spot like any other, so it goes through the
|
||||
// SAME handler as one clicked in the cluster: the rig tunes, the
|
||||
// panadapter follows, the entry takes the mode and its RST
|
||||
// preset, the park and summit references come across — and the
|
||||
// decoding application is told to change mode, which is what an
|
||||
// operator clicking an FT4 row from an FT8 slot is asking for.
|
||||
//
|
||||
// It used to do a hand-picked half of that, so the one thing
|
||||
// Chase new exists for — jumping onto a station — left WSJT-X
|
||||
// decoding the wrong mode.
|
||||
handleSpotClick({
|
||||
dx_call: sp.call,
|
||||
freq_hz: sp.freq_hz ?? 0,
|
||||
band: sp.band,
|
||||
// handleSpotClick infers the mode from the comment the way a
|
||||
// cluster line carries it; the mode is what we have, so it is
|
||||
// what we hand over.
|
||||
comment: sp.mode ?? '',
|
||||
spotter: '',
|
||||
});
|
||||
}} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<div className="w-[320px] shrink-0 min-h-0" style={{ order: wOrder('dvk') }}>
|
||||
<DvkPanel
|
||||
messages={dvkMsgs}
|
||||
status={dvkStat}
|
||||
@@ -7599,7 +7644,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
|
||||
<WinkeyerPanel
|
||||
// A rig keyer has no serial status of its own: it is connected
|
||||
// exactly when its CAT backend is. Yaesu was missing from this
|
||||
@@ -7643,7 +7688,7 @@ export default function App() {
|
||||
{/* QRZ photo: when the keyer is open it sits to its right at natural
|
||||
(capped) width, shrinking the keyer panel rather than hiding it. */}
|
||||
{lookupResult?.image_url && (
|
||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
|
||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')} style={{ order: wOrder('photo') }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||
import { GripVertical, Lock } from 'lucide-react';
|
||||
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -290,6 +292,116 @@ export function AppearancePanel() {
|
||||
)}
|
||||
|
||||
<MatrixColorsSection />
|
||||
<WidgetOrderSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The row of widgets to the right of the entry, in the order they appear.
|
||||
//
|
||||
// Flexbox does the moving in the main view — this list only decides the order
|
||||
// property each one gets. That is why a widget switched OFF still holds its
|
||||
// place here: it comes back where the operator left it rather than at the end.
|
||||
export const WIDGET_KEYS = [
|
||||
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo',
|
||||
] as const;
|
||||
|
||||
const WIDGET_LABELS: Record<string, string> = {
|
||||
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew',
|
||||
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||
};
|
||||
|
||||
function readWidgetOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||
const arr = raw ? JSON.parse(raw) : null;
|
||||
if (Array.isArray(arr)) {
|
||||
// A key from an older build that no longer exists is dropped; a widget
|
||||
// added since joins the end. An old preference can never hide a new one.
|
||||
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
||||
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
||||
}
|
||||
} catch { /* corrupt pref → the default order */ }
|
||||
return [...WIDGET_KEYS];
|
||||
}
|
||||
|
||||
function WidgetOrderSection() {
|
||||
const { t } = useI18n();
|
||||
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
||||
const dragKey = useRef<string | null>(null);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
// Where the row would land. Drawn as a line above the target rather than by
|
||||
// colouring it: the question a dragging hand asks is "between which two", and
|
||||
// a highlighted row answers a different one.
|
||||
const [over, setOver] = useState<string | null>(null);
|
||||
|
||||
const commit = (keys: string[]) => {
|
||||
setOrder(keys);
|
||||
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
||||
// The main view listens: an order is meant to be watched as it is dragged,
|
||||
// not discovered after closing Preferences.
|
||||
EventsEmit('widgets:order', keys);
|
||||
};
|
||||
const moveTo = (from: string, to: string) => {
|
||||
if (from === to) return;
|
||||
const next = order.filter((k) => k !== from);
|
||||
const at = next.indexOf(to);
|
||||
next.splice(at < 0 ? next.length : at, 0, from);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||
<div className="space-y-1 max-w-md">
|
||||
{/* The two that cannot move, shown so the order reads as the whole row
|
||||
rather than as a list that mysteriously starts at the third item. */}
|
||||
{['wo.entry', 'wo.details'].map((k) => (
|
||||
<div key={k}
|
||||
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||
</div>
|
||||
))}
|
||||
{order.map((k) => (
|
||||
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
||||
// can only be moved by a 16-pixel icon is a list most people conclude
|
||||
// cannot be moved. The grip stays as the sign that it can.
|
||||
<div key={k} draggable
|
||||
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
||||
onDragOver={(e) => {
|
||||
if (!dragKey.current) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (over !== k) setOver(k);
|
||||
}}
|
||||
onDragLeave={() => { if (over === k) setOver(null); }}
|
||||
onDrop={(e) => {
|
||||
if (!dragKey.current) return;
|
||||
e.preventDefault();
|
||||
moveTo(dragKey.current, k);
|
||||
setOver(null);
|
||||
}}
|
||||
title={t('wo.drag')}
|
||||
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
||||
'cursor-grab active:cursor-grabbing transition-shadow',
|
||||
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
||||
// The landing line, on the edge the row would take.
|
||||
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
||||
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
||||
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||
{t('wo.reset')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||
// both panels rather than being judged twice by two rules.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
@@ -95,6 +95,9 @@ interface Props {
|
||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||
txStates?: Record<string, TxMsg>;
|
||||
spotStatus: Record<string, StatusEntry>;
|
||||
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||
// the decoder announces — see the drift warning.
|
||||
rigBand?: string;
|
||||
onCall: (d: Decode) => void;
|
||||
myCall?: string;
|
||||
// Drop every decode and transmit message held for this panel. The list is a
|
||||
@@ -539,7 +542,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||
@@ -597,6 +600,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
|
||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||
// best; between overs the transmit state still does.
|
||||
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||
// frequency it knew, and every decode after that carries a stale band. Nothing
|
||||
// downstream can tell: the entity verdicts, the band filter and the FT map all
|
||||
// believe what the decoder said, and an operator ends up reading NEW BAND for a
|
||||
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
|
||||
// Korea showed as a new band because on 80 m it would have been.)
|
||||
//
|
||||
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||
// decoding a second receiver on another band, and a warning costs that setup
|
||||
// nothing but a line it can read past.
|
||||
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||
const bandDrift = !!rigBand && !!decoderBand
|
||||
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
||||
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||
|
||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
||||
|
||||
@@ -733,6 +751,18 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
what the transmit state reports, so it is right the moment anything
|
||||
is heard and keeps running when the band goes quiet. */}
|
||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
||||
{bandDrift && (
|
||||
<span
|
||||
title={t('dec.bandDriftTip')}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
{t('dec.bandDrift', {
|
||||
app: driftInstance || t('dec.bandDriftApp'),
|
||||
dec: decoderBand.toUpperCase(),
|
||||
rig: (rigBand ?? '').toUpperCase(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||
|
||||
@@ -34,8 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
||||
{ service: 'hamqth', name: 'HamQTH' },
|
||||
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
|
||||
@@ -1702,13 +1702,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
type AudioSettings = {
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
|
||||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
|
||||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||
});
|
||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||
@@ -6422,11 +6422,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={hamlog.auto_upload}
|
||||
onCheckedChange={(c) => setHamlog({ auto_upload: !!c })}
|
||||
/>
|
||||
{/* The site stopped issuing API keys and its upload API takes
|
||||
nothing else, so an auto-upload switch here would arm something
|
||||
that can only fail. Their confirmations still arrive as a file,
|
||||
which never needed a key. */}
|
||||
<p className="text-xs rounded-md border border-warning/40 bg-warning/10 px-2 py-1.5 text-warning-muted-foreground">
|
||||
{t('es.hamlogClosed')}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm cursor-not-allowed opacity-50">
|
||||
<Checkbox checked={false} disabled />
|
||||
{t('es.autoUpload')}
|
||||
</label>
|
||||
|
||||
@@ -7251,6 +7255,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Kenwood only, because only a Kenwood acts on it: TX1 is that
|
||||
family's second transmit command. Every other backend keys the
|
||||
one way it knows, so showing the box there would be a switch
|
||||
that changes nothing — the same dead furniture as ANT2 on a
|
||||
radio with one socket. */}
|
||||
{audioCfg.ptt_method === 'cat' && catCfg.backend === 'kenwood' && (
|
||||
<>
|
||||
<span />
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer" title={t('aud.pttDataHint')}>
|
||||
<Checkbox className="mt-0.5" checked={!!audioCfg.ptt_data}
|
||||
onCheckedChange={(c) => setAudioField({ ptt_data: !!c })} />
|
||||
<span>{t('aud.pttData')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||
<>
|
||||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||||
|
||||
@@ -131,7 +131,7 @@ const en: Dict = {
|
||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
|
||||
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
|
||||
'appr.matrixEnable': 'Choose the band/mode matrix colours', 'appr.matrixHint': '(the PH/CW/DIG grid in Stats — off, each theme uses its own)',
|
||||
'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the theme’s colours',
|
||||
// Matrix legend + colour names. One set of labels for the grid's legend, its
|
||||
@@ -162,7 +162,7 @@ const en: Dict = {
|
||||
'mx.tipThisCall': 'already worked with this callsign',
|
||||
'mx.tipThisCallConf': 'already confirmed with this callsign',
|
||||
// FTx decodes panel (Tools -> FT decodes)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only',
|
||||
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
||||
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
||||
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
||||
@@ -389,7 +389,7 @@ const en: Dict = {
|
||||
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
||||
'cat.ubOk': 'Connected — the antenna responded with a status frame.',
|
||||
// External services (repeated labels)
|
||||
'es.hamqthCall': 'Logbook callsign', 'es.hamqthHint': 'Your HamQTH account login — the same as the callbook lookup. Leave the callsign empty unless the account holds several logbooks.', 'es.optional': 'optional', 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||
'es.hamqthCall': 'Logbook callsign', 'es.hamqthHint': 'Your HamQTH account login — the same as the callbook lookup. Leave the callsign empty unless the account holds several logbooks.', 'es.optional': 'optional', 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.showPass': 'Show password', 'es.hidePass': 'Hide password', 'es.apiKey': 'API key', 'es.cloudlogUrl': 'Instance URL', 'es.cloudlogUrlPh': 'https://log.example.com', 'es.cloudlogStationId': 'Station ID', 'es.cloudlogKeyPh': 'read/write API key', 'es.hamlogClosed': 'HAMLOG.online no longer issues API keys, so uploading is not possible. Their confirmations can still be imported from a file: QSL Manager → HAMLOG.online → Import confirmations.', 'es.hamlogHint': 'Your personal API key, created at', 'es.hamlogCheckKey': 'Check the key', 'es.hamlogKeyOk': 'Key is valid', 'es.hamlogKeyOkCall': 'Key is valid — account {call}', 'es.cloudlogHint': 'Cloudlog and Wavelog are self-hosted: give the address of YOUR instance (an IP works on a LAN). The API key is created under Account → API Keys and must be read/write; the station ID is the number of the station location the QSOs are filed under (Station Locations page). Duplicates are rejected by the server, so re-sending a QSO is harmless.', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||
// External-services placeholders + HRDLOG / eQSL / LoTW / POTA tabs
|
||||
'es.qrzApiPh': 'QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'e.g. F4BPO — optional', 'es.callDefaultPh': "defaults to the active profile's callsign",
|
||||
'es.clubEmailPh': 'your Club Log account email', 'es.clubPwPh': 'Club Log account password',
|
||||
@@ -535,7 +535,7 @@ const en: Dict = {
|
||||
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
||||
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
||||
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
||||
'aud.dvkTitle': 'Voice keyer messages (F1–F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||
'aud.dvkTitle': 'Voice keyer messages (F1–F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttData': 'Kenwood: the keyer’s audio arrives on the DATA / USB input', 'aud.pttDataHint': 'Sends TX1 (ACC2/USB) instead of TX (front microphone) — the TS-590 family’s second transmit command. Without it the radio transmits while listening to a microphone nobody is speaking into. Kenwood only: no other backend has the distinction.', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
||||
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
|
||||
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
|
||||
@@ -651,7 +651,7 @@ const fr: Dict = {
|
||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
|
||||
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
|
||||
'appr.matrixEnable': 'Choisir les couleurs de la matrice bandes/modes', 'appr.matrixHint': '(la grille PH/CW/DIG des Stats — décoché, chaque thème garde les siennes)',
|
||||
'appr.matrixSample': 'Aperçu', 'appr.matrixReset': 'Revenir aux couleurs du thème',
|
||||
// Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la
|
||||
@@ -682,7 +682,7 @@ const fr: Dict = {
|
||||
'mx.tipThisCall': 'déjà contacté avec cet indicatif',
|
||||
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
|
||||
// Panneau des decodes FTx (Outils -> Decodes FT)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement',
|
||||
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
||||
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
||||
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
||||
@@ -900,7 +900,7 @@ const fr: Dict = {
|
||||
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||
'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.",
|
||||
'es.hamqthCall': 'Indicatif du log', 'es.hamqthHint': 'Vos identifiants HamQTH — les mêmes que pour le lookup. Laissez l’indicatif vide sauf si le compte héberge plusieurs logs.', 'es.optional': 'optionnel', 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||
'es.hamqthCall': 'Indicatif du log', 'es.hamqthHint': 'Vos identifiants HamQTH — les mêmes que pour le lookup. Laissez l’indicatif vide sauf si le compte héberge plusieurs logs.', 'es.optional': 'optionnel', 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.showPass': 'Afficher le mot de passe', 'es.hidePass': 'Masquer le mot de passe', 'es.apiKey': 'Clé API', 'es.cloudlogUrl': "Adresse de l'instance", 'es.cloudlogUrlPh': 'https://log.exemple.fr', 'es.cloudlogStationId': 'ID de station', 'es.cloudlogKeyPh': 'clé API lecture/écriture', 'es.hamlogClosed': 'HAMLOG.online ne délivre plus de clé API : l’envoi n’est donc pas possible. Leurs confirmations restent importables depuis un fichier : QSL Manager → HAMLOG.online → Importer les confirmations.', 'es.hamlogHint': 'Ta clé API personnelle, à créer sur', 'es.hamlogCheckKey': 'Vérifier la clé', 'es.hamlogKeyOk': 'Clé valide', 'es.hamlogKeyOkCall': 'Clé valide — compte {call}', 'es.cloudlogHint': "Cloudlog et Wavelog sont auto-hébergés : indiquez l'adresse de VOTRE instance (une IP convient en réseau local). La clé API se crée dans Compte → API Keys et doit être en lecture/écriture ; l'ID de station est le numéro de l'emplacement sous lequel les QSO sont classés (page Station Locations). Les doublons sont refusés par le serveur, renvoyer un QSO est donc sans risque.", 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||
// Placeholders services externes + onglets HRDLOG / eQSL / LoTW / POTA
|
||||
'es.qrzApiPh': 'Clé API du logbook QRZ.com (XXXX-XXXX-XXXX-XXXX)', 'es.forceCallPh': 'p. ex. F4BPO — optionnel', 'es.callDefaultPh': "par défaut : l'indicatif du profil actif",
|
||||
'es.clubEmailPh': 'e-mail de ton compte Club Log', 'es.clubPwPh': 'mot de passe du compte Club Log',
|
||||
@@ -1037,7 +1037,7 @@ const fr: Dict = {
|
||||
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
||||
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
||||
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttData': 'Kenwood : l’audio du keyer arrive sur l’entrée DATA / USB', 'aud.pttDataHint': 'Envoie TX1 (ACC2/USB) au lieu de TX (micro de face avant) — la seconde commande d’émission de la famille TS-590. Sans cela la radio émet en écoutant un micro devant lequel personne ne parle. Kenwood uniquement : aucun autre backend n’a cette distinction.', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
||||
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
|
||||
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.27.8';
|
||||
export const APP_VERSION = '0.27.11';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -1924,6 +1924,7 @@ export namespace main {
|
||||
preroll_seconds: number;
|
||||
ptt_method: string;
|
||||
ptt_port: string;
|
||||
ptt_data: boolean;
|
||||
format: string;
|
||||
from_gain: number;
|
||||
mic_gain: number;
|
||||
@@ -1945,6 +1946,7 @@ export namespace main {
|
||||
this.preroll_seconds = source["preroll_seconds"];
|
||||
this.ptt_method = source["ptt_method"];
|
||||
this.ptt_port = source["ptt_port"];
|
||||
this.ptt_data = source["ptt_data"];
|
||||
this.format = source["format"];
|
||||
this.from_gain = source["from_gain"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
|
||||
@@ -256,6 +256,33 @@ func (m *Manager) SetPTT(on bool) error {
|
||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||
}
|
||||
|
||||
// dataPTTSetter is implemented by a backend that can key the DATA input rather
|
||||
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
|
||||
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
|
||||
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
|
||||
// because the audio arriving on USB is simply not the input it is listening to.
|
||||
type dataPTTSetter interface {
|
||||
SetPTTData(on bool) error
|
||||
}
|
||||
|
||||
// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
|
||||
//
|
||||
// data=true means "the audio reaches the radio on its data/USB input" — what a
|
||||
// voice keyer playing through the rig's own sound card needs. A backend that
|
||||
// draws no distinction (every rig where one PTT is all there is) falls back to
|
||||
// the ordinary key, so nothing changes for it.
|
||||
func (m *Manager) SetPTTSource(on, data bool) error {
|
||||
if !data {
|
||||
return m.SetPTT(on)
|
||||
}
|
||||
return m.exec(func(b Backend) error {
|
||||
if d, ok := b.(dataPTTSetter); ok {
|
||||
return d.SetPTTData(on)
|
||||
}
|
||||
return b.SetPTT(on)
|
||||
})
|
||||
}
|
||||
|
||||
// splitSetter is implemented by the backends that can arm split AND place the
|
||||
// transmit frequency. Both together: arming without setting the dial transmits
|
||||
// on whatever the transmit VFO happened to hold, which is worse than refusing.
|
||||
|
||||
@@ -632,6 +632,28 @@ func (k *Kenwood) SetPTT(on bool) error {
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
// SetPTTData keys the transmitter on the DATA input: TX1 on a TS-590, which is
|
||||
// ACC2/USB rather than the front microphone. The radio's own manual is explicit
|
||||
// that the parameter chooses the input — "0: SEND (normal transmission using
|
||||
// the MIC input), 1: DATA SEND (ACC2/USB input)" — so a voice keyer playing
|
||||
// into the rig's USB codec has to say TX1 or it transmits dead air while the
|
||||
// radio listens to a microphone nobody is speaking into.
|
||||
//
|
||||
// Unkeying is the same RX either way; there is no data-flavoured stop.
|
||||
func (k *Kenwood) SetPTTData(on bool) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
k.tx = on
|
||||
if on {
|
||||
k.txAt = time.Now()
|
||||
return k.write("TX1;")
|
||||
}
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
func (k *Kenwood) write(cmd string) error {
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
|
||||
@@ -388,7 +388,14 @@ func (y *Yaesu) RefreshYaesu() error {
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuPower(w int) error {
|
||||
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
|
||||
// The SAME ceiling the console draws its slider to. It was hard-coded at 100
|
||||
// here while yaesuMaxPower already answered 200 for an FTDX101MP: asking for
|
||||
// 200 W sent PC100, the rig obeyed, and the slider sprang back to 100 on the
|
||||
// next poll — which is how the operator discovered it.
|
||||
y.mu.Lock()
|
||||
max := yaesuMaxPower(y.model, y.panel.RFPower)
|
||||
y.mu.Unlock()
|
||||
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, max)))
|
||||
}
|
||||
|
||||
func (y *Yaesu) SetYaesuMicGain(p int) error {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// Reported on an FTDX101MP: the power slider sprang back to 100 W. The console
|
||||
// already knew the rig could do 200 — yaesuMaxPower says so — but the SET path
|
||||
// clamped to 100, so the radio was politely given half what was asked for.
|
||||
func TestYaesuPowerCeilingFollowsTheModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
model string
|
||||
want int
|
||||
}{
|
||||
{"FTDX101MP", 200},
|
||||
{"FT-DX5000", 200},
|
||||
{"FTDX9000", 200},
|
||||
{"FTDX101D", 100},
|
||||
{"FTDX10", 100},
|
||||
{"Yaesu (0999)", 100}, // unknown: ask for too little, never too much
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := yaesuMaxPower(c.model, 0); got != c.want {
|
||||
t.Errorf("%s ceiling = %d W, want %d", c.model, got, c.want)
|
||||
}
|
||||
}
|
||||
// A rig REPORTING more than the table expects has just proved what it can do.
|
||||
if got := yaesuMaxPower("FTDX10", 150); got != 150 {
|
||||
t.Errorf("a rig reporting 150 W was capped at %d", got)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -40,6 +40,14 @@ type Match struct {
|
||||
Continent string `json:"continent"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
|
||||
//
|
||||
// The two carry very different authority. A prefix match is a rule of thumb
|
||||
// about a block of callsigns; an exact entry is somebody having looked at
|
||||
// THIS callsign and written down where it was. A second country file may
|
||||
// improve on the first kind and should not be allowed to overrule the
|
||||
// second.
|
||||
Exact bool `json:"exact,omitempty"`
|
||||
}
|
||||
|
||||
type prefixEntry struct {
|
||||
@@ -149,7 +157,9 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
|
||||
return Match{}, false
|
||||
}
|
||||
if e, ok := db.exact[call]; ok {
|
||||
return materialize(e), true
|
||||
m := materialize(e)
|
||||
m.Exact = true
|
||||
return m, true
|
||||
}
|
||||
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
||||
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -104,7 +105,26 @@ func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, a
|
||||
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
||||
}
|
||||
|
||||
// ErrHamlogClosed is why nothing is sent to HAMLOG.online any more.
|
||||
//
|
||||
// The site stopped issuing API keys, and the upload API takes nothing else. An
|
||||
// operator without a key cannot obtain one, and one WITH an old key is the
|
||||
// exception this cannot be built around — so the door is closed here rather
|
||||
// than left ajar for a request that can only fail.
|
||||
//
|
||||
// The code stays: their confirmations still arrive as an ADIF FILE (QSL Manager
|
||||
// → HAMLOG.online → Import confirmations), which never needed a key, and the
|
||||
// sent/received state already in operators' logs stays readable, filterable and
|
||||
// bulk-editable.
|
||||
var ErrHamlogClosed = errors.New("hamlog: HAMLOG.online no longer issues API keys, so uploading is not possible — their confirmations can still be imported from a file")
|
||||
|
||||
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||
return UploadResult{}, ErrHamlogClosed
|
||||
}
|
||||
|
||||
// uploadHamlogLive is the upload as it was, kept whole against the day keys
|
||||
// come back. Nothing calls it.
|
||||
func uploadHamlogLive(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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package extsvc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -28,7 +29,7 @@ func TestUploadHamlogRequestShape(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
||||
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,7 +65,7 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
||||
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>")
|
||||
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.body, err)
|
||||
@@ -80,8 +81,21 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
||||
|
||||
// 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>")
|
||||
_, err := uploadHamlogLive(context.Background(), nil, "http://example.invalid", ServiceConfig{}, "<eor>")
|
||||
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
||||
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
||||
}
|
||||
}
|
||||
|
||||
// The door is shut: HAMLOG.online stopped issuing API keys, so an upload that
|
||||
// could only fail is refused before it is attempted. The request-shape tests
|
||||
// above still cover the code kept against the day keys come back.
|
||||
func TestUploadHamlogIsClosed(t *testing.T) {
|
||||
res, err := UploadHamlog(context.Background(), nil, ServiceConfig{APIKey: "KEY123"}, "<eor>")
|
||||
if !errors.Is(err, ErrHamlogClosed) {
|
||||
t.Fatalf("err = %v, want ErrHamlogClosed", err)
|
||||
}
|
||||
if res.OK {
|
||||
t.Error("a refused upload reported success")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-12
@@ -87,6 +87,7 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
hamlogClosedOnce sync.Once
|
||||
}
|
||||
|
||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||
@@ -230,13 +231,13 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
m.route(ServiceCloudlog, id, c)
|
||||
}
|
||||
}
|
||||
// HAMLOG.online — one API key and nothing else to get wrong.
|
||||
if h := cfg.Hamlog; h.AutoUpload {
|
||||
if h.APIKey == "" {
|
||||
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
|
||||
} else {
|
||||
m.route(ServiceHamlog, id, h)
|
||||
}
|
||||
// HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
|
||||
// session rather than per QSO, because an operator who left the switch on
|
||||
// deserves to know why nothing leaves, and does not deserve it every minute.
|
||||
if cfg.Hamlog.AutoUpload {
|
||||
m.hamlogClosedOnce.Do(func() {
|
||||
m.logf("extsvc: %v", ErrHamlogClosed)
|
||||
})
|
||||
}
|
||||
// HamQTH — the callbook credentials double as the logbook login.
|
||||
if h := cfg.HamQTH; h.AutoUpload {
|
||||
@@ -296,9 +297,7 @@ func (m *Manager) onCloseServices() []Service {
|
||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||
out = append(out, ServiceCloudlog)
|
||||
}
|
||||
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
|
||||
out = append(out, ServiceHamlog)
|
||||
}
|
||||
|
||||
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
|
||||
out = append(out, ServiceHamQTH)
|
||||
}
|
||||
@@ -348,8 +347,7 @@ func (m *Manager) FlushOnClose() int {
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||
case ServiceCloudlog:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||
case ServiceHamlog:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
|
||||
|
||||
case ServiceHamQTH:
|
||||
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
|
||||
}
|
||||
|
||||
@@ -113,3 +113,27 @@ func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
|
||||
ev.DecodeModeRaw, "~")
|
||||
}
|
||||
}
|
||||
|
||||
// Reported from a real shack: JTDX decoding FT4 showed up as Q65.
|
||||
//
|
||||
// The two forks disagree about ":" — Q65 in WSJT-X, FT4 in JTDX — so the
|
||||
// character alone cannot answer, and the wrong answer poisons every verdict
|
||||
// that follows: new mode, new slot, the mode filter. The program's own Status
|
||||
// names the mode in full and settles it.
|
||||
func TestAmbiguousModeCharDefersToTheProgram(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, status, want, why string
|
||||
}{
|
||||
{":", "FT4", "FT4", "JTDX decoding FT4"},
|
||||
{":", "Q65", "Q65", "WSJT-X decoding Q65"},
|
||||
{":", "", "Q65", "no Status yet — WSJT-X's reading, the older and commoner"},
|
||||
// The unambiguous markers are unaffected, Status or no Status.
|
||||
{"~", "FT4", "FT8", "a tilde is FT8 in both"},
|
||||
{"+", "", "FT4", "a plus is FT4 in both"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DecodeModeName(c.raw, c.status); got != c.want {
|
||||
t.Errorf("DecodeModeName(%q, %q) = %q, want %q — %s", c.raw, c.status, got, c.want, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,10 +523,24 @@ var decodeModeChar = map[string]string{
|
||||
"#": "JT65",
|
||||
"@": "JT9",
|
||||
"&": "MSK144",
|
||||
":": "Q65",
|
||||
"`": "FST4",
|
||||
}
|
||||
|
||||
// ambiguousModeChar is a marker the forks do not agree on.
|
||||
//
|
||||
// ":" is Q65 in WSJT-X and FT4 in JTDX, so the character alone cannot answer:
|
||||
// a JTDX operator decoding FT4 was told they were on Q65, and every verdict
|
||||
// downstream — new mode, new slot, the mode filter — was computed against a
|
||||
// mode nobody was using.
|
||||
//
|
||||
// The sender's own Status settles it. It comes from the same program, names the
|
||||
// mode in full, and is re-sent whenever it changes, so it knows what that
|
||||
// program is decoding in a way one character never can. The fallback is
|
||||
// WSJT-X's reading, which is the older and commoner one.
|
||||
var ambiguousModeChar = map[string]string{
|
||||
":": "Q65",
|
||||
}
|
||||
|
||||
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
|
||||
// is the mode from the same program's last Status, used when the field is a
|
||||
// marker we do not know, or empty.
|
||||
@@ -535,6 +549,13 @@ func DecodeModeName(raw, statusMode string) string {
|
||||
if m, ok := decodeModeChar[raw]; ok {
|
||||
return m
|
||||
}
|
||||
if fallback, ok := ambiguousModeChar[raw]; ok {
|
||||
// Believe the program over the character it happened to print.
|
||||
if st := strings.ToUpper(strings.TrimSpace(statusMode)); st != "" {
|
||||
return st
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
|
||||
// Anything shorter, or carrying punctuation, is a marker rather than a name.
|
||||
if len(raw) >= 2 {
|
||||
|
||||
@@ -167,9 +167,14 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
r.Callsign = call
|
||||
r.Source = p.Name()
|
||||
r.FetchedAt = time.Now().UTC()
|
||||
fillFromDXCC(&r, dxcc)
|
||||
normalizeNames(&r)
|
||||
// Cached BEFORE the cty.dat pass, so the row is a copy of the
|
||||
// callbook page rather than of our conclusions about it. Every
|
||||
// read runs the pass again (see the cache-hit path above), so a
|
||||
// later cty.dat update reaches old rows — and a value we derived
|
||||
// can never come back looking like something the page said.
|
||||
_ = m.cache.Put(ctx, r)
|
||||
fillFromDXCC(&r, dxcc)
|
||||
return r, nil
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
@@ -208,9 +213,9 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
if !saysNothingAboutLocation(call) {
|
||||
clearHomeLocation(&r)
|
||||
}
|
||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||
normalizeNames(&r)
|
||||
_ = m.cache.Put(ctx, r)
|
||||
_ = m.cache.Put(ctx, r) // the page as it was; cty.dat is applied on read
|
||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
@@ -441,11 +446,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
r.Continent = cont
|
||||
filled = true
|
||||
}
|
||||
if cqz != 0 {
|
||||
// Zones FILL, they do not override.
|
||||
//
|
||||
// The rule above is right for the country and wrong for the zones, because
|
||||
// they answer different questions. An entity is what a callsign IS, and
|
||||
// cty.dat is the authority on that. A zone is where the station SITS, and a
|
||||
// large entity has many: Asiatic Russia spans CQ 16 to 23 and ITU 20 to 34,
|
||||
// and cty.dat carries one representative pair for the whole country. Stamping
|
||||
// it on every UA0 threw away the callbook's per-station answer and recorded a
|
||||
// WAZ credit for a zone the operator had not worked — RU0LL is CQ 19, ITU 34,
|
||||
// and was logged 17/30.
|
||||
//
|
||||
// So the callbook wins where it spoke, and cty.dat fills the silence.
|
||||
if cqz != 0 && r.CQZ == 0 {
|
||||
r.CQZ = cqz
|
||||
filled = true
|
||||
}
|
||||
if ituz != 0 {
|
||||
if ituz != 0 && r.ITUZ == 0 {
|
||||
r.ITUZ = ituz
|
||||
filled = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package lookup
|
||||
|
||||
import "testing"
|
||||
|
||||
// asiaticRussia stands in for cty.dat: one representative zone pair for a
|
||||
// country eight CQ zones wide.
|
||||
func asiaticRussia(cqz, ituz int) fakeDXCC {
|
||||
return fakeDXCC{"RU0LL": {num: 15, country: "Asiatic Russia", cont: "AS", cqz: cqz, ituz: ituz}}
|
||||
}
|
||||
|
||||
// A zone is where the station SITS; an entity is what the callsign IS. Asiatic
|
||||
// Russia spans CQ 16-23 and ITU 20-34, and cty.dat carries one representative
|
||||
// pair for the whole country — so stamping it over a callbook's per-station
|
||||
// answer records a WAZ credit for a zone the operator never worked.
|
||||
//
|
||||
// Reported with real callsigns: RU0LL is CQ 19 / ITU 34 and was logged 17/30.
|
||||
func TestCallbookZonesSurviveTheEntityDefault(t *testing.T) {
|
||||
// What QRZ said about this very station.
|
||||
r := Result{Callsign: "RU0LL", CQZ: 19, ITUZ: 34}
|
||||
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||
if r.CQZ != 19 || r.ITUZ != 34 {
|
||||
t.Errorf("callbook zones were overwritten: CQ%d ITU%d, want CQ19 ITU34", r.CQZ, r.ITUZ)
|
||||
}
|
||||
if r.Country != "Asiatic Russia" {
|
||||
t.Errorf("the ENTITY must still come from cty.dat, got %q", r.Country)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntityZonesFillSilence(t *testing.T) {
|
||||
// A callbook that says nothing about zones still gets an answer.
|
||||
r := Result{Callsign: "RU0LL"}
|
||||
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||
if r.CQZ != 17 || r.ITUZ != 30 {
|
||||
t.Errorf("empty zones were not filled: CQ%d ITU%d", r.CQZ, r.ITUZ)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
@@ -71,32 +72,99 @@ func NewSerial(comPort string, baud int) *Client {
|
||||
return &Client{ComPort: comPort, Baud: baud}
|
||||
}
|
||||
|
||||
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
||||
// reply line.
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
if c.ComPort != "" {
|
||||
baud := c.Baud
|
||||
// bootSettle is how long a freshly opened serial port is left alone before the
|
||||
// first command.
|
||||
//
|
||||
// An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
|
||||
// the serial port is opened: the DTR line pulses its reset pin, and the
|
||||
// bootloader then holds the processor for a second or more. A command sent into
|
||||
// that window is simply lost, which is exactly how a controller that answers
|
||||
// PuTTY perfectly reports "no reply" here.
|
||||
const bootSettle = 2 * time.Second
|
||||
|
||||
// heldPort is an open serial port, kept between calls.
|
||||
//
|
||||
// The package holds it rather than the Client because the callers build a FRESH
|
||||
// Client for every poll (one per heading request), and the port has to outlive
|
||||
// them. Reopening per command is what made an Arduino controller reboot several
|
||||
// times a second and never answer anything. A serial port is a single-owner
|
||||
// resource in any case: two clients for COM5 would be two handles on one cable.
|
||||
type heldPort struct {
|
||||
p serial.Port
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
portsMu sync.Mutex
|
||||
openPorts = map[string]*heldPort{}
|
||||
)
|
||||
|
||||
// acquire returns the open port for com, opening it if needed.
|
||||
func acquire(com string, baud int) (*heldPort, error) {
|
||||
portsMu.Lock()
|
||||
defer portsMu.Unlock()
|
||||
if h, ok := openPorts[com]; ok && h.p != nil {
|
||||
return h, nil
|
||||
}
|
||||
if baud <= 0 {
|
||||
baud = 9600
|
||||
}
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
|
||||
}
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
conn = sp
|
||||
h := &heldPort{p: sp, openedAt: time.Now()}
|
||||
openPorts[com] = h
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// drop closes and forgets a port, so the next call opens a fresh one. Called
|
||||
// when an exchange fails: a half-spoken conversation is worse than a new one.
|
||||
func drop(com string) {
|
||||
portsMu.Lock()
|
||||
defer portsMu.Unlock()
|
||||
if h, ok := openPorts[com]; ok {
|
||||
if h.p != nil {
|
||||
_ = h.p.Close()
|
||||
}
|
||||
delete(openPorts, com)
|
||||
}
|
||||
}
|
||||
|
||||
// roundTrip sends one CR-terminated command and (when wantReply) reads one
|
||||
// CR/LF-terminated reply line. Serial keeps its port open between calls; TCP
|
||||
// dials per call, which is what the ARCO's LAN side expects.
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
if c.ComPort != "" {
|
||||
h, err := acquire(c.ComPort, c.Baud)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Let a just-reset controller finish booting before speaking to it.
|
||||
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
conn = h.p
|
||||
// Whatever is already in the buffer belongs to the last exchange — the
|
||||
// trailing LF of the previous reply, or a line the controller volunteered
|
||||
// while nobody was reading. Read as the answer to THIS command it would
|
||||
// be an answer to the wrong question.
|
||||
drain(h.p)
|
||||
} else {
|
||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
||||
}
|
||||
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||
defer nc.Close()
|
||||
conn = nc
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
||||
if c.ComPort != "" {
|
||||
drop(c.ComPort)
|
||||
}
|
||||
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||
}
|
||||
if !wantReply {
|
||||
@@ -121,11 +189,29 @@ func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
}
|
||||
line := strings.TrimSpace(sb.String())
|
||||
if line == "" {
|
||||
// Silence may mean the port is fine and the controller is not, or that
|
||||
// the handle is stale (a USB adapter unplugged and replugged). Let go of
|
||||
// it so the next attempt starts from a clean open rather than repeating
|
||||
// the same silence for ever.
|
||||
if c.ComPort != "" {
|
||||
drop(c.ComPort)
|
||||
}
|
||||
return "", fmt.Errorf("no reply to %q", cmd)
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// drain empties whatever is waiting, without blocking for long.
|
||||
func drain(sp serial.Port) {
|
||||
buf := make([]byte, 128)
|
||||
for i := 0; i < 4; i++ {
|
||||
n, err := sp.Read(buf)
|
||||
if n == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
||||
// (overlap rotators accept >360); we normalise to [0,360).
|
||||
func (c *Client) GoTo(az int) error {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package gs232
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Real replies, as the controllers actually send them — a K3NG answering "C"
|
||||
// with "+0140" and CR+LF among them (reported from a live controller).
|
||||
func TestAzimuthReplies(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"+0140\r\n", 140}, // GS-232A, K3NG firmware
|
||||
{"+0000\r", 0}, // due north
|
||||
{"+0359\r\n", 359}, // just short of it
|
||||
{"AZ=140\r\n", 140}, // GS-232B flavour
|
||||
{"AZ=140 EL=000\r\n", 140}, // GS-232B with elevation on the same line
|
||||
{"\r\n+0075\r\n", 75}, // a leftover terminator ahead of the answer
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := azRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||
if m == nil {
|
||||
t.Errorf("no azimuth found in %q", c.raw)
|
||||
continue
|
||||
}
|
||||
got, _ := strconv.Atoi(m[1])
|
||||
if got%360 != c.want {
|
||||
t.Errorf("%q parsed as %d, want %d", c.raw, got%360, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.8"
|
||||
appVersion = "0.27.11"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user