Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f05e6290df | ||
|
|
a89be4b86e | ||
|
|
dbeabc1bae | ||
|
|
6f1c998a26 | ||
|
|
d185b10559 | ||
|
|
41a2a38025 | ||
|
|
f8f8de4a30 | ||
|
|
82cd5c5d0b | ||
|
|
612e265837 | ||
|
|
3415e12363 | ||
|
|
e0b110392a |
@@ -121,6 +121,12 @@ const (
|
||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
|
||||
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
|
||||
// OmniRig's model has two CW modes — PM_CW_U and PM_CW_L — and nothing says
|
||||
// which one an .ini file calls plain CW. Icom files disagree with each other:
|
||||
// on some, PM_CW_U is CI-V mode 0x03 (CW) and on others it is 0x07 (CW-R).
|
||||
// An operator clicking a CW spot on an IC-7610 landed in CW-R and had to edit
|
||||
// the rig file to get out of it. This says which bit means CW on THIS rig.
|
||||
keyCATOmniRigCWLower = "cat.omnirig.cw_lower" // "1" → send PM_CW_L for CW
|
||||
// Put the RADIO in USB for a digital mode instead of asking for the mode by
|
||||
// name. Every backend, because the reason is the radio, not the link.
|
||||
keyCATDigiUSB = "cat.digi_usb"
|
||||
@@ -483,6 +489,9 @@ type CATSettings struct {
|
||||
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
|
||||
// operator worked on the main VFO, so OpsLog wrote to A and read B.
|
||||
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
|
||||
// OmniRigCWLower sends PM_CW_L instead of PM_CW_U when the mode is CW — for
|
||||
// the rig files whose PM_CW_U is the REVERSE sideband. See the key comment.
|
||||
OmniRigCWLower bool `json:"omnirig_cw_lower"`
|
||||
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
|
||||
// than naming the mode. What the QSO is LOGGED as never changes.
|
||||
DigiAsUSB bool `json:"digi_as_usb"`
|
||||
@@ -2979,8 +2988,11 @@ func (a *App) RestartApp() error {
|
||||
return fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
// --relaunch: the child waits for OUR mutex instead of declaring us a
|
||||
// duplicate — this instance is quitting, just not always fast enough.
|
||||
cmd := exec.Command(exe, "--relaunch")
|
||||
// duplicate — this instance is quitting, just not always fast enough. And
|
||||
// --wait-pid, so it waits for this process to actually END rather than for a
|
||||
// window of time to pass: shutting down closes a logbook and a CAT session,
|
||||
// and on a remote database that takes as long as it takes.
|
||||
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
cmd.Dir = filepath.Dir(exe)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||
@@ -8363,7 +8375,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATOmniRigCWLower, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -8469,6 +8481,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if out.DigitalDefault == "" {
|
||||
out.DigitalDefault = "FT8"
|
||||
}
|
||||
out.OmniRigCWLower = m[keyCATOmniRigCWLower] == "1"
|
||||
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
|
||||
out.OmniRigVFO = v
|
||||
}
|
||||
@@ -8585,6 +8598,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||
keyCATOmniRigCWLower: boolStr(s.OmniRigCWLower),
|
||||
keyCATDigiUSB: boolStr(s.DigiAsUSB),
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
@@ -11136,6 +11150,19 @@ func (a *App) UILog(msg string) {
|
||||
// that hasn't customised them: request the online confirmations (eQSL/LoTW/
|
||||
// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and
|
||||
// "N" for paper and everything received.
|
||||
// defaultQSLDefaults is the status stamped on a new QSO when the operator has
|
||||
// not chosen otherwise.
|
||||
//
|
||||
// One rule, for every service: the SENT side starts at R (requested — this
|
||||
// contact is waiting to go out) and the RECEIVED side at N (nothing has come
|
||||
// back yet). Paper QSL is the exception on the sent side: a card is only
|
||||
// "requested" once somebody asks for one.
|
||||
//
|
||||
// The sent side must NEVER default to Y. Y means "already sent", so the
|
||||
// uploader skips the contact for ever — which is exactly how an operator ends
|
||||
// up with a logbook that never reaches eQSL and no error to explain it. Every
|
||||
// service added here has to appear in this list for that reason; a service left
|
||||
// out gets an empty status, which is a state nobody chose.
|
||||
func defaultQSLDefaults() QSLDefaults {
|
||||
return QSLDefaults{
|
||||
QSLSent: "N", QSLRcvd: "N",
|
||||
@@ -11143,10 +11170,40 @@ func defaultQSLDefaults() QSLDefaults {
|
||||
LOTWSent: "R", LOTWRcvd: "N",
|
||||
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||
HamqthStatus: "R",
|
||||
HamlogStatus: "R", HamlogCfm: "N",
|
||||
QRZComStatus: "R", QRZComCfm: "N",
|
||||
}
|
||||
}
|
||||
|
||||
// fillMissingQSLDefaults replaces an empty status with the shipped one.
|
||||
//
|
||||
// A service added to OpsLog after an operator last saved their confirmations
|
||||
// has no stored value, so it came back blank — and blank is not one of the
|
||||
// statuses anything acts on, it is the absence of one. HAMLOG.online arrived
|
||||
// that way and every existing profile got nothing for it.
|
||||
//
|
||||
// An operator who genuinely wants a column left alone has "N" for that, which
|
||||
// says the same thing and is a status. So an empty value here means "this was
|
||||
// never asked", and the answer is what a fresh profile would have got.
|
||||
func fillMissingQSLDefaults(d QSLDefaults) QSLDefaults {
|
||||
def := defaultQSLDefaults()
|
||||
for _, f := range []struct{ cur, def *string }{
|
||||
{&d.QSLSent, &def.QSLSent}, {&d.QSLRcvd, &def.QSLRcvd},
|
||||
{&d.LOTWSent, &def.LOTWSent}, {&d.LOTWRcvd, &def.LOTWRcvd},
|
||||
{&d.EQSLSent, &def.EQSLSent}, {&d.EQSLRcvd, &def.EQSLRcvd},
|
||||
{&d.ClublogStatus, &def.ClublogStatus}, {&d.ClublogCfm, &def.ClublogCfm},
|
||||
{&d.HRDLogStatus, &def.HRDLogStatus},
|
||||
{&d.QRZComStatus, &def.QRZComStatus}, {&d.QRZComCfm, &def.QRZComCfm},
|
||||
{&d.HamlogStatus, &def.HamlogStatus}, {&d.HamlogCfm, &def.HamlogCfm},
|
||||
{&d.HamqthStatus, &def.HamqthStatus},
|
||||
} {
|
||||
if strings.TrimSpace(*f.cur) == "" {
|
||||
*f.cur = *f.def
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out := QSLDefaults{}
|
||||
if a.settings == nil {
|
||||
@@ -11183,7 +11240,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
|
||||
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
|
||||
out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
|
||||
return out, nil
|
||||
return fillMissingQSLDefaults(out), nil
|
||||
}
|
||||
|
||||
// SaveQSLDefaults persists the configured defaults. Future QSO inserts
|
||||
@@ -16292,9 +16349,25 @@ func (a *App) reloadCAT() {
|
||||
if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
|
||||
_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return nil })
|
||||
}
|
||||
// Nothing about the link changed → leave it connected. See catLinkSig.
|
||||
if s.Enabled && s.Backend == "omnirig" && a.cat != nil {
|
||||
_ = a.cat.OmniRigDo(func(o cat.OmniRigController) error { o.SetCWLower(s.OmniRigCWLower); return nil })
|
||||
}
|
||||
// Nothing about the link changed → leave it as it is. See catLinkSig.
|
||||
if sig := catLinkSig(s); sig == a.catSig {
|
||||
applog.Printf("cat: settings saved, link unchanged — staying connected")
|
||||
// Which is NOT the same as staying connected.
|
||||
//
|
||||
// A disabled CAT has the signature "off", so every save while the master
|
||||
// switch is off took this branch and announced that the link was
|
||||
// unchanged and connected — when nothing was connected at all. A Flex
|
||||
// 6700 owner saved six times, with the right radio, the right address
|
||||
// and the detector naming his radio back at him, and this line told him
|
||||
// each time that all was well. It was the one line that could have said
|
||||
// otherwise.
|
||||
if !s.Enabled {
|
||||
applog.Printf("cat: settings saved, but CAT is switched OFF — nothing will connect until you tick it in Settings ▸ CAT")
|
||||
} else {
|
||||
applog.Printf("cat: settings saved, link unchanged — staying connected")
|
||||
}
|
||||
return
|
||||
} else {
|
||||
a.catSig = sig
|
||||
@@ -16316,7 +16389,7 @@ func (a *App) reloadCAT() {
|
||||
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
|
||||
// reloadCAT raised the existing instance's window to the front,
|
||||
// which is what Log4OM avoids by relying entirely on COM activation.
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO, s.OmniRigCWLower))
|
||||
case "flex":
|
||||
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
|
||||
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.19",
|
||||
"date": "",
|
||||
"en": [
|
||||
"After an update, OpsLog starts again. The fix that stopped Defender calling the updater a trojan removed the helper that waited for the old process to die, and nothing took over the job: the new instance was patient with the single-instance lock for twenty seconds while the old one is allowed thirty to shut down — closing a remote logbook, a CAT session, sometimes a backup. Where that ran long the new process gave up in silence, leaving no window and a leftover OpsLog in the task manager. It now waits for the previous process itself, ending the instant it does; and if it really has not gone, it says so instead of claiming OpsLog is already running.",
|
||||
"A UDP row set to multicast on an address that is not one now listens anyway. 127.0.0.1 in the group box is the common mistake — it is the address every other field in every other program wants — but a multicast group runs 224.0.0.0 to 239.255.255.255, and joining anything else failed on every interface with a Windows error naming nothing the operator had typed. The row simply did not run. It now listens on unicast, which is what such an address means, and says so in the log.",
|
||||
"The confirmation defaults added for the newest services were blank. HAMLOG.online arrived after most profiles were set up, so it had no default at all — and blank is not a status anybody chose. Every service now starts the same way: the sent side at R (waiting to go out), the received side at N. A blank left by a service that did not exist when you last saved is filled in; a status you chose yourself is untouched.",
|
||||
"OmniRig: a setting for rig files whose CW is the reverse one. OmniRig has two CW modes and nothing says which one a rig file calls plain CW — some Icom files map PM_CW_U to CW, others to CW-R — so clicking a CW spot on an IC-7610 landed the radio in CW-R, and the only way out was to edit the rig file. Settings → CAT → OmniRig now has a tick box for it, applied at once without dropping the link. (If your VFOs read the wrong way round on the same rig, the VFO override beside it is the answer: rig files disagree there too.)",
|
||||
"Choosing a radio now switches CAT on. The master switch sits above the radio dropdown, and leaving it off while you pick your brand, type the address and run the detector — which finds your radio and prints its name — is a trap: a Flex 6700 owner did exactly that, saved six times, and got no link and no error. Picking a radio, or clicking one the detector found, ticks it. The panel also says so plainly while it is off, and the log line that used to announce \"link unchanged, staying connected\" when nothing was connected now says CAT is switched off.",
|
||||
"My rig and my antenna are dropdowns now, in the entry form and in the QSO editor, offering what you declared in Settings → Operating conditions — and the antennas of the rig you picked, since that is what they hang off. Typing them again on every contact was both work and a source of spellings that do not match: \"IC-7610\", \"IC 7610\" and \"ic7610\" are three different rigs to an award and to a filter. Free text still works, for a QSO made from somebody else's station.",
|
||||
"The FT decodes list is capped at 2000 rows. The rolling half hour is not a limit on a crowded evening — three decoders fill it with several thousand — and the panel slowed down long before anything aged out, since every row is a layout, a status and a distance. Past two thousand the oldest go, which is what has already been scrolled past.",
|
||||
"The rotor dial sits on the panel instead of punching a hole in it. It was drawn on a full black square, which read as a tile dropped into the widget rather than an instrument on it; it is a disc now, and the corners are whatever it is sitting on. The continents are brighter too — at the old shade the land was about eight per cent lighter than the sea, technically a map and practically a dark square with a suggestion in it."
|
||||
],
|
||||
"fr": [
|
||||
"Après une mise à jour, OpsLog redémarre. Le correctif qui a fait cesser la détection en cheval de Troie a supprimé l'assistant qui attendait la mort de l'ancien processus, et rien n'a repris ce travail : la nouvelle instance patientait vingt secondes sur le verrou d'instance unique alors que l'ancienne dispose de trente pour se fermer — elle referme un journal distant, une session CAT, parfois une sauvegarde. Quand cela durait, le nouveau processus abandonnait en silence : pas de fenêtre, et un OpsLog restant dans le gestionnaire des tâches. Il attend désormais l'ancien processus lui-même, et repart à l'instant où celui-ci s'arrête ; et s'il n'est vraiment pas parti, il le dit au lieu d'annoncer qu'OpsLog tourne déjà.",
|
||||
"Une ligne UDP réglée en multicast sur une adresse qui n'en est pas une écoute désormais quand même. 127.0.0.1 dans le champ groupe est l'erreur classique — c'est l'adresse que réclame tout autre champ de tout autre programme — mais un groupe multicast va de 224.0.0.0 à 239.255.255.255, et rejoindre autre chose échouait sur toutes les interfaces avec une erreur Windows ne nommant rien de ce que l'opérateur avait saisi. La ligne ne tournait tout simplement pas. Elle écoute maintenant en unicast, ce que veut dire une telle adresse, et le dit dans le journal.",
|
||||
"Les statuts par défaut des services les plus récents étaient vides. HAMLOG.online est arrivé après la configuration de la plupart des profils : il n'avait donc aucun défaut — et vide n'est pas un statut que quelqu'un a choisi. Chaque service démarre désormais pareil : côté envoi R (en attente de départ), côté réception N. Un vide laissé par un service qui n'existait pas lors de votre dernier enregistrement est comblé ; un statut que vous avez choisi n'est pas touché.",
|
||||
"OmniRig : un réglage pour les fichiers de rig dont la CW est l'inverse. OmniRig a deux modes CW et rien ne dit lequel un fichier appelle CW tout court — certains fichiers Icom associent PM_CW_U à CW, d'autres à CW-R — si bien qu'un clic sur un spot CW mettait un IC-7610 en CW-R, sans autre issue que de modifier le fichier de rig. Réglages → CAT → OmniRig a désormais une case pour cela, appliquée aussitôt sans couper la liaison. (Si vos VFO sont inversés sur la même radio, le sélecteur de VFO juste à côté est la réponse : les fichiers de rig divergent là aussi.)",
|
||||
"Choisir une radio active désormais le CAT. L'interrupteur principal est au-dessus de la liste des radios, et le laisser éteint pendant qu'on choisit sa marque, saisit l'adresse et lance la détection — qui trouve la radio et affiche son nom — est un piège : un possesseur de Flex 6700 a fait exactement cela, enregistré six fois, sans liaison ni erreur. Choisir une radio, ou cliquer sur celle que la détection a trouvée, coche la case. Le panneau le dit aussi clairement tant qu'elle est décochée, et la ligne de journal qui annonçait « liaison inchangée, toujours connecté » alors que rien n'était connecté dit maintenant que le CAT est désactivé.",
|
||||
"Mon équipement et mon antenne sont désormais des listes déroulantes, dans la saisie comme dans l'éditeur de QSO, proposant ce que vous avez déclaré dans Réglages → Conditions de trafic — et les antennes du poste choisi, puisque c'est à lui qu'elles sont rattachées. Les retaper à chaque contact était à la fois du travail et une source d'orthographes divergentes : « IC-7610 », « IC 7610 » et « ic7610 » sont trois équipements différents pour un diplôme et pour un filtre. La saisie libre reste possible, pour un QSO fait depuis la station de quelqu'un d'autre.",
|
||||
"La liste des décodages FT est plafonnée à 2000 lignes. La demi-heure glissante n'est pas une limite un soir chargé — trois décodeurs la remplissent de plusieurs milliers — et le panneau ralentissait bien avant que quoi que ce soit n'expire, chaque ligne étant une mise en page, un statut et une distance. Au-delà de deux mille, les plus anciennes partent : celles qu'on a déjà dépassées en défilant.",
|
||||
"Le cadran du rotor se pose sur le panneau au lieu d'y percer un trou. Il était dessiné sur un carré noir plein, qui se lisait comme une tuile posée dans le widget plutôt que comme un instrument dessus ; c'est un disque désormais, et les coins sont ce sur quoi il repose. Les continents sont aussi plus clairs — à l'ancienne teinte, la terre était environ huit pour cent plus claire que la mer : techniquement une carte, en pratique un carré sombre avec une suggestion dedans."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.18",
|
||||
"date": "",
|
||||
|
||||
+12
-1
@@ -2594,6 +2594,15 @@ export default function App() {
|
||||
// half hour — long enough to hold a whole opening, short enough that a night
|
||||
// of FT8 on 20 m does not turn the list into something no filter can rescue.
|
||||
const DECODE_KEEP_MS = 30 * 60 * 1000;
|
||||
// And a hard ceiling on the count, because the half hour is not one on a
|
||||
// crowded band.
|
||||
//
|
||||
// Three decoders on an open evening put several thousand rows in that window,
|
||||
// and the panel slows down long before the age limit removes any of them:
|
||||
// every one is a row to lay out, a status to resolve and a distance to work
|
||||
// out. Two thousand is more than a screen can hold many times over, and past
|
||||
// it the oldest go — the newest period is what an operator is reading.
|
||||
const DECODE_MAX = 2000;
|
||||
const [decodes, setDecodes] = useState<DecodeRow[]>([]);
|
||||
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
|
||||
// The LIVE transmit state, replaced on every Status — what is going out now
|
||||
@@ -3887,7 +3896,9 @@ export default function App() {
|
||||
return !b2 || (d.band ?? '').toLowerCase() === b2;
|
||||
});
|
||||
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
|
||||
return next;
|
||||
// Oldest first in this list, so the ceiling is applied from the front:
|
||||
// what goes is what was already scrolled past.
|
||||
return next.length > DECODE_MAX ? next.slice(next.length - DECODE_MAX) : next;
|
||||
});
|
||||
};
|
||||
flushDecodesRef.current = () => { void flushDecodes(); };
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useOperatingLists } from '@/lib/operatingLists';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -158,6 +159,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
||||
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||
const { t } = useI18n();
|
||||
const oper = useOperatingLists(tab);
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
@@ -476,11 +478,21 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{/* The rigs and antennas already declared in Settings ▸ Operating
|
||||
conditions. Typing them again on every contact is both work and a
|
||||
source of spellings that do not match — "IC-7610", "IC 7610" and
|
||||
"ic7610" are three different rigs to an award and to a filter.
|
||||
Free text stays allowed: a QSO made from somebody else's station
|
||||
carries a rig that was never in this tree. */}
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
<Combobox value={details.my_rig} options={oper.rigs} showToggle allowFreeText
|
||||
onChange={(v) => onChange({ my_rig: v })} />
|
||||
</Field>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
{/* The antennas of the chosen rig, since that is what they hang off
|
||||
— and all of them when the rig is one this tree does not know. */}
|
||||
<Combobox value={details.my_antenna} options={oper.antennasFor(details.my_rig)} showToggle allowFreeText
|
||||
onChange={(v) => onChange({ my_antenna: v })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useOperatingLists } from '@/lib/operatingLists';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -288,6 +289,9 @@ function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: strin
|
||||
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Read once per opening of the editor: rigs and antennas do not change while
|
||||
// a contact is being corrected.
|
||||
const oper = useOperatingLists();
|
||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||
// imported/legacy value is never silently dropped from the dropdown.
|
||||
@@ -982,8 +986,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
{/* The station's own rigs and antennas (Settings ▸ Operating
|
||||
conditions), so a correction here spells them the same way
|
||||
the log already does. Free text stays: an imported contact
|
||||
carries whatever the other logger wrote. */}
|
||||
<F label={t('qedit.rig')} span={3}>
|
||||
<Combobox value={draft.my_rig ?? ''} options={oper.rigs} showToggle allowFreeText
|
||||
onChange={(v) => set('my_rig', v)} />
|
||||
</F>
|
||||
<F label={t('qedit.antenna')} span={3}>
|
||||
<Combobox value={draft.my_antenna ?? ''} options={oper.antennasFor(draft.my_rig ?? '')} showToggle allowFreeText
|
||||
onChange={(v) => set('my_antenna', v)} />
|
||||
</F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -81,8 +81,12 @@ type BeamKind = 'antenna' | 'hover';
|
||||
const TARGET_YELLOW = '#FBBF24';
|
||||
const MAP_BG_TOP = '#0B1015';
|
||||
const MAP_BG_BOTTOM = '#080C11';
|
||||
const MAP_LAND = '#202832';
|
||||
const MAP_LAND_SECONDARY = '#25303A';
|
||||
// The continents, and they have to be VISIBLE. At #202832 on a #0B1015 ground
|
||||
// the land was some eight per cent brighter than the sea — technically a map,
|
||||
// practically a dark square with a suggestion in it. These read as coastlines
|
||||
// while staying well under the beams, which are what the dial is for.
|
||||
const MAP_LAND = '#33414F';
|
||||
const MAP_LAND_SECONDARY = '#41525F';
|
||||
|
||||
// What each rotor was last seen at, and what it was last told to do, kept
|
||||
// OUTSIDE the component and keyed by rotor index.
|
||||
@@ -289,7 +293,11 @@ function RotorCompassDial({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full min-w-0 aspect-square rounded-md border border-border bg-background flex items-center justify-center overflow-hidden">
|
||||
// No card of its own, and no square: the dial is drawn as a disc and the
|
||||
// corners are left to whatever it is sitting on. A black tile inside the
|
||||
// rotor panel read as a hole punched in it — the widget is already a card,
|
||||
// and this is an instrument on that card, not a second one.
|
||||
<div className="w-full h-full min-w-0 aspect-square flex items-center justify-center overflow-hidden">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
|
||||
@@ -338,10 +346,10 @@ function RotorCompassDial({
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${bgGradientId})`} />
|
||||
<circle cx={CENTER} cy={CENTER} r={MAP_RADIUS} fill={`url(#${bgGradientId})`} />
|
||||
|
||||
{landPath && (
|
||||
<g mask={`url(#${mapFadeMaskId})`} opacity="0.78" pointerEvents="none">
|
||||
<g mask={`url(#${mapFadeMaskId})`} opacity="0.92" pointerEvents="none">
|
||||
<path d={landPath} fill={MAP_LAND} />
|
||||
<path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
|
||||
</g>
|
||||
|
||||
@@ -1859,7 +1859,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// IC-7610 the moment they chose Other.
|
||||
const [icomCustom, setIcomCustom] = useState(false);
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', omnirig_cw_lower: false, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, yaesu_rtty_usb: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||
@@ -1871,13 +1871,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// else writes the backend (loading a profile, an older settings file).
|
||||
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
|
||||
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
|
||||
// Choosing a radio TURNS CAT ON.
|
||||
//
|
||||
// The master switch sits above this dropdown, and leaving it off while the
|
||||
// operator picks their brand, types the IP and runs the detector — which found
|
||||
// their radio and printed its name — is a trap: every one of those gestures
|
||||
// means "connect to this". A Flex 6700 owner did exactly that, saved six
|
||||
// times, and got no link and no error. They can still untick it; nothing here
|
||||
// ever turns CAT off on its own.
|
||||
const applyCatBrand = (id: string) => {
|
||||
const b = CAT_BRANDS.find((x) => x.id === id);
|
||||
if (!b) return;
|
||||
// Keep the connection when the new brand offers it, otherwise take its
|
||||
// first — picking Flex from Kenwood-over-USB has to land on something.
|
||||
const link = b.links.includes(catLink) ? catLink : b.links[0];
|
||||
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
|
||||
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link, enabled: true } as any));
|
||||
};
|
||||
const applyCatLink = (link: string) => {
|
||||
const b = CAT_BRANDS.find((x) => x.id === catBrand);
|
||||
@@ -3578,6 +3586,14 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||
{t('cat.enable')}
|
||||
</label>
|
||||
{/* Said where it is happening. Everything below this line can be filled
|
||||
in perfectly — the right radio, the right address, the detector
|
||||
finding it by name — and none of it connects while this is off. */}
|
||||
{!catCfg.enabled && (
|
||||
<div className="rounded-md border border-warning-border bg-warning-muted px-3 py-2 text-xs text-warning-muted-foreground">
|
||||
{t('cat.disabledNotice')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BRAND, then CONNECTION.
|
||||
The backend list mixed the two: "Icom (USB)" and "Icom (network)"
|
||||
@@ -3640,6 +3656,16 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<div className="space-y-1 col-span-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5" checked={!!catCfg.omnirig_cw_lower}
|
||||
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, omnirig_cw_lower: !!c }))} />
|
||||
<span>{t('cat.omnirigCwLower')}</span>
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigCwLowerHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -3653,7 +3679,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port, enabled: true }))} />
|
||||
</div>
|
||||
{/* What OpsLog DOES with a Flex — panadapter spots, decode spots,
|
||||
the DAX switch for voice messages — moved to Settings →
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
// The station's own rigs and antennas, for the MY_RIG and MY_ANTENNA fields.
|
||||
//
|
||||
// They are already defined once, in Settings ▸ Operating conditions — a station
|
||||
// per rig, with the antennas hanging off it. Typing them again into every
|
||||
// contact is both work and a source of spellings that do not match: "IC-7610",
|
||||
// "IC 7610" and "ic7610" are three different rigs to an award, a filter and to
|
||||
// anyone reading the log later.
|
||||
//
|
||||
// So the two fields offer what the operator has already declared. FREE TEXT
|
||||
// stays allowed: a QSO made from somebody else's station, or imported from
|
||||
// another logger, carries a rig that was never in this tree and must still be
|
||||
// loggable — the same rule the satellite-name field follows.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ListOperatingTree } from '../../wailsjs/go/main/App';
|
||||
|
||||
export type OperatingLists = {
|
||||
rigs: string[];
|
||||
// Every antenna in the profile, whichever rig it belongs to.
|
||||
antennas: string[];
|
||||
// The antennas of ONE rig. Falls back to all of them for a rig that is not in
|
||||
// the tree — an operator typing a borrowed rig's name should still be offered
|
||||
// their own antennas rather than nothing.
|
||||
antennasFor: (rig: string) => string[];
|
||||
};
|
||||
|
||||
const EMPTY: OperatingLists = { rigs: [], antennas: [], antennasFor: () => [] };
|
||||
|
||||
function build(stations: any[]): OperatingLists {
|
||||
const rigs: string[] = [];
|
||||
const byRig = new Map<string, string[]>();
|
||||
const all = new Set<string>();
|
||||
for (const st of stations ?? []) {
|
||||
const name = String(st?.name ?? '').trim();
|
||||
const ants = ((st?.antennas ?? []) as any[])
|
||||
.map((a) => String(a?.name ?? '').trim())
|
||||
.filter(Boolean);
|
||||
if (name) {
|
||||
rigs.push(name);
|
||||
byRig.set(name.toUpperCase(), ants);
|
||||
}
|
||||
for (const a of ants) all.add(a);
|
||||
}
|
||||
const antennas = [...all];
|
||||
return {
|
||||
rigs,
|
||||
antennas,
|
||||
antennasFor: (rig: string) => byRig.get(String(rig ?? '').trim().toUpperCase()) ?? antennas,
|
||||
};
|
||||
}
|
||||
|
||||
// useOperatingLists reads the tree when the component mounts, and again whenever
|
||||
// `reloadKey` changes — pass something that moves when Preferences close, so a
|
||||
// rig added there is offered without a restart.
|
||||
export function useOperatingLists(reloadKey?: unknown): OperatingLists {
|
||||
const [lists, setLists] = useState<OperatingLists>(EMPTY);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
ListOperatingTree()
|
||||
.then((st: any) => { if (live) setLists(build(st ?? [])); })
|
||||
// An empty list simply leaves both fields as free text, which is what they
|
||||
// were before they had a list at all.
|
||||
.catch(() => {});
|
||||
return () => { live = false; };
|
||||
}, [reloadKey]);
|
||||
return lists;
|
||||
}
|
||||
@@ -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.18';
|
||||
export const APP_VERSION = '0.27.19';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -2360,6 +2360,7 @@ export namespace main {
|
||||
backend: string;
|
||||
omnirig_rig: number;
|
||||
omnirig_vfo: string;
|
||||
omnirig_cw_lower: boolean;
|
||||
digi_as_usb: boolean;
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
@@ -2414,6 +2415,7 @@ export namespace main {
|
||||
this.backend = source["backend"];
|
||||
this.omnirig_rig = source["omnirig_rig"];
|
||||
this.omnirig_vfo = source["omnirig_vfo"];
|
||||
this.omnirig_cw_lower = source["omnirig_cw_lower"];
|
||||
this.digi_as_usb = source["digi_as_usb"];
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
|
||||
@@ -1145,6 +1145,23 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||
})
|
||||
}
|
||||
|
||||
// OmniRigController is the handful of OmniRig preferences that can be changed
|
||||
// without dropping the rig link.
|
||||
type OmniRigController interface {
|
||||
SetCWLower(bool) // which of OmniRig's two CW bits means plain CW
|
||||
}
|
||||
|
||||
// OmniRigDo dispatches an OmniRig preference onto the CAT goroutine.
|
||||
func (m *Manager) OmniRigDo(fn func(OmniRigController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
oc, ok := b.(OmniRigController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not OmniRig")
|
||||
}
|
||||
return fn(oc)
|
||||
})
|
||||
}
|
||||
|
||||
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||
type KenwoodController interface {
|
||||
|
||||
@@ -71,10 +71,10 @@ type Flex struct {
|
||||
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
|
||||
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
|
||||
// one it will close again.
|
||||
satOn bool
|
||||
satRX int
|
||||
satTX int
|
||||
satCreatedTX bool
|
||||
satOn bool
|
||||
satRX int
|
||||
satTX int
|
||||
satCreatedTX bool
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||
|
||||
+24
-3
@@ -36,6 +36,14 @@ type OmniRig struct {
|
||||
// on the main VFO) and read B — the frequency "never followed the knob",
|
||||
// while it was following the other one all along.
|
||||
ForceVFO string
|
||||
// CWLower sends PM_CW_L rather than PM_CW_U when asked for CW.
|
||||
//
|
||||
// OmniRig has two CW modes and nothing says which one an .ini file calls
|
||||
// plain CW. Icom rig files disagree: on some PM_CW_U is CI-V mode 0x03 (CW),
|
||||
// on others it is 0x07 (CW-R). An operator clicking a CW spot on an IC-7610
|
||||
// landed in CW-R every time and had to edit the rig file to get out of it.
|
||||
// This is the setting that means he does not have to.
|
||||
CWLower bool
|
||||
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
@@ -76,7 +84,7 @@ type OmniRig struct {
|
||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
||||
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
||||
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
||||
func NewOmniRig(rigNum int, forceVFO string, cwLower bool) *OmniRig {
|
||||
if rigNum < 1 || rigNum > 2 {
|
||||
rigNum = 1
|
||||
}
|
||||
@@ -84,7 +92,7 @@ func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
||||
if v != "A" && v != "B" {
|
||||
v = ""
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum, ForceVFO: v}
|
||||
return &OmniRig{RigNum: rigNum, ForceVFO: v, CWLower: cwLower}
|
||||
}
|
||||
|
||||
func (o *OmniRig) Name() string { return "omnirig" }
|
||||
@@ -602,6 +610,13 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCWLower chooses which of OmniRig's two CW bits means plain CW.
|
||||
//
|
||||
// Applied to the RUNNING backend, because the CAT link does not depend on it:
|
||||
// dropping the rig — and with it WSJT-X's rigctl session — to change which bit
|
||||
// a mode maps to would cost far more than it fixes.
|
||||
func (o *OmniRig) SetCWLower(on bool) { o.CWLower = on }
|
||||
|
||||
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
|
||||
// For SSB, the USB/LSB side is chosen from the rig's current frequency
|
||||
// following worldwide convention (LSB below 14 MHz, USB above).
|
||||
@@ -625,7 +640,13 @@ func (o *OmniRig) SetMode(mode string) error {
|
||||
)
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
bit, bitName = pmCWU, "PM_CW_U"
|
||||
// Which bit means plain CW is a property of the RIG FILE, not of CW —
|
||||
// see the CWLower field.
|
||||
if o.CWLower {
|
||||
bit, bitName = pmCWL, "PM_CW_L"
|
||||
} else {
|
||||
bit, bitName = pmCWU, "PM_CW_U"
|
||||
}
|
||||
case "SSB":
|
||||
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
|
||||
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A "multicast" row whose group is not a multicast address.
|
||||
//
|
||||
// 127.0.0.1 in that box is the common mistake — it is the address every other
|
||||
// field in every other program wants — and it used to fail the join on every
|
||||
// interface with a Windows error about an address not being valid in its
|
||||
// context. The row did not run and the message named nothing the operator had
|
||||
// typed. Reported by an operator whose WSJT-X rows were dead for exactly this
|
||||
// reason, while a third row on unicast worked perfectly beside them.
|
||||
func TestOnlyRealMulticastGroupsAreJoined(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr string
|
||||
multicast bool
|
||||
}{
|
||||
{"224.0.0.1", true}, // the all-hosts group WSJT-X offers
|
||||
{"239.255.0.1", true}, // the administratively-scoped range
|
||||
{"127.0.0.1", false}, // loopback: the mistake
|
||||
{"192.168.1.10", false},
|
||||
{"0.0.0.0", false},
|
||||
} {
|
||||
ip := net.ParseIP(tc.addr)
|
||||
if ip == nil {
|
||||
t.Fatalf("%s does not parse", tc.addr)
|
||||
}
|
||||
if got := ip.IsMulticast(); got != tc.multicast {
|
||||
t.Errorf("%s: IsMulticast() = %v, wanted %v", tc.addr, got, tc.multicast)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,28 @@ func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
||||
|
||||
func (s *Server) start() error {
|
||||
var conn *net.UDPConn
|
||||
if s.cfg.Multicast {
|
||||
// "Multicast" ticked with an address that is not one.
|
||||
//
|
||||
// 127.0.0.1 in the group box is the common mistake, and it is an
|
||||
// understandable one — it is the address every other field in every other
|
||||
// program wants. But a multicast group is 224.0.0.0 to 239.255.255.255, and
|
||||
// joining anything else fails on every interface with a Windows error about
|
||||
// an address not being valid in its context. The row then simply does not
|
||||
// run, and an operator reads a setsockopt message that names nothing they
|
||||
// typed.
|
||||
//
|
||||
// So it listens anyway, as unicast, which is what an address like that means
|
||||
// — and says what it did. The row works, and the reason it is not multicast
|
||||
// is in the log rather than in a kernel error code.
|
||||
multicast := s.cfg.Multicast
|
||||
if multicast {
|
||||
if ip := net.ParseIP(strings.TrimSpace(s.cfg.MulticastGroup)); ip != nil && !ip.IsMulticast() {
|
||||
applog.Printf("udp: [%s] %s is not a multicast address (those run 224.0.0.0-239.255.255.255) — listening on unicast :%d instead\n",
|
||||
s.cfg.Name, ip, s.cfg.Port)
|
||||
multicast = false
|
||||
}
|
||||
}
|
||||
if multicast {
|
||||
group := strings.TrimSpace(s.cfg.MulticastGroup)
|
||||
if group == "" {
|
||||
return fmt.Errorf("multicast enabled but group address is empty")
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -58,7 +59,17 @@ func acquireInstance(wait bool) bool {
|
||||
if !wait {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
// Forty-five seconds, not twenty.
|
||||
//
|
||||
// The instance we are waiting for is allowed THIRTY to shut down — see
|
||||
// armExitWatchdog, which force-exits it at that point — because it closes a
|
||||
// remote logbook, a CAT session and sometimes a backup on the way out. A
|
||||
// twenty-second patience was therefore shorter than the wait it existed for,
|
||||
// and on a station where shutdown ran long the new instance gave up while
|
||||
// the old one was still finishing: no new window after an update, and a
|
||||
// leftover OpsLog in the task manager. This is the backstop; --wait-pid
|
||||
// below is the real answer.
|
||||
deadline := time.Now().Add(45 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if acquireSingleInstance() {
|
||||
@@ -68,6 +79,23 @@ func acquireInstance(wait bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// waitPidArg reads "--wait-pid N": the process this one must outlive.
|
||||
func waitPidArg(args []string) int {
|
||||
for i, a := range args {
|
||||
if a == "--wait-pid" && i+1 < len(args) {
|
||||
if n, err := strconv.Atoi(args[i+1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--wait-pid="); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// processStart is stamped on the very first instruction of main, before the
|
||||
// single-instance guard and before anything else runs.
|
||||
//
|
||||
@@ -90,6 +118,20 @@ func main() {
|
||||
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||
bootLogLaunch()
|
||||
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||
// The instance that started us is still shutting down. Wait for it to
|
||||
// actually END — a kernel wait on its handle, which finishes the instant it
|
||||
// does — rather than hoping the mutex frees inside a fixed window. This is
|
||||
// what the old PowerShell helper did, and losing it is what left an operator
|
||||
// with no window after an update and the previous OpsLog still in the task
|
||||
// manager.
|
||||
if pid := waitPidArg(os.Args[1:]); pid > 0 {
|
||||
bootLog("waiting for the previous instance (pid %d) to exit", pid)
|
||||
if waitForProcessExit(pid, 60*time.Second) {
|
||||
bootLog("the previous instance is gone")
|
||||
} else {
|
||||
bootLog("the previous instance (pid %d) is STILL running after 60s — trying anyway", pid)
|
||||
}
|
||||
}
|
||||
// A self-relaunch (database switch) races its own parent: the new process
|
||||
// regularly wins the start against the old one's teardown, and the operator
|
||||
// got "OpsLog is already running" for following instructions. Same patience
|
||||
@@ -100,7 +142,16 @@ func main() {
|
||||
// window, no data folder, no log, which is indistinguishable from a
|
||||
// program that died on its first instruction.
|
||||
bootLog("another instance already holds the single-instance mutex - exiting")
|
||||
fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.")
|
||||
if postUpdate {
|
||||
// After an update the ordinary message is a lie by omission: the
|
||||
// operator did not start a second copy, the update did, and what
|
||||
// they need to know is that the PREVIOUS version never finished
|
||||
// closing.
|
||||
fatalBox("OpsLog", "The previous version of OpsLog has not finished closing, so the updated one cannot start.\n\n"+
|
||||
"Close the leftover OpsLog.exe in the Task Manager, then start OpsLog again — the update is already installed.")
|
||||
} else {
|
||||
fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.")
|
||||
}
|
||||
return
|
||||
}
|
||||
bootLog("single-instance mutex acquired")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The sent side must never start at Y.
|
||||
//
|
||||
// Y means "already sent", so the uploader skips the contact for ever. An
|
||||
// operator whose eQSL default was Y had a logbook that never reached eQSL and
|
||||
// nothing on screen to explain it — the log said "not eligible, EQSLSent
|
||||
// already Y" and that is the only place it was ever said.
|
||||
func TestNoConfirmationDefaultsToAlreadySent(t *testing.T) {
|
||||
d := defaultQSLDefaults()
|
||||
v := reflect.ValueOf(d)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
name := v.Type().Field(i).Name
|
||||
val := strings.ToUpper(strings.TrimSpace(v.Field(i).String()))
|
||||
if val == "Y" {
|
||||
t.Errorf("%s defaults to Y — every new QSO would be skipped by its uploader for ever", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every field has a default. A service added without one comes back blank,
|
||||
// which is not a status anybody chose — HAMLOG.online arrived that way and
|
||||
// every existing profile got nothing for it.
|
||||
func TestEveryConfirmationHasADefault(t *testing.T) {
|
||||
d := defaultQSLDefaults()
|
||||
v := reflect.ValueOf(d)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if strings.TrimSpace(v.Field(i).String()) == "" {
|
||||
t.Errorf("%s has no default — add it to defaultQSLDefaults", v.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A profile saved before a service existed has no value stored for it, and
|
||||
// gets the shipped one rather than a blank.
|
||||
func TestStoredBlanksAreFilledFromTheDefaults(t *testing.T) {
|
||||
// What an old profile looks like: the services it knew about are set, the
|
||||
// ones added later are empty.
|
||||
stored := QSLDefaults{
|
||||
QSLSent: "N", QSLRcvd: "N",
|
||||
EQSLSent: "R", EQSLRcvd: "N",
|
||||
LOTWSent: "R", LOTWRcvd: "N",
|
||||
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||
QRZComStatus: "R", QRZComCfm: "N",
|
||||
}
|
||||
got := fillMissingQSLDefaults(stored)
|
||||
if got.HamlogStatus != "R" || got.HamlogCfm != "N" || got.HamqthStatus != "R" {
|
||||
t.Errorf("services added later were left blank: hamlog=%q/%q hamqth=%q",
|
||||
got.HamlogStatus, got.HamlogCfm, got.HamqthStatus)
|
||||
}
|
||||
// And a value the operator DID choose is untouched.
|
||||
chosen := QSLDefaults{QSLSent: "I", EQSLSent: "N"}
|
||||
filled := fillMissingQSLDefaults(chosen)
|
||||
if filled.QSLSent != "I" || filled.EQSLSent != "N" {
|
||||
t.Errorf("a chosen status was overwritten: %q / %q", filled.QSLSent, filled.EQSLSent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWaitPidArg(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want int
|
||||
}{
|
||||
{"after the flag", []string{"--post-update", "--wait-pid", "4321"}, 4321},
|
||||
{"joined with =", []string{"--wait-pid=4321"}, 4321},
|
||||
{"absent", []string{"--post-update"}, 0},
|
||||
{"flag with nothing after it", []string{"--wait-pid"}, 0},
|
||||
{"not a number", []string{"--wait-pid", "later"}, 0},
|
||||
} {
|
||||
if got := waitPidArg(tc.args); got != tc.want {
|
||||
t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every relaunch has to tell the new process which one to wait for.
|
||||
//
|
||||
// The auto-update relaunch lost that when its PowerShell helper was removed —
|
||||
// the helper had waited for the pid, and nothing took over the job — and an
|
||||
// operator was left with no window after an update and the previous OpsLog
|
||||
// still running. This keeps the two spawn sites honest: if a relaunch is added
|
||||
// without --wait-pid, it is the same bug again.
|
||||
func TestEveryRelaunchPassesItsPid(t *testing.T) {
|
||||
spawn := regexp.MustCompile(`exec\.Command\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||
for _, file := range []string{"update.go", "app.go"} {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
for _, call := range spawn.FindAllString(string(src), -1) {
|
||||
if !strings.Contains(call, "--wait-pid") {
|
||||
t.Errorf("%s: %s does not tell the new instance which process to wait for", file, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
|
||||
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
|
||||
// runs this binary, and a real OpsLog already running would otherwise make it
|
||||
// exit before Wails could reflect the bindings.
|
||||
func acquireSingleInstance() bool { return true }
|
||||
|
||||
// waitForProcessExit has nothing to wait on off Windows: there is no
|
||||
// single-instance guard there either, so the relaunch never has to queue behind
|
||||
// the old process.
|
||||
func waitForProcessExit(pid int, timeout time.Duration) bool { return true }
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -68,3 +69,35 @@ func focusExistingWindow() {
|
||||
showWindow.Call(hwnd, swRestore)
|
||||
setForeground.Call(hwnd)
|
||||
}
|
||||
|
||||
// waitForProcessExit blocks until the process with this pid is gone, or the
|
||||
// timeout runs out. Reports whether it actually went.
|
||||
//
|
||||
// This is what the auto-update relaunch needs, and it is the piece that was
|
||||
// lost when the PowerShell helper went. The old instance is allowed thirty
|
||||
// seconds to shut down (see armExitWatchdog) — it closes a remote logbook, a
|
||||
// CAT session, sometimes a backup — while the new one was only patient with the
|
||||
// mutex for twenty. On a station where shutdown took longer than that, the new
|
||||
// process gave up and exited, and the operator was left with the old one still
|
||||
// running and no new window: exactly the report.
|
||||
//
|
||||
// Waiting on a handle rather than sleeping a fixed time is also the honest
|
||||
// version: it ends the instant the old process ends, however long or short that
|
||||
// is, and it is a plain kernel wait — nothing that looks like a script starting
|
||||
// another program.
|
||||
func waitForProcessExit(pid int, timeout time.Duration) bool {
|
||||
if pid <= 0 {
|
||||
return true
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||
if err != nil {
|
||||
// Already gone, or not ours to wait on. Either way there is nothing to
|
||||
// wait for — and refusing to launch over an unexpected permission error
|
||||
// would be worse than starting.
|
||||
return true
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
ms := uint32(timeout / time.Millisecond)
|
||||
ev, err := windows.WaitForSingleObject(h, ms)
|
||||
return err == nil && ev == uint32(windows.WAIT_OBJECT_0)
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.18"
|
||||
appVersion = "0.27.19"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -222,10 +222,18 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
|
||||
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
|
||||
//
|
||||
// The wait it existed for is not needed: --post-update already makes the new
|
||||
// instance patient with the single-instance mutex (see acquireInstance), so it
|
||||
// can start while this one is still shutting down and simply wait its turn.
|
||||
cmd := exec.Command(exe, "--post-update")
|
||||
// The wait it existed for still has to happen — it just happens on the other
|
||||
// side now. The new instance is told OUR pid and waits for this process to
|
||||
// end before taking the single-instance mutex.
|
||||
//
|
||||
// Waiting on the mutex alone was not enough, and that is the bug this line
|
||||
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog),
|
||||
// because it closes a remote logbook, a CAT session and sometimes a backup,
|
||||
// while the new instance was only patient for twenty. On a station where
|
||||
// that ran long, the new process gave up and exited — leaving the old one
|
||||
// still running and no new window, which is precisely what the PowerShell
|
||||
// helper never did: it waited for the pid, however long it took.
|
||||
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
cmd.Dir = dir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
if err := cmd.Start(); err != nil {
|
||||
|
||||
@@ -31,6 +31,15 @@ WSJT-X and MSHV normally broadcast to a **multicast group** (usually
|
||||
give the group. A program sending to one address only needs unicast — leave it
|
||||
unticked.
|
||||
|
||||
If you also run **GridTracker**, or anything else that listens to your decoder,
|
||||
read [[Digital Modes and GridTracker]] first: two programs on one unicast port
|
||||
work or not depending on which started first, which is the usual cause of
|
||||
"the decodes arrived yesterday and not today".
|
||||
|
||||
A multicast group is an address between `224.0.0.0` and `239.255.255.255`.
|
||||
`127.0.0.1` is not one — a row ticked *Multicast* with a loopback or LAN
|
||||
address in the group box listens on unicast instead, and says so in the log.
|
||||
|
||||
> **Do not put an inbound row and an outbound row on the same port.** OpsLog
|
||||
> then receives its own messages, and something it publishes can come back as a
|
||||
> command. It says so in the log when it spots the arrangement:
|
||||
@@ -45,10 +54,36 @@ unticked.
|
||||
|---|---|---|
|
||||
| **ADIF message** | a plain ADIF record | each QSO logged |
|
||||
| **WSJT-X logged QSO** | the same ADIF wrapped in a WSJT-X datagram | each QSO logged |
|
||||
| **Relay the WSJT-X stream** | every datagram received, byte for byte | as it arrives |
|
||||
| **PstRotator frequency** | `<PST><FREQUENCY>` | frequency change |
|
||||
| **N1MM RadioInfo** | N1MM's RadioInfo XML | frequency or mode change |
|
||||
| **Custom message** | whatever you write | a trigger you pick |
|
||||
|
||||
### Relay the WSJT-X stream
|
||||
|
||||
WSJT-X, JTDX and MSHV each send to **one address**. Without a relay the choice
|
||||
is between OpsLog and JTAlert / GridTracker, not both.
|
||||
|
||||
This row re-sends every datagram an inbound WSJT-X row receives, **byte for
|
||||
byte**, to another program — so OpsLog stops being the reason you cannot run the
|
||||
two together:
|
||||
|
||||
```
|
||||
WSJT-X ──▶ OpsLog (inbound, 2237) ──relay──▶ GridTracker (2238)
|
||||
```
|
||||
|
||||
Point it at the **other** program's port. Pointing it at one of OpsLog's own
|
||||
listening ports would feed the stream back into itself for ever; the relay
|
||||
refuses and says so in the log rather than flooding the network.
|
||||
|
||||
Nothing is added to the datagrams — no origin header. Some relays in the field
|
||||
prepend one (`127.0.0.1:2237|`), which is exactly the kind of thing OpsLog has
|
||||
had to write code to survive on the receiving side; passing it on downstream
|
||||
would be repeating that mistake.
|
||||
|
||||
> If every program involved can do **multicast**, prefer that: each one gets its
|
||||
> own copy and no relay is needed. See [[Digital Modes and GridTracker]].
|
||||
|
||||
### Which one for another logger?
|
||||
|
||||
If a logger says it accepts "WSJT-X UDP", it wants the **WSJT-X logged QSO**
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Digital modes: WSJT-X, GridTracker and OpsLog together
|
||||
|
||||
Running **WSJT-X** (or JTDX or MSHV) with **GridTracker** *and* OpsLog at the
|
||||
same time is the normal digital station, and it is the setup that most often
|
||||
half-works: the decodes arrive one day and not the next, with nothing changed.
|
||||
|
||||
There is one cause and one fix. The cause is **unicast**; the fix is
|
||||
**multicast**.
|
||||
|
||||
---
|
||||
|
||||
## Unicast and multicast, in one minute
|
||||
|
||||
WSJT-X does not know about OpsLog. It sends a stream of UDP messages — every
|
||||
decode, the DX call you are working, each QSO you log — to **one address and
|
||||
port** you give it. Everything else is just listeners.
|
||||
|
||||
**Unicast** is a message addressed to one place: `127.0.0.1:2237`.
|
||||
|
||||
> One letter, one letterbox. If two programs are watching the same letterbox,
|
||||
> the operating system decides which one gets the letter — and it decides on
|
||||
> the order they started, not on what you want. That is why it works after one
|
||||
> restart and not after the next.
|
||||
|
||||
**Multicast** is a message addressed to a **group**: `239.255.0.1:2237`.
|
||||
|
||||
> A radio broadcast. Every program that has tuned to the group gets its own
|
||||
> copy. No competition, no order, no luck.
|
||||
|
||||
Multicast is what WSJT-X offers for exactly this reason, and it is what you
|
||||
want as soon as **more than one program** listens to your decoder.
|
||||
|
||||
### What it looks like when it goes wrong
|
||||
|
||||
Two starts of the same station, an hour apart, nothing changed in any setting:
|
||||
|
||||
```
|
||||
06:43 autostart: GridTracker2 — already_running
|
||||
06:43 udp: [Decodium] listening on unicast :2237
|
||||
… not one decode all morning, though WSJT-X was running and logging QSOs
|
||||
|
||||
10:30 udp: [Decodium] listening on unicast :2237
|
||||
10:30 autostart: GridTracker2 — launched
|
||||
10:30 udp: emit udp:dx_call "JK2TTP" (mode=FT4 freq=21140000)
|
||||
… decodes all the way through
|
||||
```
|
||||
|
||||
In the morning GridTracker was already holding port 2237 when OpsLog started.
|
||||
In the second session OpsLog got there first. Same programs, same settings, and
|
||||
the only difference is who started first.
|
||||
|
||||
---
|
||||
|
||||
## The setup that works
|
||||
|
||||
Pick one group and one port and give the **same pair** to every program.
|
||||
`239.255.0.1` port `2237` is the usual choice.
|
||||
|
||||
### WSJT-X
|
||||
|
||||
**File → Settings → Reporting**, *UDP Server* box:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| UDP Server | `239.255.0.1` |
|
||||
| UDP Server port number | `2237` |
|
||||
| Outgoing interfaces | tick your **LAN/Wi-Fi adapter** *and* **loopback** |
|
||||
| Accept UDP requests | ✔ ticked |
|
||||
|
||||
*Outgoing interfaces* matters and is easy to miss: with only the LAN adapter
|
||||
ticked, programs on the same PC may never see the traffic. Tick loopback too.
|
||||
|
||||
*Accept UDP requests* is what lets OpsLog answer a station for you and clear
|
||||
the DX call. Without it OpsLog still hears everything, but can only watch.
|
||||
|
||||
### JTDX
|
||||
|
||||
Same place, same values — JTDX keeps WSJT-X's Reporting tab.
|
||||
|
||||
### MSHV
|
||||
|
||||
**Options → Settings → Reporting** (WSJT-X protocol), same address and port.
|
||||
|
||||
### GridTracker
|
||||
|
||||
**Settings → WSJT-X / UDP**: address `239.255.0.1`, port `2237`, multicast
|
||||
enabled. GridTracker joins the group like everyone else instead of owning the
|
||||
port.
|
||||
|
||||
### OpsLog
|
||||
|
||||
**Settings → Connections → Add**:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Direction | Inbound |
|
||||
| Service | WSJT-X / JTDX / MSHV |
|
||||
| Port | `2237` |
|
||||
| Multicast | ✔ ticked |
|
||||
| Group | `239.255.0.1` |
|
||||
|
||||
Save. The log should say:
|
||||
|
||||
```
|
||||
udp: [WSJT-X] listening on multicast 239.255.0.1:2237 on 2 interface(s) (service=wsjt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The mistake to avoid: 127.0.0.1 in the group box
|
||||
|
||||
A **multicast group is an address between `224.0.0.0` and `239.255.255.255`**.
|
||||
Nothing else can be joined.
|
||||
|
||||
`127.0.0.1` is loopback — an ordinary unicast address — and it is a very
|
||||
understandable thing to type, because it is what every other field in every
|
||||
other program wants. But a row ticked *Multicast* with `127.0.0.1` in the group
|
||||
box cannot join anything, and used to fail with a Windows message naming
|
||||
nothing you had typed:
|
||||
|
||||
```
|
||||
udp: [Wsjtx] join 127.0.0.1 on Wi-Fi 2: setsockopt: the requested address is not
|
||||
valid in its context
|
||||
udp: start "Wsjtx" failed: couldn't join multicast 127.0.0.1 on any interface
|
||||
```
|
||||
|
||||
OpsLog now listens on unicast instead and says so, so the row still works — but
|
||||
if you meant multicast, put a real group in.
|
||||
|
||||
**Rule of thumb:** if the address starts with `127.` or `192.168.` or `10.`,
|
||||
untick *Multicast*.
|
||||
|
||||
---
|
||||
|
||||
## If you have to stay on unicast
|
||||
|
||||
Sometimes you cannot use multicast — an old program, a locked-down network.
|
||||
Then **only one program may listen on the port**, and the others are fed by it.
|
||||
|
||||
**OpsLog can be the one that feeds them.** Add an outbound *Relay the WSJT-X
|
||||
stream* row (Settings → Connections) and it re-sends every datagram it receives,
|
||||
byte for byte, to wherever you point it:
|
||||
|
||||
```
|
||||
WSJT-X ──unicast 2237──▶ OpsLog ──relay──▶ GridTracker (2238)
|
||||
```
|
||||
|
||||
Set GridTracker to listen on **2238** instead of 2237 and nothing competes for
|
||||
anything. Point the relay at the other program's port, never at one of OpsLog's
|
||||
own — that would feed the stream back into itself, and the relay refuses rather
|
||||
than let it.
|
||||
|
||||
It works the other way round too, if you prefer GridTracker to own the port:
|
||||
GridTracker forwards to a **different** port and OpsLog takes an inbound row
|
||||
there. Either order is fine. The one arrangement that never works reliably is
|
||||
two programs on one unicast port.
|
||||
|
||||
---
|
||||
|
||||
## Checking it
|
||||
|
||||
1. **Settings → Connections** — the row is enabled and its port matches.
|
||||
2. **Help → Application log** — one of these two lines appears at startup:
|
||||
- `listening on multicast 239.255.0.1:2237 on N interface(s)` ✔
|
||||
- `listening on unicast :2237` — fine only if nothing else listens there
|
||||
3. Tune to a busy FT8 frequency. Within a minute the log fills with:
|
||||
```
|
||||
udp: emit udp:dx_call "F5ABC" (mode=FT8 freq=14074000)
|
||||
```
|
||||
4. Nothing at all? In order: is WSJT-X's UDP server address the same group; is
|
||||
*Outgoing interfaces* including loopback; is another program holding the
|
||||
port; is Windows Firewall blocking OpsLog.
|
||||
|
||||
> **Not the same thing:** `rigctld: sharing CAT on port 2237` in the log is
|
||||
> OpsLog's **TCP** CAT sharing for Hamlib clients. TCP and UDP ports are
|
||||
> separate — it does not conflict with the UDP listener, however alike the
|
||||
> numbers look.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Connections]] — every inbound and outbound row, and what each service does
|
||||
- [[Logging Basics]] — what happens when a QSO arrives from WSJT-X
|
||||
- [[Troubleshooting]] — where the log lives
|
||||
@@ -29,6 +29,7 @@ Developed by **F4BPO**.
|
||||
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
|
||||
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
|
||||
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
|
||||
| Digital modes | [[Digital Modes and GridTracker]] · [[Connections]] |
|
||||
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
|
||||
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
|
||||
| Confirmations | [[Awards]] · [[QSL Management]] · [[QSL Card Designer]] |
|
||||
|
||||
@@ -3,6 +3,69 @@
|
||||
**Tools → QSL Manager.** Upload and download confirmations for the online QSL
|
||||
services, and manage paper QSL.
|
||||
|
||||
---
|
||||
|
||||
## Confirmations — the status every new QSO starts with
|
||||
|
||||
**Settings → Confirmations.** This page does not send anything. It sets the
|
||||
**status stamped on each new QSO**, per service, at the moment it is logged —
|
||||
by hand, or automatically from WSJT-X / JTDX / MSHV.
|
||||
|
||||
Think of it as the starting position of each column in your log. Everything
|
||||
else — the QSL Manager, the automatic uploads, the *Select required* button —
|
||||
reads those columns and acts on what it finds.
|
||||
|
||||
### ⚠ Sent = **Yes** means the QSO will NEVER be uploaded
|
||||
|
||||
`Y` does not mean *please send it*. It means **already sent**.
|
||||
|
||||
A contact stamped `Y` is finished as far as that service is concerned: the
|
||||
uploader skips it, the QSL Manager does not offer it, and nothing is ever sent
|
||||
for it. Set **eQSL Sent** to `Y` here and every QSO you log from that moment on
|
||||
will silently never reach eQSL.
|
||||
|
||||
There is no error, because nothing failed — OpsLog did exactly what the column
|
||||
said. The only trace is one line in the application log:
|
||||
|
||||
```
|
||||
extsvc: QSO 3807 not eligible for eqsl — EQSLSent already "Y"
|
||||
(set Confirmations default to N to upload)
|
||||
```
|
||||
|
||||
If a service has stopped receiving your contacts, this is the first place to
|
||||
look.
|
||||
|
||||
### What each status means
|
||||
|
||||
| | Meaning | Effect on uploads |
|
||||
|---|---|---|
|
||||
| **R** — requested | Waiting to go out | Uploaded ✔ |
|
||||
| **N** — no | Not sent / not received | Uploaded ✔ |
|
||||
| **Q** — queued | Waiting in a batch | Uploaded ✔ |
|
||||
| **Y** — yes | **Already sent / received** | **Skipped for ever** ✘ |
|
||||
| **I** — ignore | Deliberately excluded | Skipped |
|
||||
| *blank* | No status at all | Column stays empty |
|
||||
|
||||
### What OpsLog ships
|
||||
|
||||
One rule, for every service: the **sent** side starts at **R** (this contact is
|
||||
waiting to go out) and the **received** side at **N** (nothing has come back
|
||||
yet).
|
||||
|
||||
Paper QSL is the exception on the sent side — it starts at **N**, because a
|
||||
card is only *requested* once somebody asks for one.
|
||||
|
||||
You only need to change this if you work differently. The common reason is
|
||||
gating: set a service to `N` and nothing goes out until you select the contacts
|
||||
yourself in the QSL Manager.
|
||||
|
||||
### Two things worth knowing
|
||||
|
||||
- **It is per profile.** Your contest callsign can upload to a different set of
|
||||
services from your home one.
|
||||
- **It applies from now on.** Changing a default never touches contacts already
|
||||
in the log — use **Bulk edit field** (right-click in Recent QSOs) for those.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Upload | Download confirmations |
|
||||
|
||||
@@ -4,7 +4,23 @@ These files are the source for the **OpsLog GitHub Wiki**. GitHub stores a wiki
|
||||
as its own git repository (`<repo>.wiki.git`), so publishing means pushing these
|
||||
`.md` files into it.
|
||||
|
||||
## Automatic — via the release script (recommended)
|
||||
## Just the wiki, between releases
|
||||
|
||||
**VS Code → *Tasks: Run Task* → *Publish wiki***, or:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .vscode\publish-wiki.ps1
|
||||
```
|
||||
|
||||
No version bump, no build, no release — it clones the wiki repo, copies these
|
||||
pages in, and pushes only if something changed (it lists what). Use it whenever
|
||||
a page is corrected: documentation and releases move at different speeds, and a
|
||||
fix on a Sunday should not have to wait for the next version.
|
||||
|
||||
The release script calls this same file at the end of a release, so there is one
|
||||
implementation rather than two that drift.
|
||||
|
||||
## Automatic — via the release script
|
||||
|
||||
The release task (`.vscode/release.ps1`, VS Code → *Tasks: Run Task* → *Release
|
||||
OpsLog*) **publishes the wiki automatically** at the end of every release: it
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
- [[Net Control]]
|
||||
- [[Multi-Operator Live Status]]
|
||||
- [[Connections]]
|
||||
- [[Digital Modes and GridTracker]]
|
||||
|
||||
**QSL & Awards**
|
||||
- [[Awards]]
|
||||
|
||||
Reference in New Issue
Block a user