Compare commits

...
22 Commits
Author SHA1 Message Date
rouggy f05e6290df chore: release v0.27.19 2026-09-08 22:08:20 +02:00
rouggyandClaude Opus 5 a89be4b86e fix(rotor): a disc on the panel, not a black tile in it
The dial painted its background as a full-bleed square, so inside the
rotor widget — which is already a card — it read as a hole punched in the
panel rather than an instrument sitting on it. It is a circle now, at the
radius the map already used, and the corners are left to whatever it is
drawn on. The wrapper loses its own border and background for the same
reason: one card, not two.

And the continents were barely there. 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. The new shades read as
coastlines while staying well under the beams, which are what the dial is
actually for.

The palette stays deliberately unthemed. That was never the problem: a
map that repaints itself in four colour schemes stops being readable, and
the beams' green, orange and yellow have to mean the same thing in every
one of them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 22:00:39 +02:00
rouggyandClaude Opus 5 dbeabc1bae feat: my rig and my antenna as dropdowns, and a ceiling on the decodes
The rigs and antennas are already declared once, in Settings ▸ Operating
conditions — a station per rig with its antennas hanging off it — and
then typed again into every contact. That is work, and it is a source of
spellings that do not match: "IC-7610", "IC 7610" and "ic7610" are three
different rigs to an award, to a filter, and to anyone reading the log
later.

Both fields now offer that list, in the entry form and in the QSO editor,
and the antenna field offers the antennas of the rig that was picked
because that is the structure the tree already has. It falls back to all
of them for a rig the tree does not know, so an operator typing a
borrowed rig is still offered their own antennas rather than nothing.
Free text stays allowed throughout — a contact 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. Same rule the satellite-name field
follows.

And the decodes list gains a ceiling of two thousand rows. The rolling
half hour was never a limit on a crowded evening — three decoders put
several thousand rows inside it — and the panel slows down long before
the age cut removes any of them, because each row is a layout, a status
lookup and a distance. Past the ceiling the oldest go: what has already
been scrolled past, rather than the period being read.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 21:52:38 +02:00
rouggyandClaude Opus 5 6f1c998a26 fix(cat): choosing a radio switches CAT on, and the log stops lying
A Flex 6700 owner's log: no connection attempt of any kind, and six
saves each answered with

    cat: settings saved, link unchanged - staying connected

His settings were right — FlexRadio selected, 192.168.1.154, port 4992,
and the detector had found and named his radio back at him. What was off
was the master CAT switch, which sits above the radio dropdown.

Three things, because each of them failed him on its own.

The log line was false on both counts: a disabled CAT has the signature
"off", so every save took the unchanged branch and announced a link that
was staying connected when nothing was connected at all. It now says CAT
is switched off, which is the fact he needed and the only place it could
have reached him.

The panel says it too, where he was actually looking: a notice under the
switch while it is off, because everything below it can be perfectly
right and still connect to nothing.

And choosing a radio now ticks the switch. Picking a brand, typing an
address, running a detector and clicking the radio it found are all one
gesture — "connect to this" — and making the operator find a separate
master switch afterwards is a trap. Nothing here ever turns CAT off on
its own; they can still untick it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 18:52:19 +02:00
rouggyandClaude Opus 5 d185b10559 docs(wiki): the WSJT-X relay was missing from the outbound list
Six outbound services, five documented. The missing one is the answer to
the problem the digital-modes page had just finished describing: WSJT-X
sends to one address, so without a relay the choice is between OpsLog and
GridTracker rather than both — and OpsLog has been able to be that relay
all along.

Connections now lists it and explains the loop guard (pointing it at one
of OpsLog's own ports would feed the stream back into itself, and it
refuses) and that datagrams are passed on verbatim, with no origin header
of the kind the receiving side has had to write code to survive.

Digital Modes and GridTracker gains it as the unicast answer, which is
better than the one it gave: OpsLog relaying to GridTracker on 2238 needs
nothing from GridTracker and no multicast anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 13:44:40 +02:00
rouggyandClaude Opus 5 41a2a38025 chore(wiki): publish the wiki on its own, without cutting a release
Documentation and releases move at different speeds. A page corrected on
a Sunday should not have to wait for the next version, and it should
certainly not be a reason to cut one.

The wiki step moves out of release.ps1 into publish-wiki.ps1, and the
release calls that same file — one implementation rather than two that
drift. Run it from VS Code (Tasks: Run Task -> Publish wiki) or directly;
it clones the wiki repo, copies the pages in and pushes only if something
changed, listing what it is about to commit so the wiki's own history
says which pages moved rather than a column of identical lines.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 13:32:45 +02:00
rouggyandClaude Opus 5 f8f8de4a30 feat(omnirig): say which of its two CW bits means CW on this rig
Reported on an IC-7610 shared through OmniRig: clicking a CW spot put
the radio in CW-R every time, and the only way out was to edit the
OmniRig rig file by hand.

OmniRig's model has two CW modes, PM_CW_U and PM_CW_L, and nothing in it
says which one an .ini calls plain CW. Icom rig files disagree with each
other — on some PM_CW_U is CI-V mode 0x03 (CW), on others 0x07 (CW-R) —
so asking for "CW" is a question with two right answers and OpsLog was
only ever giving one of them.

Settings ▸ CAT ▸ OmniRig now carries the answer for THIS rig, and it is
pushed to a radio that is already connected rather than waiting for a
reconnect: which bit a mode maps to is not worth dropping the link, and
with it WSJT-X's rigctl session. Same shape as the Yaesu RTTY sideband,
for the same reason.

Nothing changes for anyone whose rig file already agrees with us.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 12:05:31 +02:00
rouggyandClaude Opus 5 82cd5c5d0b fix(qsl): every confirmation service has a default, and the wiki explains them
HAMLOG.online was added after most profiles were configured, so it had no
entry in the shipped defaults and no stored value either: it came back
blank, and blank is not a status anybody chose. Every service now starts
the same way — the sent side at R, the received side at N — and a blank
left by a service that did not exist when the operator last saved is
filled in from that. A status they chose themselves is untouched.

Two tests hold the line: no sent side may default to Y, and no field may
be left without a default. Y means "already sent", so it makes the
uploader skip the contact for ever — an operator with eQSL Sent at Y had
a logbook that never reached eQSL, and the only trace was one line in the
application log.

Wiki, both from operator reports:

QSL Management opens with Confirmations — what the page actually is (the
status stamped on every new QSO, not an action), what each status does,
and the warning about Y in the plainest words available, because it fails
silently and by design.

Digital Modes and GridTracker is new. Unicast and multicast explained
from the operating problem rather than the networking: one letterbox that
two programs watch, against a broadcast everyone can tune to. It carries
the real evidence — two starts of one station an hour apart, decodes in
the second and none in the first, the only difference being whether
GridTracker or OpsLog reached port 2237 first — then the settings for
WSJT-X, JTDX, MSHV, GridTracker and OpsLog, the 127.0.0.1-in-the-group-box
mistake, what to do if unicast is unavoidable, and how to check it from
the log.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 09:37:37 +02:00
rouggy 612e265837 fix(udp): a "multicast" row on an address that is not one still listens
Reported from a station whose two WSJT-X rows had been dead for weeks:
both were ticked multicast with 127.0.0.1 in the group box, and both
failed the join on every interface with

    setsockopt: l'adresse demandée n'est pas valide dans son contexte

which names nothing the operator typed and does not say what is wrong
with it. A multicast group runs 224.0.0.0 to 239.255.255.255; 127.0.0.1
is loopback unicast, and it is an understandable thing to type — it is
the address every other field in every other program wants.

The address is now checked before the join. When it is not a multicast
one the row listens on unicast instead, which is what such an address
means, and the log says why it is not multicast. The row works, and the
reason is in a sentence rather than in a kernel error code.
2026-09-08 09:27:09 +02:00
rouggy 3415e12363 docs(changelog): the relaunch fix opens 0.27.19
0.27.18 went out before it was committed, so it does not belong in that
block — and it is the one entry an operator on 0.27.18 needs, since it is
their update that will not come back.
2026-09-08 09:23:32 +02:00
rouggy e0b110392a fix(update): wait for the old process, not for a fixed window
The relaunch after an update stopped working, and the regression is mine:
removing the PowerShell helper — which is what Defender was reading as a
dropper — also removed the wait it was doing. Nothing took over the job.

The numbers made it certain rather than unlucky. The instance being
replaced is allowed THIRTY seconds to shut down (armExitWatchdog forces
it out at that point) because it closes a remote logbook, a CAT session
and sometimes a backup. The new instance was patient with the
single-instance mutex for TWENTY. On any station where shutting down ran
past that, the new process gave up and exited in silence: no window after
an update, and the previous OpsLog still in the task manager. Exactly the
report.

Both relaunch paths now pass --wait-pid, and the new process waits on
that process's handle — a plain kernel wait, which ends the instant the
old one ends, however long or short that is, and looks nothing like a
script starting another program. The mutex retry stays as a backstop and
goes to forty-five seconds, so it is longer than the wait it exists for
rather than shorter.

And when the old process really has not gone, the message says that
instead of "OpsLog is already running" — after an update the operator did
not start a second copy, and what they need to know is which one to
close.

A test keeps the two spawn sites honest: a relaunch added without
--wait-pid is this bug again.
2026-09-08 09:18:06 +02:00
rouggy 38da904f4b chore: release v0.27.18 2026-09-08 00:25:27 +02:00
rouggy b7d1eddfa0 fix(entry): the two report boxes stop moving as well
Once the callsign stopped giving up its width, the RST boxes were the
next widest things in the row and became the ones that shifted when the
date field appeared. Both are now a notch narrower and shrink-0, like the
callsign; "59+20" is the longest report either ever holds and still fits.
2026-09-08 00:24:15 +02:00
rouggy 7208efbba6 fix(entry): the callsign box stops moving when the padlock closes
Its row gains a date field for a manual entry, and a flex row makes room
by shrinking its children — so the widest box, which is the one the eye
is on while typing, was the one that visibly narrowed.

A notch narrower and shrink-0: the size it will always be, with or
without the date, and the slack now comes from the boxes beside it.
2026-09-08 00:21:12 +02:00
rouggy 2414b2077f chore(awards): log a successful bulk recompute, not only a failed one
"I changed an award and the column stayed empty" cannot be answered
without knowing whether the recompute ran at all, how long it took, and
whether it changed anything — and zero rows changed is a real answer
(the definition matches nothing in the log) that looks exactly like a
recompute which never happened.
2026-09-08 00:19:12 +02:00
rouggy 1cbf3ecc4b perf(settings): every field in Preferences types into itself first
Preferences is one component holding two hundred pieces of state, and its
biggest panels are eight hundred lines of form — external services, CAT.
A plain controlled input sends every keystroke into that state, so every
character re-rendered the whole dialog, and the letter arrived after the
finger had left the key.

BufferedInput keeps the text where it is typed and hands it up 120 ms
later. A drop-in for Input, so the change is one import rather than a
hundred call sites — which means it has to be right in every shape those
call sites take:

  • Blur flushes at once, so clicking Save cannot lose the last word, and
    so does unmounting: a panel changed mid-word still hands up what was
    there.
  • A value that comes back DIFFERENT from what was sent up is adopted,
    even with the focus in the field. That is what keeps the fields that
    normalise as you type working — the callsign box that upper-cases,
    the port box that drops everything but digits. They echo a corrected
    value, and the correction wins.
  • A value changed from outside while the field is idle wins too: that
    is how loading the settings, or switching profile, refills the form.
  • Checkboxes, colour pickers and file fields pass straight through.
2026-09-08 00:15:44 +02:00
rouggy ccae8599eb docs(changelog): the padlock and what followed it are 0.27.18
0.27.17 went out, so everything committed after its release commit needs
a block of its own — the padlock, the duplicated section name, the
digital row of the matrix and the MQTT chip.

The backdrop-blur fix goes with them rather than staying in 0.27.17. It
had been folded into that version's typing-lag entry, but it is not in
the binary 0.27.17 shipped: what that release actually carries is the
cluster-macro half, which is what its entry now says again.
2026-09-08 00:12:18 +02:00
rouggy cdd91ab7e6 fix(ui): one section name, a chosen digital row, no MQTT chip
Three things reported together.

Preferences said the section name twice: a small line above every panel
repeated the heading right underneath it — "GENERAL" over "General" —
while the sidebar next to them already shows which section is open,
highlighted. Two lines and a highlight for one fact; the small line goes.

The band matrix opens on the digital row the operator chooses. Its
digital row has always rotated — DIGI, then each digital mode in their
own list — but it always STARTED on DIGI, so somebody who only works FT8
clicked through to their own mode on every callsign. Settings ▸ General
now says where the rotation starts, and the dropdown offers exactly the
modes the matrix rotates through (the phone-mode rule is now shared
rather than copied, so the two cannot drift). DIGI stays the default: it
is the right answer for anyone working several digital modes.

And the MQTT chip is gone from the status bar. That is the name of a
message protocol, not of anything an operator has — a chip in the status
bar has to say what it is about, and this one told nobody anything. What
it carried is shown in the Chase New panel, which is the place that uses
it; its poller went with it.
2026-09-08 00:10:06 +02:00
rouggy 5b7469ae44 feat(entry): one padlock instead of five
Logging a contact from a piece of paper — a contest sheet, a friend's
report, a QSO worked on another radio — means the frequency, the band,
the mode, the date and both times all have to stop following the rig and
the clock at once. That is a single decision, and it was five clicks in
five different places, each of which had to be found first.

The padlock beside Start UTC now holds all five and releases all five,
with the same behaviour as before: locking pre-fills the times so the
fields are not empty, unlocking hands them back to the clock and snaps a
QSO already in progress to now.

The five per-field locks stay underneath. Everything downstream reads
them, and individually they say the right thing — "this value is
decoupled from the rig". Only the control is one.
2026-09-08 00:04:56 +02:00
rouggy 3c7ea2d894 perf(ui): the dialogs you type in no longer sit on a blurred backdrop
A backdrop-filter covers the whole window and is recomputed every time
anything above it repaints. Behind these dialogs is an application that
never stops moving — CAT polls four times a second, spots arrive, meters
sweep, maps redraw — so the filter was being recomputed continuously, and
each keystroke's repaint dragged a full-window blur with it. That is why
the lag was felt in Preferences and nowhere else, and why fixing the
cluster macros did not end it.

Worse in one place: the cluster server editor opens FROM Preferences, so
its overlay was the SECOND full-window filter stacked over the first.
That is exactly where the delay was first reported.

The dialogs an operator types in for minutes — Preferences, the cluster
editor, the QSO editor, bulk edit, alert rules, award definitions — now
dim the background harder instead of blurring it. Everything else keeps
the blur: a confirmation you click through in a second costs nothing.
2026-09-08 00:00:47 +02:00
rouggy 55fbfa4499 chore: release v0.27.17 2026-09-07 23:56:40 +02:00
rouggy b655cd9631 feat(station): the radio and both keyers on the Station Control tab
This tab began as the relay and rotator dashboard and stopped there: the
three things an operator touches most — the radio, the CW keyer, the
voice keyer — were the ones missing from the page that claims to show the
station.

The radio card carries the frequency and the mode large, because that is
what gets glanced at, and the split pair only when there IS a split: a
second frequency shown at all times is one more number to read past. When
CAT is down it says which kind of down — switched off, or on and not
answering.

The CW keyer card carries the speed, which is the control an operator
reaches for mid-QSO when a station comes back faster than expected, and
Stop beside it because a message going to the wrong callsign has to end
now. The voice keyer card carries the recorded messages themselves: a
card that only said "idle" would be a light, not a control.

Each polls its own binding and holds its own state, like the supply card
above them, so they drop into the grid and reorder with everything else.
The two keyers appear only when there is something behind them — a port
configured, a message actually recorded — because an operator who works
neither should not be handed two dead cards. That question is asked once
on opening the tab: a keyer is bought and wired, not something that
appears mid-session.
2026-09-07 23:48:10 +02:00
37 changed files with 1373 additions and 131 deletions
+87 -6
View File
@@ -121,6 +121,12 @@ const (
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file. // Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B" 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 // 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. // name. Every backend, because the reason is the radio, not the link.
keyCATDigiUSB = "cat.digi_usb" 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 // 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. // operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "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 // 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. // than naming the mode. What the QSO is LOGGED as never changes.
DigiAsUSB bool `json:"digi_as_usb"` DigiAsUSB bool `json:"digi_as_usb"`
@@ -2979,8 +2988,11 @@ func (a *App) RestartApp() error {
return fmt.Errorf("locate executable: %w", err) return fmt.Errorf("locate executable: %w", err)
} }
// --relaunch: the child waits for OUR mutex instead of declaring us a // --relaunch: the child waits for OUR mutex instead of declaring us a
// duplicate — this instance is quitting, just not always fast enough. // duplicate — this instance is quitting, just not always fast enough. And
cmd := exec.Command(exe, "--relaunch") // --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) cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err) return fmt.Errorf("relaunch OpsLog: %w", err)
@@ -5612,11 +5624,19 @@ func (a *App) recomputeAwardRefsForCodesAsync(codes []string) {
// definition / reference-list change, or a bulk ADIF import. // definition / reference-list change, or a bulk ADIF import.
func (a *App) recomputeAwardRefsAsync() { func (a *App) recomputeAwardRefsAsync() {
go func() { go func() {
t0 := time.Now()
n, err := a.RecomputeAllAwardRefs() n, err := a.RecomputeAllAwardRefs()
if err != nil { if err != nil {
applog.Printf("award_refs: bulk recompute failed: %v", err) applog.Printf("award_refs: bulk recompute failed: %v", err)
return return
} }
// Logged on SUCCESS too, and this is why: "I changed an award and the
// column stayed empty" is unanswerable without knowing whether the
// recompute ran at all, how long it took, and whether it changed
// anything. Zero rows changed is a real answer — the definition matches
// nothing in the log — and it looks exactly like a recompute that never
// happened.
applog.Printf("award_refs: bulk recompute done — %d rows changed in %s", n, time.Since(t0).Round(time.Millisecond))
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n) wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
} }
@@ -8355,7 +8375,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") 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 { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -8461,6 +8481,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if out.DigitalDefault == "" { if out.DigitalDefault == "" {
out.DigitalDefault = "FT8" out.DigitalDefault = "FT8"
} }
out.OmniRigCWLower = m[keyCATOmniRigCWLower] == "1"
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" { if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v out.OmniRigVFO = v
} }
@@ -8577,6 +8598,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATBackend: s.Backend, keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)), keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATOmniRigCWLower: boolStr(s.OmniRigCWLower),
keyCATDigiUSB: boolStr(s.DigiAsUSB), keyCATDigiUSB: boolStr(s.DigiAsUSB),
keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexPort: strconv.Itoa(s.FlexPort),
@@ -11128,6 +11150,19 @@ func (a *App) UILog(msg string) {
// that hasn't customised them: request the online confirmations (eQSL/LoTW/ // that hasn't customised them: request the online confirmations (eQSL/LoTW/
// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and // Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and
// "N" for paper and everything received. // "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 { func defaultQSLDefaults() QSLDefaults {
return QSLDefaults{ return QSLDefaults{
QSLSent: "N", QSLRcvd: "N", QSLSent: "N", QSLRcvd: "N",
@@ -11135,10 +11170,40 @@ func defaultQSLDefaults() QSLDefaults {
LOTWSent: "R", LOTWRcvd: "N", LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R", ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
HamqthStatus: "R", HamqthStatus: "R",
HamlogStatus: "R", HamlogCfm: "N",
QRZComStatus: "R", QRZComCfm: "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) { func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out := QSLDefaults{} out := QSLDefaults{}
if a.settings == nil { if a.settings == nil {
@@ -11175,7 +11240,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.HamlogStatus = m[keyQSLDefaultHamlogStatus] out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
out.HamqthStatus = m[keyQSLDefaultHamqthStatus] out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
out.HamlogCfm = m[keyQSLDefaultHamlogCfm] out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
return out, nil return fillMissingQSLDefaults(out), nil
} }
// SaveQSLDefaults persists the configured defaults. Future QSO inserts // SaveQSLDefaults persists the configured defaults. Future QSO inserts
@@ -16284,9 +16349,25 @@ func (a *App) reloadCAT() {
if s.Enabled && s.Backend == "yaesu" && a.cat != nil { if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return 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 { if sig := catLinkSig(s); sig == a.catSig {
// 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") applog.Printf("cat: settings saved, link unchanged — staying connected")
}
return return
} else { } else {
a.catSig = sig a.catSig = sig
@@ -16308,7 +16389,7 @@ func (a *App) reloadCAT() {
// Spawning OmniRig.exe ourselves (even with /Embedding) on every // Spawning OmniRig.exe ourselves (even with /Embedding) on every
// reloadCAT raised the existing instance's window to the front, // reloadCAT raised the existing instance's window to the front,
// which is what Log4OM avoids by relying entirely on COM activation. // 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": case "flex":
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed. // Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots) fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
+48 -2
View File
@@ -1,4 +1,48 @@
[ [
{
"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": "",
"en": [
"The dialogs you type in no longer sit on a blurred backdrop. A backdrop filter covers the whole window and is recomputed every time anything above it repaints — and behind these dialogs is an application that never stops moving: CAT polling four times a second, spots arriving, meters sweeping, maps redrawing. Worse in one place: the cluster editor opens from Preferences, so its overlay was a second full-window filter stacked over the first. Preferences, the cluster editor, the QSO editor, bulk edit, alert rules and award definitions now dim the background instead of blurring it; everything else keeps the blur.",
"One padlock on the entry form instead of five. Logging a contact from paper — a contest sheet, a friend's report, a QSO worked on another radio — means the frequency, the band, the mode, the date and both times all have to stop following the rig and the clock at once. That was five clicks in five different places, each of which had to be found first. The padlock beside Start UTC now holds all of them, and releases all of them.",
"Preferences no longer says the section name twice — the small line above each panel repeated the heading right under it, and the sidebar beside it already shows which section is open.",
"The band matrix can open on the digital mode you actually work. An operator who only ever does FT8 was shown DIGI every time and had to click through to their own mode on every callsign; Settings → General now chooses which digital row the matrix starts on. The row still rotates when you click it, and DIGI — all of them together — stays the default.",
"The MQTT chip is gone from the status bar. That is the name of a message protocol, not of anything an operator has. The state it carried — the openings feed up or down, and how many reports have arrived — is in the Chase New panel, which is the place that uses it.",
"The callsign box no longer narrows when you close the padlock. Its row gains a date field for a manual entry, and a flex row makes room by shrinking its children — so the widest box, the one the eye is on while typing, was the one that visibly moved. The callsign and both report boxes are now a notch narrower and fixed there, whether the date is showing or not."
],
"fr": [
"Les dialogues dans lesquels on tape ne reposent plus sur un fond flouté. Un filtre de fond couvre toute la fenêtre et est recalculé chaque fois que quoi que ce soit au-dessus se repeint — et derrière ces dialogues il y a une application qui ne s'arrête jamais de bouger : le CAT qui interroge quatre fois par seconde, les spots qui arrivent, les vumètres qui balaient, les cartes qui se redessinent. Pire à un endroit : l'éditeur de cluster s'ouvre depuis les Préférences, donc son fond était un deuxième filtre plein écran empilé sur le premier. Les Préférences, l'éditeur de cluster, l'éditeur de QSO, l'édition groupée, les règles d'alerte et les définitions de diplômes assombrissent désormais le fond au lieu de le flouter ; tout le reste garde le flou.",
"Un seul cadenas dans la saisie au lieu de cinq. Enregistrer un contact depuis une feuille — un carnet de concours, le report d'un ami, un QSO fait sur une autre radio — suppose que la fréquence, la bande, le mode, la date et les deux heures cessent tous en même temps de suivre le poste et l'horloge. C'étaient cinq clics à cinq endroits différents, qu'il fallait d'abord trouver. Le cadenas à côté de Début UTC les fige maintenant tous, et les libère tous.",
"Les Préférences ne disent plus deux fois le nom de la section — la petite ligne au-dessus de chaque panneau répétait le titre juste en dessous, et la barre latérale montre déjà laquelle est ouverte.",
"La matrice peut s'ouvrir sur le mode numérique que vous travaillez vraiment. Celui qui ne fait que du FT8 voyait DIGI à chaque fois et devait cliquer jusqu'à son mode pour chaque indicatif ; Réglages → Général choisit désormais la ligne numérique sur laquelle la matrice démarre. La ligne continue de tourner au clic, et DIGI — tous ensemble — reste le défaut.",
"La pastille MQTT disparaît de la barre d'état. C'est le nom d'un protocole de messages, pas de quelque chose que possède un opérateur. Ce qu'elle indiquait — le flux d'ouvertures actif ou non, et le nombre de reports arrivés — est dans le panneau Chasse au nouveau, à l'endroit qui s'en sert.",
"Le champ indicatif ne rétrécit plus quand on ferme le cadenas. Sa ligne gagne un champ date pour une saisie manuelle, et une ligne flex fait de la place en rétrécissant ses enfants — donc le plus large, celui que l'œil suit pendant la frappe, était celui qui bougeait visiblement. L'indicatif et les deux champs de report sont désormais un cran plus étroits et fixes, que la date soit affichée ou non."
]
},
{ {
"version": "0.27.17", "version": "0.27.17",
"date": "", "date": "",
@@ -8,7 +52,8 @@
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.", "Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.",
"The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.", "The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.",
"The cluster editor offers a list of known nodes. Setting up a telnet cluster is the step operators get stuck on: the address and the port are two pieces of information nobody has to hand, and a typo in either looks exactly like a node that is down. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.", "The cluster editor offers a list of known nodes. Setting up a telnet cluster is the step operators get stuck on: the address and the port are two pieces of information nobody has to hand, and a typo in either looks exactly like a node that is down. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.",
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop." "Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop.",
"Station Control shows what commands the station, not only what it switches. The radio is there now — frequency, mode, band, and the split pair when there is one — with the CW keyer beside it (speed up and down, and Stop, because a message going to the wrong callsign has to end now) and the voice keyer with its recorded messages as buttons, so a CQ goes out without leaving the tab. The two keyers appear only when there is something behind them: a port configured, or a message actually recorded. All three move and reorder with the other cards."
], ],
"fr": [ "fr": [
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.", "[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.",
@@ -16,7 +61,8 @@
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.", "Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.",
"Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.", "Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.",
"L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.", "L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.",
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie." "Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie.",
"Contrôle station montre ce qui commande la station, et plus seulement ce qui la commute. La radio y figure désormais — fréquence, mode, bande, et le couple split quand il y en a un — avec à côté le manipulateur CW (vitesse en plus ou en moins, et Stop, parce qu'un message parti vers le mauvais indicatif doit s'arrêter tout de suite) et le manipulateur vocal avec ses messages enregistrés en boutons, pour lancer un CQ sans quitter l'onglet. Les deux manipulateurs n'apparaissent que s'il y a quelque chose derrière : un port configuré, ou un message réellement enregistré. Les trois se déplacent et se réordonnent avec les autres cartes."
] ]
}, },
{ {
+66 -63
View File
@@ -52,7 +52,7 @@ import {
ReportLiveActivity, LiveLastQSOAgeSec, ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate, GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries, GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
@@ -566,7 +566,9 @@ function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle
type="button" type="button"
tabIndex={-1} tabIndex={-1}
onClick={onToggle} onClick={onToggle}
title={`${on ? 'Unlock' : 'Lock'} ${title}`} // The whole tooltip, not a verb glued to a noun: the caller knows what
// this padlock does and can say it in the operator's own language.
title={title}
className={cn( className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors', 'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground', on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
@@ -618,31 +620,34 @@ export default function App() {
}); });
const locksRef = useRef(locks); const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]); useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => { // ONE padlock, not five.
setLocks((s) => { //
const wasLocked = s[k]; // Logging a contact from a piece of paper — a contest sheet, a friend's
const next = { ...s, [k]: !wasLocked }; // report, a QSO worked on another radio — means the frequency, the band, the
if (wasLocked) { // mode, the date and both times all have to stop following the rig and the
// Unlocking → restore automatic behavior. Without this the locked // clock at once. That is a single decision, and it used to be five clicks in
// value would linger forever: a stale Start time would never refresh // five different places, each of which had to be found first.
// even after a new callsign is entered. //
if (k === 'start') { // The five per-field locks stay underneath, because everything downstream
// If a QSO is currently in progress (callsign typed), snap start // reads them and they say the right thing individually ("this value is
// to now since we missed the auto-start moment. Otherwise clear. // decoupled from the rig"). Only the control is one.
const manualEntry = locks.start && locks.end && locks.band && locks.mode && locks.freq;
const setManualEntry = (on: boolean) => {
setLocks({ band: on, mode: on, freq: on, start: on, end: on });
if (on) {
// Pre-filled with today's date and the current UTC time so the fields are
// not empty; the operator only has to correct them.
const now = new Date();
setQsoStartedAt((d) => d ?? now);
setQsoEndedAt((d) => d ?? now);
} else {
// Back to automatic. Without this the frozen values would linger for
// ever: a start time held from a backdated entry would never refresh,
// even after a new callsign is typed. A QSO already in progress snaps its
// start to now, since the moment it would have been taken has passed.
setQsoStartedAt(callsign.trim() ? new Date() : null); setQsoStartedAt(callsign.trim() ? new Date() : null);
} else if (k === 'end') {
// Drop the frozen end so the field tracks the live UTC clock.
setQsoEndedAt(null); setQsoEndedAt(null);
} }
} else {
// Locking (manual / deferred entry) → pre-fill with today's date + the
// current UTC time so the fields aren't empty; the operator just adjusts.
const now = new Date();
if (k === 'start') setQsoStartedAt((d) => d ?? now);
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
}
return next;
});
}; };
const [band, setBand] = useState('20m'); const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB'); const [mode, setMode] = useState('SSB');
@@ -2354,16 +2359,6 @@ export default function App() {
return () => window.clearInterval(t); return () => window.clearInterval(t);
}, []); }, []);
// PSK Reporter feed, for the status-bar chip. Polled slowly: the chip only
// says up or down, and the count behind it is a tooltip.
const [pskr, setPskr] = useState<any>(null);
useEffect(() => {
const load = () => { GetPSKReporterStatus().then(setPskr).catch(() => {}); };
load();
const t = window.setInterval(load, 10000);
return () => window.clearInterval(t);
}, []);
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator // "ON AIR" status-bar badge: mirrors the multi-op live status this operator
// publishes — online (blinking) when a QSO was logged in the last 5 min, else // publishes — online (blinking) when a QSO was logged in the last 5 min, else
// offline. Publishing is always on for a shared MySQL logbook (no user toggle: // offline. Publishing is always on for a shared MySQL logbook (no user toggle:
@@ -2599,6 +2594,15 @@ export default function App() {
// half hour — long enough to hold a whole opening, short enough that a night // 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. // of FT8 on 20 m does not turn the list into something no filter can rescue.
const DECODE_KEEP_MS = 30 * 60 * 1000; 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 [decodes, setDecodes] = useState<DecodeRow[]>([]);
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]); const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
// The LIVE transmit state, replaced on every Status — what is going out now // The LIVE transmit state, replaced on every Status — what is going out now
@@ -3892,7 +3896,9 @@ export default function App() {
return !b2 || (d.band ?? '').toLowerCase() === b2; return !b2 || (d.band ?? '').toLowerCase() === b2;
}); });
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff); 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(); }; flushDecodesRef.current = () => { void flushDecodes(); };
@@ -5421,8 +5427,15 @@ export default function App() {
// "59+30" turned up, which is five characters plus its padding and no longer // "59+30" turned up, which is five characters plus its padding and no longer
// fitted. The RST fields are back to their original width; the callsign keeps // fitted. The RST fields are back to their original width; the callsign keeps
// the rest of the row. // the rest of the row.
// shrink-0, and a notch narrower than it used to be.
//
// The row it sits in gains a date field when the padlock is closed, and a
// flex row makes room by shrinking its children — so the callsign box, the
// widest of them, visibly narrowed the moment the operator started a manual
// entry. The field the eye is on while typing must not move. It is now the
// size it will always be, and the slack comes from the boxes beside it.
const callsignBlock = ( const callsignBlock = (
<div className="flex flex-col w-56" data-esm="call"> <div className="flex flex-col w-52 shrink-0" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}> <Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
<span className="text-primary font-semibold">{t('field.callsign')}</span> <span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && ( {lookupBusy && (
@@ -5568,8 +5581,12 @@ export default function App() {
</div> </div>
</div> </div>
); );
// Both report boxes: a notch narrower and pinned, for the same reason as the
// callsign. They were the next widest things in the row, so once the callsign
// stopped giving, they were the ones that moved when the date appeared.
// "59+20" is the longest report either ever holds and still fits.
const rstTxBlock = ( const rstTxBlock = (
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label> <div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
{/* The wheel steps the report an S-unit on RST, a decibel on a digital {/* The wheel steps the report an S-unit on RST, a decibel on a digital
one. Wheeling is the same gesture as saying "he is a bit stronger than one. Wheeling is the same gesture as saying "he is a bit stronger than
that", and it beats retyping three characters between overs. */} that", and it beats retyping three characters between overs. */}
@@ -5579,7 +5596,7 @@ export default function App() {
</div> </div>
); );
const rstRxBlock = ( const rstRxBlock = (
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label> <div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} /> onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
@@ -5615,7 +5632,7 @@ export default function App() {
) : null; ) : null;
const startBlock = ( const startBlock = (
<div className="flex flex-col w-28"> <div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={manualEntry} title={manualEntry ? t('field.manualEntryOff') : t('field.manualEntryOn')} onToggle={() => setManualEntry(!manualEntry)} /></Label>
<Input <Input
readOnly={!locks.start} readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1} tabIndex={locks.start ? 0 : -1}
@@ -5634,7 +5651,7 @@ export default function App() {
); );
const endBlock = ( const endBlock = (
<div className="flex flex-col w-28"> <div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')}</Label>
<Input <Input
readOnly={!locks.end} readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1} tabIndex={locks.end ? 0 : -1}
@@ -5905,7 +5922,7 @@ export default function App() {
// used in the full layout to save vertical height. // used in the full layout to save vertical height.
const bandRow = ( const bandRow = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label> <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')}</Label>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}> <Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -5916,7 +5933,7 @@ export default function App() {
); );
const modeRow = ( const modeRow = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label> <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')}</Label>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}> <Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -6014,7 +6031,7 @@ export default function App() {
}; };
const freqBlock = ( const freqBlock = (
<div className="flex flex-col w-32"> <div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')}</Label>
<Input <Input
tabIndex={-1} tabIndex={-1}
className="font-mono" className="font-mono"
@@ -9131,26 +9148,12 @@ export default function App() {
</button> </button>
); );
})} })}
{/* PSK Reporter, next to the hardware chips because it is the same {/* The PSK Reporter chip used to sit here, labelled MQTT. That is
kind of fact: a link that is either up or it is not. Shown ONLY the name of a message protocol, not of anything an operator has:
when the opening watch is on a permanently grey chip for a a chip in the status bar has to say what it is about, and this
feature nobody enabled is clutter, and the bar is 28 px. one told nobody anything. The state it carried the openings
feed up or down, and how many reports have arrived is shown in
The decode count is in the tooltip rather than the chip: it moves the Chase New panel, which is the place that uses it. */}
several times a second on an open band, and a number flickering in
the corner of the eye is not information, it is a distraction. */}
{pskr?.running && (
<button
type="button"
title={t('pskr.tip', { n: pskr.received ?? 0, bands: (pskr.bands ?? []).join(' ') })}
onClick={() => { setSettingsSection('cluster'); setShowSettings(true); }}
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer shrink-0"
>
<span className={cn('size-2 rounded-full',
(pskr.received ?? 0) > 0 ? 'bg-success' : 'bg-warning')} />
MQTT
</button>
)}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY {/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific), logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for so it is always shown. Gating it on MySQL made it vanish for
+1 -1
View File
@@ -141,7 +141,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl"> <DialogContent overlayBlur={false} className="max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle> <DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
<DialogDescription>{t('altm.desc')}</DialogDescription> <DialogDescription>{t('altm.desc')}</DialogDescription>
+1 -1
View File
@@ -403,7 +403,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> <DialogContent overlayBlur={false} className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b"> <DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle> <DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader> </DialogHeader>
+15 -1
View File
@@ -60,7 +60,11 @@ const DEFAULT_BANDS: { tag: string; label: string }[] = [
]; ];
const CLASSES = ['PH', 'CW', 'DIG'] as const; const CLASSES = ['PH', 'CW', 'DIG'] as const;
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']); export const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
// Which digital row the matrix opens on. Empty = DIG, the group of them all.
// Set in Settings ▸ General; see the rotation below.
export const MATRIX_DIGI_KEY = 'opslog.matrixDigiMode';
function classMatchesMode(cls: string, mode: string): boolean { function classMatchesMode(cls: string, mode: string): boolean {
const u = (mode || '').toUpperCase(); const u = (mode || '').toUpperCase();
if (cls === 'PH') return PHONE_MODES.has(u); if (cls === 'PH') return PHONE_MODES.has(u);
@@ -143,7 +147,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes,
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)), .filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes], [modes],
); );
// Where the rotation STARTS. An operator who only ever works FT8 was shown
// "DIG" every time and had to click to the mode they actually use, on every
// callsign — so the row they want is the one it opens on. Empty (the default)
// keeps DIG, which is right for anyone working several digital modes.
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
useEffect(() => {
const want = (localStorage.getItem(MATRIX_DIGI_KEY) || '').toUpperCase().trim();
if (!want) { setDigIdx(0); return; }
const i = digModes.indexOf(want);
setDigIdx(i >= 0 ? i + 1 : 0);
}, [digModes]);
// A shorter mode list (the operator edited it) must not strand the rotation // A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists. // on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0; const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
+1 -1
View File
@@ -193,7 +193,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md"> <DialogContent overlayBlur={false} className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('bulk.title')}</DialogTitle> <DialogTitle>{t('bulk.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
+14 -2
View File
@@ -9,6 +9,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useOperatingLists } from '@/lib/operatingLists';
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead'; import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid'; import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector'; 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) { 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 { t } = useI18n();
const oper = useOperatingLists(tab);
const [internalOpen, setInternalOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided const open = tab ?? internalOpen; // controlled when `tab` is provided
@@ -476,11 +478,21 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </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}> <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>
<Field label={t('detp.antenna')} span={3}> <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> </Field>
{satelliteMode && ( {satelliteMode && (
<> <>
+17 -3
View File
@@ -19,6 +19,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useOperatingLists } from '@/lib/operatingLists';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n'; 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) { export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
const { t } = useI18n(); 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); // 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 // 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. // imported/legacy value is never silently dropped from the dropdown.
@@ -542,7 +546,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> <DialogContent overlayBlur={false} className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2"> <DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>{t('qedit.title')}</DialogTitle> <DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span> <span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
@@ -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.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.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.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> {/* The station's own rigs and antennas (Settings ▸ Operating
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F> 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> </div>
</TabsContent> </TabsContent>
+13 -5
View File
@@ -81,8 +81,12 @@ type BeamKind = 'antenna' | 'hover';
const TARGET_YELLOW = '#FBBF24'; const TARGET_YELLOW = '#FBBF24';
const MAP_BG_TOP = '#0B1015'; const MAP_BG_TOP = '#0B1015';
const MAP_BG_BOTTOM = '#080C11'; const MAP_BG_BOTTOM = '#080C11';
const MAP_LAND = '#202832'; // The continents, and they have to be VISIBLE. At #202832 on a #0B1015 ground
const MAP_LAND_SECONDARY = '#25303A'; // 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 // What each rotor was last seen at, and what it was last told to do, kept
// OUTSIDE the component and keyed by rotor index. // OUTSIDE the component and keyed by rotor index.
@@ -289,7 +293,11 @@ function RotorCompassDial({
}; };
return ( 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 <svg
viewBox={`0 0 ${SIZE} ${SIZE}`} viewBox={`0 0 ${SIZE} ${SIZE}`}
className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')} className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
@@ -338,10 +346,10 @@ function RotorCompassDial({
))} ))}
</defs> </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 && ( {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} />
<path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" /> <path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
</g> </g>
+77 -10
View File
@@ -74,7 +74,11 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { WebPublishPanel } from '@/components/WebPublishPanel'; import { WebPublishPanel } from '@/components/WebPublishPanel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; // Every text field in Preferences types into itself and hands the value up a
// moment later. This dialog is one component with two hundred pieces of state
// and panels eight hundred lines long, so a plain controlled input re-rendered
// the whole thing per character — see BufferedInput.
import { BufferedInput as Input } from '@/components/ui/buffered-input';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -95,6 +99,7 @@ import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel'; import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros'; import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
import { CLUSTER_PRESETS } from '@/lib/clusterPresets'; import { CLUSTER_PRESETS } from '@/lib/clusterPresets';
import { MATRIX_DIGI_KEY, PHONE_MODES as MATRIX_PHONE_MODES } from '@/components/BandSlotGrid';
type LookupSettings = LookupSettingsForm; type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm; type StationSettings = StationSettingsForm;
@@ -1854,7 +1859,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// IC-7610 the moment they chose Other. // IC-7610 the moment they chose Other.
const [icomCustom, setIcomCustom] = useState(false); const [icomCustom, setIcomCustom] = useState(false);
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', 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: '', 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, 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, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
@@ -1866,13 +1871,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// else writes the backend (loading a profile, an older settings file). // else writes the backend (loading a profile, an older settings file).
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand; const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link; 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 applyCatBrand = (id: string) => {
const b = CAT_BRANDS.find((x) => x.id === id); const b = CAT_BRANDS.find((x) => x.id === id);
if (!b) return; if (!b) return;
// Keep the connection when the new brand offers it, otherwise take its // Keep the connection when the new brand offers it, otherwise take its
// first — picking Flex from Kenwood-over-USB has to land on something. // first — picking Flex from Kenwood-over-USB has to land on something.
const link = b.links.includes(catLink) ? catLink : b.links[0]; 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 applyCatLink = (link: string) => {
const b = CAT_BRANDS.find((x) => x.id === catBrand); const b = CAT_BRANDS.find((x) => x.id === catBrand);
@@ -2007,6 +2020,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1'); const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
// The digital row the band matrix opens on: '' = DIG, the group of them all.
const [matrixDigi, setMatrixDigi] = useState(() => localStorage.getItem(MATRIX_DIGI_KEY) || '');
// The operator's own digital modes, which is what the matrix rotates through
// — the same rule it uses: everything in their mode list that is neither CW
// nor a phone mode.
const digitalModeNames = useMemo(
() => (lists.modes ?? [])
.map((m: any) => String(m?.name ?? '').toUpperCase().trim())
.filter((m) => m && m !== 'CW' && !MATRIX_PHONE_MODES.has(m)),
[lists.modes],
);
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1'); const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion()); const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1'); const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
@@ -2812,7 +2836,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
} }
} }
const breadcrumb = useMemo(() => { const k = SECTION_KEY[selected]; return k ? t(k) : (SECTION_LABELS[selected] ?? selected); }, [selected, t]);
// === Section content renderers === // === Section content renderers ===
@@ -3563,6 +3586,14 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} /> <Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
{t('cat.enable')} {t('cat.enable')}
</label> </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. {/* BRAND, then CONNECTION.
The backend list mixed the two: "Icom (USB)" and "Icom (network)" The backend list mixed the two: "Icom (USB)" and "Icom (network)"
@@ -3625,6 +3656,16 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p> <p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
</div> </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' && ( {catCfg.backend === 'flex' && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
@@ -3638,7 +3679,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} /> onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div> </div>
<div className="col-span-2"> <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> </div>
{/* What OpsLog DOES with a Flex panadapter spots, decode spots, {/* What OpsLog DOES with a Flex panadapter spots, decode spots,
the DAX switch for voice messages moved to Settings the DAX switch for voice messages moved to Settings
@@ -8106,6 +8147,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} /> <Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span> <span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
</label> </label>
{/* Which digital row the band matrix opens on. An operator who works
only FT8 was shown DIG every time and had to click through to the
mode they actually use, on every callsign. The row still rotates
this only says where it starts. */}
<div className="flex items-center gap-2 text-sm">
<span title={t('gen.matrixDigiHint')}>{t('gen.matrixDigi')}</span>
<Select value={matrixDigi || '_'} onValueChange={(v) => {
const next = v === '_' ? '' : v;
setMatrixDigi(next);
writeUiPref(MATRIX_DIGI_KEY, next);
}}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_">DIGI</SelectItem>
{digitalModeNames.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
{/* Distances are computed in km everywhere and converted at display {/* Distances are computed in km everywhere and converted at display
time see lib/units. Changing this repaints the columns that time see lib/units. Changing this repaints the columns that
@@ -8858,7 +8917,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> {/* No backdrop blur. This dialog is open for minutes with the operator
typing in it, and the application behind it never stops repainting
a full-window backdrop filter is then recomputed under every one of
those repaints, which is what the delay between key and letter was. */}
<DialogContent overlayBlur={false} className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('settings.title')}</DialogTitle> <DialogTitle>{t('settings.title')}</DialogTitle>
<DialogDescription className="sr-only">Configure OpsLog modules station, lookup, hardware</DialogDescription> <DialogDescription className="sr-only">Configure OpsLog modules station, lookup, hardware</DialogDescription>
@@ -8874,10 +8937,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</aside> </aside>
{/* Right content pane */} {/* Right content pane */}
{/* No breadcrumb line. It said the same word as the heading right
underneath it "GENERAL" over "General" and the sidebar
beside it already shows which section is open, highlighted. Two
lines and a highlight for one fact. */}
<div className="overflow-y-auto p-6"> <div className="overflow-y-auto p-6">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{breadcrumb}
</div>
<PanelHost key={selected} render={PANELS[selected]} /> <PanelHost key={selected} render={PANELS[selected]} />
{err && ( {err && (
@@ -8971,7 +9035,10 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
(p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port); (p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port);
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}> <Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-[640px] px-6"> {/* Opened from Preferences, so its overlay would be the SECOND
full-window backdrop filter stacked over a moving page which is
where the typing delay was first noticed. */}
<DialogContent overlayBlur={false} className="max-w-[640px] px-6">
<DialogHeader className="px-2"> <DialogHeader className="px-2">
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle> <DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
+205 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -25,6 +25,9 @@ import {
GetAmpStatuses, GetFlexState, GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings, GetTunerGeniusStatus, GetTunerGeniusSettings,
GetPSUStatus, GetPSUSettings, SetPSUOutput, GetPSUStatus, GetPSUSettings, SetPSUOutput,
GetCATState,
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -82,6 +85,181 @@ function PSUCard({ st, busy, onToggle, t }: {
); );
} }
// ── What commands the station, and not only what it switches ───────────────
//
// This tab began as the relay and rotator dashboard, and stopped there: the
// three things an operator touches most — the radio, the CW keyer and the voice
// keyer — were the ones missing from the page that claims to show the station.
//
// Each card polls its own binding and holds its own state, like PSUCard above.
// That is deliberate: they can then be dropped into the grid, reordered and
// hidden with everything else, and adding one costs nothing to the panel around
// it. None of them tries to be the full console — a card says what the thing is
// doing and offers the one or two controls worth reaching for from here.
const fmtMHz = (hz: number) => (hz > 0 ? (hz / 1e6).toFixed(6) : '—');
// The radio. The frequency and the mode large, because that is what an operator
// glances at, and the split pair underneath only when there IS a split — a
// second frequency shown at all times is one more number to read past.
function RigCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetCATState().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Radio className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{st?.rig || t('station.rig')}</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold tabular-nums leading-none">{fmtMHz(st?.freq_hz ?? 0)}</span>
<span className="text-xs text-muted-foreground">MHz</span>
</div>
<div className="flex items-center gap-2 flex-wrap text-[11px]">
{!!st?.mode && <span className="rounded px-1.5 py-px font-semibold bg-primary/15 text-primary border border-primary/30">{st.mode}</span>}
{!!st?.band && <span className="text-muted-foreground">{st.band}</span>}
{!!st?.vfo && <span className="text-muted-foreground">VFO {st.vfo}</span>}
{!!st?.backend && <span className="ml-auto text-muted-foreground/70 truncate">{st.backend}</span>}
</div>
{st?.split && (
<div className="flex items-center gap-2 text-[11px] tabular-nums">
<span className="rounded px-1.5 py-px font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border">SPLIT</span>
<span className="text-muted-foreground">RX {fmtMHz(st?.freq_rx_hz ?? 0)}</span>
</div>
)}
{!on && (
<div className="text-[11px] text-muted-foreground truncate" title={st?.error || ''}>
{st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')}
</div>
)}
</div>
</div>
);
}
// The CW keyer. Speed is the control an operator reaches for mid-QSO — a
// station answers faster or slower than expected and the reply has to match —
// so it is here rather than only in the docked panel, and Stop is beside it
// because a message sent to the wrong callsign has to end NOW.
function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
const wpm = st?.wpm || 0;
const step = (d: number) => {
const w = Math.max(5, Math.min(50, wpm + d));
setSt((cur: any) => ({ ...(cur ?? {}), wpm: w })); // shows at once; the poll confirms
WinkeyerSetSpeed(w).catch(() => {});
};
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Zap className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.keyer')}</div>
{st?.busy && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(-1)}>
<Minus className="size-3.5" />
</Button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-semibold tabular-nums leading-none">{wpm || '—'}</span>
<span className="text-xs text-muted-foreground">WPM</span>
</div>
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(1)}>
<Plus className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="ml-auto h-7 px-2" disabled={!on || !st?.busy}
onClick={() => WinkeyerStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{st?.port || t('station.noPort')}</span>
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
</div>
{!on && (
<Button variant="outline" size="sm" className="w-full h-7"
onClick={() => WinkeyerConnect().catch(() => {})}>
{t('station.connect')}
</Button>
)}
</div>
</div>
);
}
// The voice keyer. The messages themselves, because a card that only said
// "idle" would be a light and not a control — from here a CQ goes out without
// leaving the tab.
function VoiceKeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>({ playing: false, recording: false });
const [msgs, setMsgs] = useState<any[]>([]);
useEffect(() => {
let alive = true;
const tick = () => GetDVKStatus().then((s: any) => { if (alive) setSt(s ?? {}); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
// The recordings change when the operator records one, which is rare and
// never from this tab — read once, and again only on a status change worth
// it would be more machinery than it saves.
GetDVKMessages().then((m: any[]) => { if (alive) setMsgs(m ?? []); }).catch(() => {});
return () => { alive = false; window.clearInterval(h); };
}, []);
const recorded = msgs.filter((m) => m.has_audio);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Mic className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.voiceKeyer')}</div>
{st?.playing && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
{st?.recording && <span className="text-[10px] font-bold text-warning animate-pulse">REC</span>}
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2 text-[11px]"
disabled={!st?.playing} onClick={() => DVKStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="p-3">
{recorded.length === 0 ? (
<div className="text-[11px] text-muted-foreground">{t('station.noVoiceMsg')}</div>
) : (
<div className="flex flex-wrap gap-1.5">
{recorded.map((m) => (
<button key={m.slot} type="button"
onClick={() => DVKPlay(m.slot).catch(() => {})}
disabled={st?.playing}
title={`${m.duration_sec?.toFixed?.(1) ?? ''}s`}
className="rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40">
<span className="text-muted-foreground mr-1">F{m.slot}</span>
{m.label || `#${m.slot}`}
</button>
))}
</div>
)}
</div>
</div>
);
}
type Device = { type Device = {
id: string; type: string; name: string; host: string; id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[]; user?: string; pass?: string; channels?: number; labels: string[];
@@ -317,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
}, [poll, pollAnt, devices.length]); }, [poll, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); }; const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Whether the two keyers exist at this station. Asked ONCE, on opening the
// tab: a keyer is bought, wired and configured, not something that appears
// mid-session, and polling for the answer would be a round trip a second for
// a fact that does not change. A keyer counts as present when it is connected
// or a port is configured for it, the voice keyer when at least one message
// has actually been recorded — an empty set of slots is not a keyer.
const [keyerShown, setKeyerShown] = useState(false);
const [dvkShown, setDvkShown] = useState(false);
useEffect(() => {
let alive = true;
GetWinkeyerStatus().then((s: any) => {
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim()));
}).catch(() => {});
GetDVKMessages().then((m: any[]) => {
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
}).catch(() => {});
return () => { alive = false; };
}, []);
// Reorder so `dragged` lands just before `target`. // Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => { const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null; const src = dragId.current; dragId.current = null;
@@ -419,6 +616,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// single ~430px column — they are the same cards the FlexRadio panel shows // single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too. // full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = []; const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
// The radio first: it is the station, and everything else on this page is
// something attached to it. Then the two keyers, each only when there is
// something behind it — an operator who works neither CW nor voice keyer
// should not be given two dead cards to read past.
widgets.push({ id: 'rig', node: <RigCard t={t} /> });
if (keyerShown) widgets.push({ id: 'keyer', node: <KeyerCard t={t} /> });
if (dvkShown) widgets.push({ id: 'dvk', node: <VoiceKeyerCard t={t} />, wide: true });
if (rot.enabled) { if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> }); widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
} }
@@ -0,0 +1,102 @@
import * as React from 'react';
import { Input } from '@/components/ui/input';
// A text field that types into itself first.
//
// Preferences is one component holding two hundred pieces of state, and its
// biggest panels are eight hundred lines of form. A plain controlled input
// sends every keystroke into that state, so every character re-renders the
// whole dialog — the external-services panel, the CAT panel — and the letter
// appears after the finger has left the key.
//
// This keeps the text where it is being typed and hands it up shortly after.
// The value shown is the operator's, immediately; the parent's copy catches up
// a moment later, which is soon enough for anything that reads it — nothing in
// a settings form acts on a half-typed word.
//
// It is a drop-in for Input, on purpose: the fix is a changed import, not a
// hundred edited call sites. Which means it has to behave correctly in every
// shape those call sites take:
//
// • Blur flushes at once, so clicking Save cannot lose the last word typed,
// and so does unmounting — a panel changed mid-word still hands up what
// was there.
// • A value that comes back DIFFERENT from what was sent up is adopted, even
// while the field has focus. That is how the fields which normalise as you
// type keep working: a callsign box that upper-cases, a port box that
// drops everything but digits. They echo a corrected value, and the
// correction wins.
// • A value changed from outside while the field is idle wins too — that is
// how loading the settings, or switching profile, refills the form.
// • Types that are not text — checkbox, colour, file — pass straight
// through. There is no typing to buffer and their events are not text.
const PASSTHROUGH = new Set(['checkbox', 'radio', 'file', 'color', 'range', 'submit', 'button', 'image', 'reset']);
// Short enough that a normalising field corrects itself while the operator is
// still on the same word, long enough that a burst of typing is one render.
const DEBOUNCE_MS = 120;
export const BufferedInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ value, onChange, onBlur, onFocus, type, ...props }, ref) => {
const buffered = value !== undefined && !!onChange && !PASSTHROUGH.has(type ?? 'text');
const incoming = String(value ?? '');
const [local, setLocal] = React.useState(incoming);
const focused = React.useRef(false);
const timer = React.useRef<number | undefined>(undefined);
// What we last handed up. Anything else arriving from the parent is the
// parent's own doing — a normalisation, a reload — and it wins.
const emitted = React.useRef(incoming);
const pending = React.useRef<React.ChangeEvent<HTMLInputElement> | null>(null);
const onChangeRef = React.useRef(onChange);
React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
React.useEffect(() => {
if (!focused.current || incoming !== emitted.current) {
setLocal(incoming);
emitted.current = incoming;
}
}, [incoming]);
const flush = React.useCallback(() => {
window.clearTimeout(timer.current);
timer.current = undefined;
const e = pending.current;
pending.current = null;
if (e) {
emitted.current = e.target.value;
onChangeRef.current?.(e);
}
}, []);
// Unmounted mid-word — the panel changed, the dialog closed — still hands
// up what was typed.
React.useEffect(() => () => {
window.clearTimeout(timer.current);
if (pending.current) onChangeRef.current?.(pending.current);
}, []);
if (!buffered) {
return <Input ref={ref} type={type} value={value} onChange={onChange} onBlur={onBlur} onFocus={onFocus} {...props} />;
}
return (
<Input
ref={ref}
type={type}
value={local}
onFocus={(e) => { focused.current = true; onFocus?.(e); }}
onChange={(e) => {
const v = e.target.value;
setLocal(v);
// The element's value changes again before the timer fires, so what
// matters is copied out of it now.
pending.current = { ...e, target: { ...e.target, value: v } } as React.ChangeEvent<HTMLInputElement>;
window.clearTimeout(timer.current);
timer.current = window.setTimeout(flush, DEBOUNCE_MS);
}}
onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }}
{...props}
/>
);
},
);
BufferedInput.displayName = 'BufferedInput';
+15 -6
View File
@@ -8,14 +8,23 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal; const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close; const DialogClose = DialogPrimitive.Close;
// blur=false drops the backdrop filter and dims harder instead.
//
// A backdrop-filter over the whole window is recomputed every time anything
// above it repaints — and underneath this one sits an application that never
// stops moving: CAT polls four times a second, spots arrive, meters sweep, maps
// redraw. On a long-lived dialog with text fields in it, that shows as a delay
// between the key and the letter. Ornament is not worth a keyboard that feels
// slow, so the dialogs an operator TYPES in for minutes at a time turn it off.
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
>(({ className, ...props }, ref) => ( >(({ className, blur = true, ...props }, ref) => (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
blur ? 'bg-stone-900/40 backdrop-blur-sm' : 'bg-stone-900/60',
className, className,
)} )}
{...props} {...props}
@@ -25,10 +34,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean } React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean; overlayBlur?: boolean }
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => ( >(({ className, children, hideClose, hideOverlay, overlayBlur, ...props }, ref) => (
<DialogPortal> <DialogPortal>
{!hideOverlay && <DialogOverlay />} {!hideOverlay && <DialogOverlay blur={overlayBlur} />}
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
File diff suppressed because one or more lines are too long
+66
View File
@@ -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
View File
@@ -34,6 +34,7 @@ const PORTABLE_KEYS = [
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView', 'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots 'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.matrixDigiMode', // band matrix: which digital row it opens on ('' = DIGI, the group)
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
// One imagery choice per map — world, grid squares, FT map, satellites. // One imagery choice per map — world, grid squares, FT map, satellites.
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase', 'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.17'; export const APP_VERSION = '0.27.19';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -2360,6 +2360,7 @@ export namespace main {
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
omnirig_vfo: string; omnirig_vfo: string;
omnirig_cw_lower: boolean;
digi_as_usb: boolean; digi_as_usb: boolean;
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
@@ -2414,6 +2415,7 @@ export namespace main {
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"]; this.omnirig_vfo = source["omnirig_vfo"];
this.omnirig_cw_lower = source["omnirig_cw_lower"];
this.digi_as_usb = source["digi_as_usb"]; this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
+17
View File
@@ -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), // 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. // so a K3 can key CW through its single CAT link instead of a second COM port.
type KenwoodController interface { type KenwoodController interface {
+23 -2
View File
@@ -36,6 +36,14 @@ type OmniRig struct {
// on the main VFO) and read B — the frequency "never followed the knob", // on the main VFO) and read B — the frequency "never followed the knob",
// while it was following the other one all along. // while it was following the other one all along.
ForceVFO string 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 omnirig *ole.IDispatch
rig *ole.IDispatch rig *ole.IDispatch
@@ -76,7 +84,7 @@ type OmniRig struct {
// NewOmniRig creates a non-connected backend. Call Connect before use. // NewOmniRig creates a non-connected backend. Call Connect before use.
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file // NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
// reports, or "A"/"B" to override it — see the ForceVFO field. // 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 { if rigNum < 1 || rigNum > 2 {
rigNum = 1 rigNum = 1
} }
@@ -84,7 +92,7 @@ func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
if v != "A" && v != "B" { if v != "A" && v != "B" {
v = "" v = ""
} }
return &OmniRig{RigNum: rigNum, ForceVFO: v} return &OmniRig{RigNum: rigNum, ForceVFO: v, CWLower: cwLower}
} }
func (o *OmniRig) Name() string { return "omnirig" } func (o *OmniRig) Name() string { return "omnirig" }
@@ -602,6 +610,13 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return nil 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. // 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 // For SSB, the USB/LSB side is chosen from the rig's current frequency
// following worldwide convention (LSB below 14 MHz, USB above). // 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)) { switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW": case "CW":
// 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" bit, bitName = pmCWU, "PM_CW_U"
}
case "SSB": case "SSB":
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED // 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 // (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)
}
}
}
+22 -1
View File
@@ -310,7 +310,28 @@ func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
func (s *Server) start() error { func (s *Server) start() error {
var conn *net.UDPConn 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) group := strings.TrimSpace(s.cfg.MulticastGroup)
if group == "" { if group == "" {
return fmt.Errorf("multicast enabled but group address is empty") return fmt.Errorf("multicast enabled but group address is empty")
+52 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"embed" "embed"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -58,7 +59,17 @@ func acquireInstance(wait bool) bool {
if !wait { if !wait {
return false 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) { for time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
if acquireSingleInstance() { if acquireSingleInstance() {
@@ -68,6 +79,23 @@ func acquireInstance(wait bool) bool {
return false 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 // processStart is stamped on the very first instruction of main, before the
// single-instance guard and before anything else runs. // 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. // to free instead of bailing out. Then clear the old exe it left behind.
bootLogLaunch() bootLogLaunch()
postUpdate := hasFlag(os.Args[1:], "--post-update") 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 // 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 // regularly wins the start against the old one's teardown, and the operator
// got "OpsLog is already running" for following instructions. Same patience // 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 // window, no data folder, no log, which is indistinguishable from a
// program that died on its first instruction. // program that died on its first instruction.
bootLog("another instance already holds the single-instance mutex - exiting") bootLog("another instance already holds the single-instance mutex - exiting")
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.") 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 return
} }
bootLog("single-instance mutex acquired") bootLog("single-instance mutex acquired")
+63
View File
@@ -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)
}
}
+48
View File
@@ -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)
}
}
}
}
+7
View File
@@ -2,8 +2,15 @@
package main package main
import "time"
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named // acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
// mutex), and during Wails' binding generation (the `bindings` tag) — that step // mutex), and during Wails' binding generation (the `bindings` tag) — that step
// runs this binary, and a real OpsLog already running would otherwise make it // runs this binary, and a real OpsLog already running would otherwise make it
// exit before Wails could reflect the bindings. // exit before Wails could reflect the bindings.
func acquireSingleInstance() bool { return true } 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 }
+33
View File
@@ -10,6 +10,7 @@ package main
import ( import (
"errors" "errors"
"time"
"unsafe" "unsafe"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
@@ -68,3 +69,35 @@ func focusExistingWindow() {
showWindow.Call(hwnd, swRestore) showWindow.Call(hwnd, swRestore)
setForeground.Call(hwnd) 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
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.17" appVersion = "0.27.19"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+12 -4
View File
@@ -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 // 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. // 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 // The wait it existed for still has to happen — it just happens on the other
// instance patient with the single-instance mutex (see acquireInstance), so it // side now. The new instance is told OUR pid and waits for this process to
// can start while this one is still shutting down and simply wait its turn. // end before taking the single-instance mutex.
cmd := exec.Command(exe, "--post-update") //
// 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.Dir = dir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
+35
View File
@@ -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 give the group. A program sending to one address only needs unicast — leave it
unticked. 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 > **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 > 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: > 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 | | **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 | | **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 | | **PstRotator frequency** | `<PST><FREQUENCY>` | frequency change |
| **N1MM RadioInfo** | N1MM's RadioInfo XML | frequency or mode change | | **N1MM RadioInfo** | N1MM's RadioInfo XML | frequency or mode change |
| **Custom message** | whatever you write | a trigger you pick | | **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? ### Which one for another logger?
If a logger says it accepts "WSJT-X UDP", it wants the **WSJT-X logged QSO** If a logger says it accepts "WSJT-X UDP", it wants the **WSJT-X logged QSO**
+185
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@ Developed by **F4BPO**.
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] | | 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]] | | 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]] | | Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
| Digital modes | [[Digital Modes and GridTracker]] · [[Connections]] |
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] | | Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] | | Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
| Confirmations | [[Awards]] · [[QSL Management]] · [[QSL Card Designer]] | | Confirmations | [[Awards]] · [[QSL Management]] · [[QSL Card Designer]] |
+63
View File
@@ -3,6 +3,69 @@
**Tools → QSL Manager.** Upload and download confirmations for the online QSL **Tools → QSL Manager.** Upload and download confirmations for the online QSL
services, and manage paper 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 ## Services
| Service | Upload | Download confirmations | | Service | Upload | Download confirmations |
+17 -1
View File
@@ -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 as its own git repository (`<repo>.wiki.git`), so publishing means pushing these
`.md` files into it. `.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 The release task (`.vscode/release.ps1`, VS Code → *Tasks: Run Task* → *Release
OpsLog*) **publishes the wiki automatically** at the end of every release: it OpsLog*) **publishes the wiki automatically** at the end of every release: it
+1
View File
@@ -28,6 +28,7 @@
- [[Net Control]] - [[Net Control]]
- [[Multi-Operator Live Status]] - [[Multi-Operator Live Status]]
- [[Connections]] - [[Connections]]
- [[Digital Modes and GridTracker]]
**QSL & Awards** **QSL & Awards**
- [[Awards]] - [[Awards]]