Compare commits

..
66 Commits
Author SHA1 Message Date
rouggy aa995c250c chore: release v0.22.6 2026-07-31 22:04:09 +02:00
rouggy 72fee7a090 fix: a quiet cluster was treated as a dead one and reconnected
Reported by an operator whose node sends little: nothing for two minutes, then a
reconnect — losing the login, the filters, and any spot that arrived during the
gap.

The read had a 120 s deadline and ANY error ended the session, a timeout
included. That reads a silence as a verdict on the link. It is not: a cluster on
a dead band legitimately says nothing for minutes.

Only a real error ends a session now. A genuinely dead peer is still caught, and
by the mechanism meant for it — TCP keepalive probes an idle connection and its
failure surfaces as an error on Read, not as a timeout. The dialer sets it
explicitly rather than inheriting a default, since it is now what the design
depends on.

One trap the advice this came from did not mention: ReadString returns the bytes
it HAD read along with the timeout. Discarding them would lose the first half of
any spot line straddling a deadline, so the partial is carried over.

Tested: a listener that goes silent past the deadline and then sends a spot —
the spot arrives on the ORIGINAL connection, and the listener never accepts a
second one. The tick is a var so that test runs in four seconds instead of
sixty; a slow test is a test that gets skipped.
2026-07-31 21:48:05 +02:00
rouggy ed67ed7fe3 fix: replaying a QSO used the voice keyer's level and overdrove the rig
A panadapter shot settles it: answering an S5 station, the replayed recording
goes out several times wider and taller than the station being answered — in WAV
as well as MP3, so this is not the encoder.

One setting served two sources that have nothing in common. A voice-keyer
message is a microphone at speaking distance and legitimately wants 195%; a QSO
recording is a receiver's line output, which is far hotter. The same 195% put it
into the transmitter flat out, ALC pinned, noise and voice alike.

The QSO playback now has its own level, defaulting to 100%. The voice keyer
keeps the setting it had, so nobody's F-key messages change.
2026-07-31 21:32:55 +02:00
rouggy fddb3c45c4 fix: MP3 recordings carried a hiss the WAV of the same audio did not
The operator split it in one test: WAV is fine, MP3 is not. The MP3 path is the
only one that resamples — 16 kHz up to 32 kHz, because the encoder emits broken
frames at MPEG-2 rates — and it did so by averaging neighbouring samples.

That is an upsampler in name only. Every component at f is mirrored to
16 kHz − f, and the test measures the mirror just 7 dB below the wanted signal:
a second copy of the whole spectrum. On speech it adds brightness; on receiver
noise, which is broadband, it lays a second noise floor over the top of the
band. Hence "a hiss that is not the QRM, louder than the voice" — and hence its
absence from the WAV, which resamples nothing.

Zero-stuffing plus a 63-tap windowed sinc at the old Nyquist puts the image
56.8 dB down instead of 7, at a cost measured in microseconds against the MP3
encode that follows. Two tests pin it: the image suppression, and that the level
is unchanged — a fix that quietly halved every recording would be a second
surprise on top of the one being repaired.
2026-07-31 21:27:57 +02:00
rouggy da1f3eb2bd fix: say which devices a recording is captured from
A station with two radios has two sets of audio endpoints, and the recorder will
happily capture the one nobody is listening to — a Flex DAX channel while the
operator works a Yaesu over USB. The file is then full of hiss with no relation
to what they hear, unaffected by the rig's AGC, and nothing anywhere said which
receiver it came from.

The device names are now logged, resolved to their friendly names, once when
capture starts.
2026-07-31 21:20:35 +02:00
rouggy 5e0bb6e68e fix: recordings buried under hiss — the cheap resampler was folding it in
Reported precisely: on the air the voice is well clear of the noise; in the
recording the noise is louder than the voice. Same audio, so the fault is in how
it is captured, not in what is captured.

AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY was set alongside AUTOCONVERTPCM. The
name reads like a sensible default and is not: it selects the CHEAP sample-rate
converter, for cases where quality does not matter. Taking a rig's 48 kHz stream
down to our 16 kHz, it folds everything above 8 kHz back into the audio band —
and receiver hiss is mostly high frequencies. The voice keeps its level, the
noise arrives twice.

Dropped, so Windows uses its normal converter, which filters before decimating.
This affects every capture and render path in the app, the voice keyer included.
2026-07-31 21:12:59 +02:00
rouggy 2b61f43780 fix: a level set by the slider was lost when a recording started
The monitor followed the slider, but two takes made at 10% and 300% came out
identical: starting a recording re-reads the STORED settings and calls SetGains
with them, so the live value was overwritten the moment a take began.

The slider now persists as it applies. Saving Settings still works as before —
this only means the stored value is already right when a take starts, which is
what every path downstream reads.
2026-07-31 21:04:20 +02:00
rouggy 2b66e5bc7f fix: the audio level sliders did nothing until Settings were saved
Reported again after the monitor was wired up: 300% still sounded like 10%. The
monitor fix was right but could not show, because the sliders only changed the
settings panel's own state — the value reached the audio engine when the
operator clicked Save.

A level is set by ear, by dragging. It now applies on every move, to the monitor
and to the recorder mix alike; saving still persists it. Both halves were needed
and neither works alone.
2026-07-31 20:58:40 +02:00
rouggy 84b06ed47b fix: padlocks unclickable, From-radio level inert, callsign field too narrow
Padlocks. LockBtn was declared INSIDE the App component, so every render gave
React a new component type and the button was unmounted and rebuilt — about four
times a second while the CAT polls. It flickered under the pointer and swallowed
clicks, because the node being clicked no longer existed when the click landed.
The padlock now lives at module level and is passed its state.

From-radio level. It reached the QSO recorder and nothing else, so an operator
listening through OpsLog heard no difference between 10% and 150% — the control
was not weak, it was disconnected. It now scales the monitor too, applied on the
captured chunk so the network-fed path keeps its own levels, and a running
monitor picks up a change immediately: making someone restart the monitor to
hear the slider is how a working control gets reported as broken.

Callsign field widened to w-56, the room coming from the RST pair which never
needs more than five characters. It is the one field always typed into, it
carries the REC badges, and a long portable call has to stay readable.
2026-07-31 20:33:39 +02:00
rouggy 39bd1ff414 feat: the play button stops the transmission while it is going out
A second press restarted the message from the beginning. What the operator wants
there is to cut it short — enough heard, or pressed by mistake with the
transmitter keyed — and then to be able to send it again at once.

It now shows a stop square while playing and releases the PTT through the same
path the natural end uses, so nothing is left keyed. Together with the device
handover fixed a moment ago, sending again immediately afterwards works.
2026-07-31 20:23:03 +02:00
rouggy 6d54100bc9 fix: a new playback raced the previous one for the audio device
Reported precisely: press play again and it stops the current message, but you
then have to wait the whole length of the message before it will play — otherwise
the radio is keyed and the PTT released at once.

Play only SIGNALLED the previous playback to stop and started a new one straight
away. The old goroutine still held the WASAPI render client for a moment, so the
new client could not start; it returned immediately and the PTT tail was all that
was left. The log now shows both halves of that: a play line, then a release
120 ms later.

Stopping now waits for the goroutine to have returned — the point of stopping
before starting again — bounded at 1.5 s so a wedged device cannot freeze the
operator's click, and saying so in the log if it ever comes to that.
2026-07-31 19:05:09 +02:00
rouggy 4fe5811fd2 fix: a playback that never starts no longer fails silently
From an operator's log, playing a recording on the air repeatedly:

  18:52:40.148 play → 18:52:48.420 PTT released   8.2 s, the whole take
  18:52:57.759 play → 18:52:57.879 PTT released   0.12 s, nothing came out

Those 120 ms are the PTT release tail alone: the render device refused to start
and playPCM returned an error that was thrown away. The call reported success
every time, which is what "it plays once and then never again" looks like from
the outside — and why the search went to the recorder, which was innocent.

The error is now logged with the device name.
2026-07-31 18:55:48 +02:00
rouggy d42e6669e1 fix: name the field when playback has no output device
An operator emptied "Recording mic" and got "no audio output to the radio is
configured", and read it as a restriction: that a recording of the other station
alone cannot be played back. No such rule exists — the mic is an INPUT, one more
source to record, while this message is about the OUTPUT that feeds the
transmitter.

The message now names the setting exactly as the panel labels it, and the log
line carries the other two device values so the next report says which field was
actually empty.
2026-07-31 18:50:48 +02:00
rouggy c14353a399 test: playing a recording must not consume it
Reported: the recording plays once and never again, "as if it threw the sound
away after playing it". PeekQSO is what the playback reads and it is meant to
copy — TakeQSO is the one that clears — so this pins that, and that the copy is
independent of the recorder's own buffer.

It passes, which is the useful part: the take survives, so the one-shot failure
is downstream of it and the search moves to the file and the player.
2026-07-31 18:45:49 +02:00
rouggy 734931cf54 chore: release v0.22.5 2026-07-31 17:18:18 +02:00
rouggy cad4ec4b72 feat: filter cluster spots on new POTA and new county
The grid already badges them; only the filter was missing.

They are not entity statuses — a new park or county is another dimension of the
same spot — so the filter key is now a superset of the status, and matching is
an OR across all of them, as it already was for a previously-worked callsign.
The chips carry the colours their badges use in the grid: a filter that does not
look like what it selects has to be learned twice.
2026-07-31 17:04:01 +02:00
rouggy 5623b860c2 chore: release v0.22.4 2026-07-31 16:56:41 +02:00
rouggy 3e22c4d1a3 feat: filter award references by mode class
All / CW / Phone / Digital, stacked on top of the worked/confirmed filter. The
question an operator actually has — which DXCC entities have I worked on CW but
not confirmed on CW — needs both at once; answering only one of them is what
sends people to a spreadsheet.

References carried bands but not modes, so the award computation now aggregates
mode CLASSES per reference, worked and confirmed separately. Classes, not ADIF
modes: nobody chasing CW cares whether the digital side was FT8 or RTTY, and a
list of twenty mode names would not answer the question. An unrecognised mode
counts as data rather than as nothing, so a new digital mode does not vanish
from the filter the day it appears.

Two rules the filter needs to be honest:

  - Not-confirmed means not confirmed ON THIS MODE. An entity worked on CW and
    confirmed on SSB is still a CW entity to chase, and that is exactly what the
    filter is for.
  - "Not worked" plus a mode is a contradiction — an unworked reference has no
    mode — so the mode filter stands aside there instead of emptying the list.
2026-07-31 16:09:11 +02:00
rouggy d90f953df4 fix: QRZ.com download marked unconfirmed QSOs as confirmed
The operator's own fetch settles it. One record, both fields:

  <app_qrzlog_status:1>N  <qrzcom_qso_download_status:1>Y

QRZ says the QSO is NOT confirmed and sets the download field anyway — it marks
what was handed back, not what was confirmed. OpsLog read it as a confirmation,
so eighteen QSOs came back "UPDATED" and green, none of them confirmed on QRZ.
The site's own logbook page shows them without a star.

Only app_qrzlog_status = C counts now. The test that asserted otherwise is
corrected and carries the real record.

And the download now takes back what it wrongly gave: when QRZ explicitly says
not-confirmed and our log says confirmed, the flag is cleared and reported on
its own line. These QSOs count towards award slots, so leaving them green until
someone noticed was not an option — and nothing else in the app would ever have
undone them. Only on an explicit no: a record with no app_qrzlog_status is QRZ
saying nothing, and silence is not a retraction.
2026-07-31 14:40:50 +02:00
rouggy e4217da010 fix: right-click menu cut off at the bottom of the window
Closes #9.

It was clamped against a GUESSED height — 230 px with the upload submenu, 110
without. The menu is far taller than that with a selection, so right-clicking on
a row near the bottom hid half the entries, delete among them.

It is measured now: placed above the cursor when it does not fit below, pinned
to the top with its own scrollbar when it fits neither way. Its height depends
on which actions the caller passes, so no constant could have stayed right.
2026-07-31 12:25:37 +02:00
rouggy c5ac945de0 fix: digital-mode grouping ignored by the spots already listed
The backend honours the setting: it normalises the log side and the spot's own
mode through the same grouper, so an FT4 spot on a band worked in FT8 reads as
worked. That was not the fault.

Spot statuses are cached per call+band+mode and the cache never expired. Ticking
the box in Settings therefore changed nothing on screen — every spot already
listed kept the answer to the old question, and only new arrivals were judged by
the new rule. The cache is now dropped when settings are saved.

Also: writeUiPref swallowed a failed database write. The interface kept working
from localStorage while the BACKEND — which reads some of these keys, this one
among them — still saw the old value. The two disagreeing in silence is exactly
the shape of this bug, so the failure is now logged.
2026-07-31 12:20:07 +02:00
rouggy eb40991482 fix: the power headings sat one column left of their boxes
The rule between the band-driven columns and the mode-driven ones was an extra
empty cell added to the body rows only. Seven cells per row against six in the
header slid every heading across: Phone stood over the TX antenna, CW over
Phone.

The rule is now drawn on the first power cell itself, in the header and the body
alike, so the two cannot drift apart again.
2026-07-31 12:00:21 +02:00
rouggy 781cf636ea fix: one table per band instead of two
Antennas and power were two tables listing the same bands, one under the other,
so configuring a band meant matching rows by eye between them.

They are now one row per band. That the two settings are stored separately and
applied on different triggers — antennas on band change, power on band or mode
change — is a backend detail, and no reason to split what is a single decision
per band for the person configuring it. A rule in the header keeps the two
groups readable.
2026-07-31 11:52:03 +02:00
rouggy 62b6fe0a3a feat: FlexRadio TX power per band and mode class
Settings → FlexRadio, beside the per-band antennas and applied the same way:
when the band or the mode changes. 1.5 kW that is fine on SSB has no business
going into an FT8 signal on the same band.

Three classes rather than one row per mode, because what decides the power is
duty cycle and what the amplifier will take of it, not the mode's name — FT8 and
RTTY punish an amplifier the same way, SSB and AM do not. The classification is
pinned by a test: a digital mode sorted as phone would deliver exactly the
1.5 kW this exists to prevent.

An empty box means "leave the power alone", never zero watts, and an unknown
mode changes nothing — setting a transmit power from a name we do not recognise
is not a good guess.

Unlike the antennas, a locked band does not skip it: the lock means "do not let
the rig drag my log entry around", not "let the amplifier keep whatever the last
mode left".
2026-07-31 11:30:46 +02:00
rouggy bd2a8524dc fix: CW decoder loses the first characters after a speed change, and splits callsigns
Both faults reported on the air, both reproduced in synthetic signals first —
the existing tests all passed because each starts a fresh decoder and sends
textbook spacing, which is neither of the cases that hurt.

Speed change. A 32 wpm station followed by a 14 wpm reply decoded as
"TTT TTTT TTTTT TT…" for most of the over. Not the element classifier: the
CHARACTER-gap threshold is 2.2 dits, so at the stale fast estimate it stood at
81 ms while the newcomer's element gaps were 86 ms. Every single element was
flushed as its own character, and a lone element with no contrast reads as a
dah. A silence long enough to end an over now drops the estimate back to the
seed, which is exactly the state a freshly started decoder is in — and that case
was always fine. The threshold scales with the current estimate (12 dits, floor
600 ms) because a fixed one cannot serve both 10 and 40 wpm.

Callsign split. The word boundary sits at 4.6 dits, the geometric mean of a
3-dit letter gap and a 7-dit word gap. It assumes textbook spacing; a fist
leaving 5 dits between letters had every letter turned into a word, so a
callsign arrived as "O Y 1 C T". The boundary now also follows the letter gaps
this operator actually sends, whichever is larger. A wide sender's words may run
together — a far smaller price than a callsign in pieces.
2026-07-31 11:15:02 +02:00
rouggy 4afd7dda90 fix: keep the audio captured while waiting for a silent source
The operator asked the right question: why wait 1.5 s at all, when the mode is
known from CAT?

Because the wait is not the problem — what happened during it was. The drift
guard, meant for two sound cards running on their own clocks, saw a starved
source as a one-second skew and trimmed the radio audio to match. So the opening
of every CW recording was thrown away although it had been captured.

The guard now applies only while BOTH sources are actually delivering. Waiting
then costs nothing: everything buffered is written whole the moment the silent
source is written off, which a test now pins with three seconds of audio.

Using the CAT mode to skip the wait would have been a second mechanism for a
case this one already covers — and it would not cover the others: a device
switched off, unplugged, or held by another program.
2026-07-31 00:31:02 +02:00
rouggy ed930667a1 fix: stop warning about a silent mic when the radio is streaming
The log read:

  recorder: the second audio source is silent — recording the radio alone
  recorder: no audio at all from "DAX Mic" after 3 s — nothing is streaming

Both true, and together they read as a fault while the recording was going
perfectly well — it was saved seconds later. On CW the mic channel legitimately
delivers nothing; the mixer already said what it was doing about it.

The watchdog now mentions the mic only when the radio is silent too, which is
the case where the recording really is empty.
2026-07-31 00:22:44 +02:00
rouggy 311479c52f fix: write off an audio source that never delivers anything
The operator said it from the start: in CW there is no sidetone, only reception.
My previous fix did not cover it — it wrote off a source that STOPPED, but a
source that never starts has no last-delivery time, so it stayed forever "not
yet dry" and held the mixer at min(A,B)=0. Three empty recordings while the
radio audio streamed perfectly.

A source with no delivery is now measured from when capture started. Tested both
ways: nothing is written off in the first moments, and a source that has never
spoken is written off once the grace period has passed.
2026-07-31 00:15:11 +02:00
rouggy b25efabab8 fix: check the configured capture device still exists before recording
Third empty recording, same station, and the sequence was as plain as it gets:
type the call, click record, log the QSO. The device opened and never streamed.

Endpoint ids are what gets stored, and a DAX channel that is reconfigured,
disabled or recreated comes back with a DIFFERENT id. The stale one opens
without complaint on some drivers and simply never delivers — indistinguishable
from a quiet band until the file turns out to be empty.

The recorder now checks the id against the current endpoint list when it starts,
which takes milliseconds, and says outright that the configured input is gone —
on screen, not only in the log. If it IS still there, the answer is equally
useful: the device exists and the fault is upstream, in whatever should be
feeding it.
2026-07-31 00:12:51 +02:00
rouggy a93f52d2b9 fix: name the audio device in diagnostics instead of quoting its GUID
The watchdog fired exactly as intended and said:

  no audio at all from "{0.0.1.00000000}.{6a27abfd-f277-496b-b6f1-f92ec284c636}"

which tells the operator nothing they can act on. "DAX RX 1 (FlexRadio DAX)"
points straight at the DAX panel.

Endpoint ids are what gets CONFIGURED and stored, so they are what the recorder
holds; the friendly name is resolved only when something has gone wrong. When
the endpoint cannot be found at all the id is still shown — a device that has
disappeared explains an empty recording too.
2026-07-31 00:11:29 +02:00
rouggy c9f7279a01 fix: log when an in-progress recording is discarded
"No recording was running at log time" says the take had already ended, but not
who ended it. The only path that does is the callsign being cleared — which also
happens when a clicked spot replaces it — and that path was silent.

Now it names itself, so the log shows the moment the recording was lost instead
of only its absence at the end.
2026-07-31 00:01:08 +02:00
rouggy 2560fced87 docs: match the French changelog to the English one 2026-07-30 23:57:17 +02:00
rouggy 1718bf6f33 fix: say when a capture device is streaming nothing
Second empty recording from the same station, and this time NEITHER source had
delivered a sample — so the fix for a silent second source could not apply, and
the log still said only "recording was empty" at the end of the QSO.

Two blind spots, both now closed:

  - captureStream's error was discarded. A device that cannot be opened — renamed,
    unplugged, held by another program — looked exactly like one that is merely
    quiet.
  - A WASAPI device can open cleanly and then produce nothing at all. A DAX
    channel with no stream behind it does precisely that, and it is
    indistinguishable from silence until the file turns out to be empty.

A watchdog now reports both, three seconds in, and the first goes to the OPERATOR
as a toast rather than only to the log: they are mid-QSO, and by the time they
find out at save time the audio is gone for good.
2026-07-30 23:46:27 +02:00
rouggy c4c5db3921 fix: a silent audio source no longer empties the whole recording
From an operator's log: a Flex with From Radio on DAX RX and Recording mic on
DAX Mic, a full CW QSO recorded, and at log time "snapshot failed: recording
was empty".

The mixer took min(len(A), len(B)) samples. In CW the mic path does not run, so
DAX Mic delivered nothing, min stayed 0, and the drift guard then threw the
radio audio away a second at a time. Two devices configured meant the recording
depended on the quieter one being alive.

A source that has delivered nothing for 1.5 s is now treated as absent and the
other is recorded alone, with one line in the log saying so — half a recording
is worth having, silence is not. Both are mixed again the moment the missing one
comes back.

Neither is written off before it has EVER spoken: at startup both timestamps are
zero, and declaring one dead then would clip the opening of every recording.
That case is tested too.
2026-07-30 23:39:54 +02:00
rouggy a1ceea978b fix: no play-on-air button on CW
In CW the transmitter builds its tone from the keyer and ignores the DAX TX
audio path, so the button keyed the rig and sent silence. Confirmed on a Flex:
our side reported the playback as done while nothing went out.

Only the button goes. The row stays, or a CW operator who stopped their
recording would be left with no way to resume it — and the recording itself is
still worth keeping.
2026-07-30 23:34:23 +02:00
rouggy f4956a63bb fix: say why playing a recording on the air did nothing
"I click play and nothing happens" is what an operator sees when a function
returns an error into a promise the interface swallows. Every refusal in the
playback path is now logged as well as returned, and each names its own cause —
audio not initialised, the take still running, the take empty, the device
rejecting the file.

One of those was not even checked: playing back needs an OUTPUT device INTO the
radio, which is a different setting from the recorder's input and is very likely
unset on a station that has only ever recorded. It now refuses with that
sentence instead of failing somewhere inside the player.
2026-07-30 23:27:30 +02:00
rouggy 5a4ad800b3 test: pin which modes produce a recording
CW has moved in and out of this list twice — excluded for the missing sidetone,
restored because the other station is captured regardless. A test says which,
so the next reading of that comment does not undo the decision.
2026-07-30 23:24:58 +02:00
rouggy e152ef0ee0 feat: record CW QSOs again
CW was excluded because SmartSDR does not route the operator's own sidetone
through DAX, so the recording sounds one-sided. The audio path is open all the
same and the other station IS captured — which is the half worth keeping.

The frontend had ONE list serving two purposes: which modes may be recorded, and
which modes the voice keyer may transmit on. Adding CW to it would have let the
DVK key the rig with speech on a CW frequency. They are now two lists: PHONE_MODES
for the keyer, RECORDABLE_MODES (phone + CW) for the recorder.

Digital modes stay out of both: their audio is a modem tone nobody will replay.
2026-07-30 23:13:21 +02:00
rouggy b6465ddc9d docs: trim the Flex split entry to the change itself 2026-07-30 23:07:52 +02:00
rouggy 961357474d feat: Flex stays on the RX slice in split
SmartSDR moves its active flag onto the TX slice as soon as the transmit
frequency is touched, and OpsLog followed it. But the operator is listening to
the DX on the other slice: the S-meter, audio level, filter and DSP they are
adjusting all belong there, so following the transmitter handed them the
controls of a receiver they were not using.

In split the receive slice now wins. Simplex is unchanged, and two same-band
slices in different classes (SSB alongside FT8) are still not a split.

A slice clicked IN OPSLOG is remembered and overrides both the split rule and
the radio's focus — that is the operator speaking. Activating a slice on the
radio or in SmartSDR deliberately does not move OpsLog. The pin is dropped when
that slice stops being in use, so a closed receiver cannot strand the display.

The panel now highlights the slice OpsLog is working with rather than the radio's
focused one; reporting the radio's flag would light up one slice while every
control acted on another.
2026-07-30 23:03:29 +02:00
rouggy 702ca4f0c9 docs: one line per changelog entry
The 0.22.3 block had grown into explanations: why a fault happened, how it was
found, what it does not cover. That belongs in the commits, where it already is.

An operator opens this list to learn what changed for them. Sixteen entries now
say it in a line each.
2026-07-30 23:02:16 +02:00
rouggy 93b4964b3b chore: release v0.22.3 2026-07-30 22:33:01 +02:00
rouggy 14c57035e1 docs: changelog entry for the DARC DOK Award (DLD) 2026-07-30 22:32:53 +02:00
rouggy 7a7fa62af7 feat: choose how dates are displayed (Standard / French / US)
Settings → General, beside the language. An operator reading 2026-07-30 as the
7th of the 30th month is reading their own log wrongly, and that is a display
problem.

Storage stays ISO/ADIF, deliberately and permanently: it is what ADIF
specifies, it sorts correctly as text, and a stored format that followed a UI
preference would make the log unreadable the day the preference changed. Exports
and uploads are untouched.

Two details that decide whether it actually works:

  - The columns are rebuilt when the format changes. The formatters are captured
    inside AG-Grid's column definitions, so nothing else would notice and the
    table would keep the old format until something forced a rebuild.
  - main.tsx re-reads the choice after the portable prefs are pulled from the
    database. The module reads localStorage at import time, which is BEFORE that
    sync — so a copied data/ folder would have shown ISO until the next launch.

UTC is not part of the choice and never will be: a logbook is kept in UTC, and
showing local time would make the display disagree with the QSO's own record
twice a year.
2026-07-30 22:22:33 +02:00
rouggy 6ee4c430c7 feat: colour the QSL and upload status letters
Y green, N red, R blue — colour only, no pill or badge. These columns sit among
callsigns and dates, and a row of coloured boxes would shout louder than the
callsign it belongs to.

Twelve columns: paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com both ways, and
OpsLog's own card and recording flags. All four views the operator named —
recent QSOs, worked before, NET Control, QSL manager — turn out to share this
one grid, so they are all covered by construction rather than by four edits that
could drift.

Semantic tokens rather than fixed colours, so the four themes follow and the
contrast stays right on the light ones. ADIF's I (ignore) is deliberately left
in the default ink: it is neither good news nor bad, and colouring it would put
it in one camp or the other.
2026-07-30 22:12:23 +02:00
rouggy 0df6370c59 docs: the author's wording for the remote-delete hint
Says what the switch does, then names the one limit that changes the decision.
Drops my "no undo" line: deleting a QSO is already understood to be permanent,
and repeating it reads as a warning about the checkbox rather than the QSO.
2026-07-30 22:04:15 +02:00
rouggy fa94657619 docs: cut the remote-delete hint to what must be known before ticking it
Four sentences of API behaviour under a checkbox. Two facts actually change the
operator's decision: it cannot be undone, and QRZ.com cannot reach QSOs uploaded
before OpsLog started keeping their record number.

The rest — how Club Log matches, that the local delete happens regardless, that
the log records each answer — is true, and belongs in the log and the code where
it already is. Documentation in a checkbox hint is documentation nobody reads.
2026-07-30 22:03:02 +02:00
rouggy f5b74856c6 fix: the remote-delete switch sits above the service tabs
It applies to QRZ.com and Club Log both, but it was rendered after the tab
strip — so it read as a QRZ setting when the QRZ tab was open, appeared to
repeat itself on the Club Log tab, and was invisible to anyone opening a third
one.

Above the tabs it belongs to the panel, which is what it actually is.
2026-07-30 22:01:45 +02:00
rouggy d42fdab85c feat: withdraw a deleted QSO from QRZ.com and Club Log
Opt-in, off by default: neither service can undo it, and a local delete is not
always a statement about the world — a duplicate cleared out of a contest log
was never meant to change the DX's log.

The two APIs are not alike, and the difference decides what is possible:

  - Club Log deletes by MATCH (callsign, exact timestamp, band number), so it
    works for every QSO in the log, past or future. The band table is its own
    list and is pinned by a test: a band mapped to the wrong number does not
    fail, it points the delete at a different QSO.
  - QRZ.com deletes ONLY by logid. So markExtUploaded now stores the LOGID it
    already received on every upload — it was being logged and thrown away. A
    QSO uploaded before this cannot be withdrawn from here at all, and the log
    says so by name rather than leaving the operator to assume it worked.

Both run BEFORE the local delete, because both need the QSO's own fields to
find their copy and there is nothing left to ask with once the row is gone.
Neither can block it: the operator asked for the QSO to go, and a refusing
website is not a reason to keep it.
2026-07-30 21:57:40 +02:00
rouggy 83a8708d60 fix: remember the award sort, and the grey line, across restarts
The sort choice is now written through writeUiPref and both keys are registered
as portable, so they travel with a copied data/ folder like every other UI
preference.

Registering them is the part that matters: writeUiPref stores to the DB, but
only keys on the portable list are read BACK at startup. A key written and never
restored looks exactly like one that was never saved — which is what would have
happened here.

The grey-line toggle had the same gap. It called writeUiPref from the day it was
written but was never added to the list, so it came back off on the next launch.
Found while adding the award keys beside it.
2026-07-30 21:49:25 +02:00
rouggy c0671bfe2e feat: sort award references by reference or description
Reference stays the default: that is how award lists are published and how a
paper list is read. Description answers the other question an operator actually
has — "have I worked anything in Alicante" — which reference order scatters
across eight thousand rows.

Sorted where the filtered list is built, so the grid and list views inherit it
rather than each growing its own copy that could disagree.

localeCompare rather than a byte comparison, because these lists are Spanish,
French and Italian place names: "Ávila" belongs beside "Avilés", not after
"Zaragoza". References compare numerically for the same reason in reverse —
08019 must not sort between 0801 and 081.
2026-07-30 21:47:05 +02:00
rouggy ec0cb95de9 style: the play button is green
It was the same red as the recording indicator beside it, so at that size the
two read as one control. Green is also what the theme already uses for a
go-ahead action (the amplifier ON buttons), and it is a semantic token, so it
follows all four themes rather than pinning a colour.
2026-07-30 21:43:41 +02:00
rouggy 9d691343cf fix: say in the log why a recording was not saved
Two silent returns in the save path: no take was running at log time, or the
mode carries no audio worth keeping. Both simply produced no file.

An operator reporting "the sound is not in the folder" is describing an absence,
which is the same absence in either case — but they are different problems with
different fixes, and neither leaves anything behind to inspect. Now each says
which one happened, and for which callsign.

The success line already existed ("qso-rec: saved <path>"), so between the three
the log now accounts for every logged QSO.
2026-07-30 21:41:25 +02:00
rouggy c6088bb2de fix: the stop button overlapped the recording counter
Two absolutely-positioned boxes at guessed offsets: right-14 and right-2. They
collided as soon as the counter passed a minute and the text grew — a layout
that only looked right for the first 60 seconds, and did not even manage that at
this font size.

One flex row with a gap, so spacing is a consequence of the content rather than
an arithmetic guess about it. The callsign field also gains right padding while
the badges are shown, so a long call runs under them instead of being typed over.
2026-07-30 21:39:50 +02:00
rouggy 069728ac68 feat: stop a QSO recording and play it back on the air
The situation, in the operator's words: working VP2MAA, recording, and he asks
to hear his own signal. So: stop, play, and the recording is still saved with
the QSO.

Stopping FREEZES the take rather than ending it — nothing more is captured,
nothing is discarded, and logging writes the file exactly as before. Playback
reuses the voice keyer's path: same output device, same PTT keying and release
with its generation guard, so a replay cannot be cut short by a stale unkey.

Playing is refused while still recording. The transmitted audio would feed back
into the same take on any rig monitoring its own transmission, and stopping is
one click the operator has already made — it is their statement that the take is
finished, not a hoop.

The elapsed clock freezes with the recording and resumes from where it stopped,
because it now shows the LENGTH OF THE FILE rather than the time since the QSO
began. Only a fresh take returns it to zero.
2026-07-30 21:36:42 +02:00
rouggy f221c0ee1e feat: record a contact by hand when automatic recording is off
A red dot in the slot the counter will occupy. Click it and the counter starts,
the dot disappears, and logging the QSO writes the file exactly as an automatic
recording would — the save path already keyed off an active take, not off the
setting.

With automatic recording disabled the capture engine is not idle, it is not
running at all, so a manual take starts it. Two consequences handled here:

  - It is released again when the take ends — logged, cancelled, or dropped for
    being a digital mode. An operator who records one contact does not expect
    their microphone held open for the rest of the session. The release is a
    deferred call at the top of the save path so every exit goes through it,
    rather than one that has to be remembered at each return.
  - There is no pre-roll. An automatic recording opens with the seconds BEFORE
    the callsign was typed; a manual one can only begin now. The button's
    tooltip says so, because otherwise the difference between two files is a
    mystery to the person holding them.

The button only appears where it can work: devices configured, automatic
recording off, and a mode whose audio is worth keeping. That state is asked of
the backend rather than inferred from a preference in the interface, so the two
cannot drift apart, and it is re-read when settings are saved.
2026-07-30 21:24:51 +02:00
rouggy 013492adb0 fix: name CAT backends by how the radio is reached
The list read "Yaesu (native CAT)", "Kenwood (native)", "FlexRadio / SmartSDR
(native)". Native says how OpsLog is built, which is our business; the operator
opening that menu is answering a different question — how is my radio plugged
in.

So: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB),
Icom (CI-V USB), Icom (CI-V network), TCI. Shorter, and each entry now tells
someone with a cable in their hand which line to pick.
2026-07-30 21:20:08 +02:00
rouggy 3a87b58ba3 feat: Kenwood CAT over a network serial bridge
The same ASCII stream over a socket instead of a wire: ser2net, an
Ethernet-serial adapter, a Raspberry Pi at the radio. One TCP transport
presented as a serial port, so the backend keeps a single code path.

Explicitly NOT the radio's own RJ45. A TS-890 or TS-990 speaks Kenwood's
KNS/ARCP there — session, authentication, a different protocol — and that needs
one of those radios in hand to write honestly. The setting's help text says so,
because an operator who plugs in their TS-890's Ethernet port and types its
address deserves to learn that from the interface rather than from silence.

Two details that decide whether this is usable or maddening:

  - A read deadline expiring is reported as "no data yet", not as an error. It
    is exactly what a serial read timeout means to the caller; as an error it
    would make ask() abandon a rig that is merely thinking.
  - The log line names what was connected to. "connected on  @ 0 baud" after a
    network connect sends someone hunting a serial fault that does not exist.

The host wins over the COM port when both are filled: it is the more deliberate
setting, and silently preferring the wire leaves someone staring at an address
they typed and a radio that never answers.
2026-07-30 21:13:19 +02:00
rouggy e6b8a17772 fix: a rig reporting LSB/USB left the mode selector empty
From a Kenwood trace: IF answered mode digit 1 on 7.160 MHz, the backend read
LSB correctly, and the entry form showed no mode at all while the frequency
tracked perfectly.

Nothing was wrong in the backend. Radios report the SIDEBAND, and the station's
mode list holds SSB — so the value pushed into the selector was not in its own
list of options and nothing could match. Yaesu and Icom report the same way, so
this was never Kenwood-specific; it only surfaced there.

Normalising at the point where a rig-reported mode enters the form fixes every
backend at once, rather than editing three mode tables and the next backend
someone writes.

It is also the ADIF-correct direction, which matters more than the selector:
LSB and USB are SUBMODEs in ADIF, not modes. MODE=LSB is not a valid value and
is what would have gone to LoTW and eQSL.

An operator who has deliberately put LSB or USB in their own mode list keeps
them — the list is their statement of what they log, not ours.
2026-07-30 20:50:05 +02:00
rouggy 802c6e8c43 docs: changelog records what shipped, not how it was built
The grey line has never been released, so its off-by-one-copy edge was never
something an operator saw. An entry for it would describe a defect that only
existed between two commits of the same unreleased feature — noise in the middle
of the list someone reads to find out what changed for them.

Same case for the bulk-update progress bar: the feature and the fix that made it
non-blocking are both new in 0.22.3, so they are now one entry describing what
it does. The menu rename goes there too rather than standing alone.
2026-07-30 20:37:16 +02:00
rouggy f82631690a fix: grey line cut off at the edge of the world copy
The map repeats the world sideways, and zoomed out several copies are on screen
at once. The rings span −180…+180 once, so the shading ended in a hard vertical
edge wherever the next copy began: it read as a rectangle laid over the planet
rather than as night.

Each ring is now drawn again shifted a full turn either way. Three copies cover
every zoom this map reaches and cost nothing — three more polygons on a canvas
layer.

Zooming the map to fit one world would have hidden it rather than fixed it, and
would have taken away the free pan and zoom the operator has.
2026-07-30 19:35:26 +02:00
rouggy 3b58e39eec feat: grey line on the world map; fix: trace checkboxes lied about their state
Grey line — the day/night terminator and its twilight band, off by default,
redrawn every minute so a map left open all day is not silently wrong by
evening. Its own Leaflet pane below the overlays, non-interactive, so it never
hides the path or beam nor swallows a click.

The solar maths is pure and was checked against known positions before being
wired to anything: both solstices, the March equinox, the subsolar longitude at
12 UTC, and day/night at Paris, Tokyo, New York, Sydney and Reykjavik across
seasons and hemispheres. That last set is what caught the real bug: the equation
has TWO solutions per meridian and the naive branch put New York in daylight at
02:00 in December — the terminator at +63° where it belongs at −63°. A shaded
map that looks entirely plausible and is exactly mirrored. It now anchors on the
classical terminator and takes the branch nearest it, which is also what makes
the twilight band follow the terminator instead of jumping hemisphere.

Separately, from a Xiegu user: the protocol-trace checkbox does not stay ticked.
It is React state initialised to false on every mount, so a trace still running
came back unticked — the operator ticks it to "switch it on", which switches it
OFF, and the log they send contains no trace at all. Worse than a cosmetic bug:
it silently defeats the one tool asked for to diagnose their radio. Both boxes
(CAT and WinKeyer) now read their real state from the backend.
2026-07-30 18:06:12 +02:00
rouggy 45e3f3edb8 fix: bulk update no longer holds the application hostage, and says what it does
Two faults in the progress bar shipped an hour ago.

It was a modal. A hundred QSOs is a minute; a contest log is two thousand and
ten minutes, and for all of it the operator could do nothing else — while their
radio is on. It is now a card in the corner: same bar, same count, same
callsign, none of the imprisonment.

And the menu said "Update from QRZ.com" while the code has always walked EVERY
configured provider, falling through to HamQTH when QRZ answers not-found. The
label is now "Update from the callsign databases", which is what happens.

Noted while reading that code, NOT fixed here because it trades against the
user's API quota and is their call: this path goes through the lookup cache, so
a second run on the same callsigns re-reads the cache rather than reaching the
provider. The cache comment already argues the opposite for deliberate clicks —
"a lookup the operator asked for by clicking is a deliberate act and must reach
the provider" — and a right-click on a selection is exactly that.
2026-07-30 18:00:19 +02:00
rouggy c931d8a762 feat: progress bar for bulk QSO updates from QRZ.com
Selecting the 102 contacts of a freshly imported contest log and updating them
from QRZ.com is 102 network round trips. Nothing moved on screen while it ran,
so the only honest reading was that the application had frozen — and the natural
response to that is to kill it, halfway through.

The backend now emits a progress event per QSO and the frontend shows the same
overlay the ADIF import uses: a bar, the count, and the callsign being queried.

The callsign is reported BEFORE the lookup rather than after. With a slow
provider that is the difference between "waiting on F4BPO" and a name that lags
one QSO behind whatever is actually taking the time — which would be the field
someone stares at while deciding whether it is stuck.

The overlay is cleared by the closing event and again in a finally, so neither
a completed run nor a failed one can leave it on screen.
2026-07-30 17:54:07 +02:00
rouggy eb271e8f20 fix: Kenwood split read from FR/FT when the status frame omits it
Reported from a Flex in Kenwood CAT mode: frequency read perfectly, split never
appeared. Not every rig speaking this dialect fills IF's split bit.

Rather than guess which column that firmware populates, ask the question that
DEFINES split — is the transmit VFO a different VFO from the receive one — which
is exactly what FR and FT answer, and the same rule the Yaesu backend settled on
after several wrong turns. FR is also trusted over IF for which VFO is in use:
they are asked in the same breath, and a rig vague about the split bit may be
just as vague about the VFO field.

Cost is bounded: a rig that rejects FR/FT answers "?;" once and is never asked
again, so this is two short commands per poll only where it works. Both paths
are tested against the emulator — split found through FR/FT with IF silent, and
IF-reported split still working on a rig that refuses FR/FT without re-asking.

Also extends the CAT wire trace to the Kenwood backend, ASCII quoted so an empty
reply is visible as such: "" and ";" look identical unquoted, and telling them
apart is the whole question when a rig half-supports a command. If this fix is
not the whole story on real hardware, the trace is what will say so.
2026-07-30 16:50:06 +02:00
rouggy e2d2485703 test: drive the Kenwood backend against a rig that answers
Nobody here owns a Kenwood, and a backend that has never completed a single
exchange is a guess however carefully it was written.

But this repository already contains the other half of the conversation:
internal/catemu ANSWERS this dialect, pretending to be a TS-2000 so an ACOM
amplifier follows OpsLog. The test's responder replies exactly as catemu does —
same FA/FB widths, same 38-character IF layout, same ID019 — so the two halves
are checked against each other instead of against my reading of a manual.

Connect adds a dialPort seam for this; nothing else changed in the backend.

Covered: model identification, simplex on A, everything following the VFO in
use when on B, tuning writing to that VFO rather than blindly to FA, split with
TX and RX the right way round (ADIF FREQ is the TRANSMIT frequency — swapping
them writes the wrong frequency into every logged QSO), the mode digit, and a
silent port reported as not answering rather than as a connected radio. The last
three are each a fault that actually reached a user through the Yaesu backend.

What this does NOT prove: that a real TS-590 answers on the same timings, or
fills every IF field the way its documentation says. That still needs a radio.
2026-07-30 16:36:49 +02:00
49 changed files with 12127 additions and 321 deletions
+563 -49
View File
@@ -116,6 +116,7 @@ const (
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70) keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4) keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400) keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft) keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200) keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
@@ -140,11 +141,20 @@ const (
keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings keyAudioQSODir = "audio.qso_dir" // folder for QSO recordings
keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length keyAudioPreroll = "audio.preroll_seconds" // rolling-buffer pre-roll length
keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded) keyAudioTXGain = "audio.tx_gain" // voice-keyer playback level % (100 = as recorded)
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr" // Replaying a QSO recording needs its OWN level, and by a wide margin.
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT //
keyAudioFormat = "audio.qso_format" // "wav" | "mp3" // A voice-keyer message is a microphone at speaking distance; a QSO
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent // recording is a receiver's line output, which is far hotter. Sharing one
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent // setting meant the 195% an operator needs for their F-key messages was
// also applied to a recording, which then hit the transmitter flat out —
// visible on the panadapter as a signal several times the size of the S5
// station being answered.
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable) keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
@@ -271,7 +281,10 @@ const (
// External services (logbook upload). QRZ.com first; Clublog / LoTW // External services (logbook upload). QRZ.com first; Clublog / LoTW
// will add their own keys under the same extsvc.* prefix. // will add their own keys under the same extsvc.* prefix.
keyExtQRZAPIKey = "extsvc.qrz.api_key" keyExtQRZAPIKey = "extsvc.qrz.api_key"
// Opt-in, and off by default: withdrawing a QSO from QRZ or Club Log cannot
// be undone, and a local delete is not always meant to reach the world.
keyExtDeleteRemote = "extsvc.delete_remote" // also delete from QRZ/Club Log when a QSO is deleted
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign" keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload" keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
keyExtQRZUploadMode = "extsvc.qrz.upload_mode" keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
@@ -359,23 +372,25 @@ type CATSettings struct {
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70) XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4) YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400) YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft) KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600) // adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP.
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
PollMs int `json:"poll_ms"` // poll interval in ms (default 250) TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
DelayMs int `json:"delay_ms"` // pause between commands (default 0) TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl) DelayMs int `json:"delay_ms"` // pause between commands (default 0)
SharePort int `json:"share_port"` // TCP port for it (default 4532) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
SharePort int `json:"share_port"` // TCP port for it (default 4532)
} }
// ModePreset is a mode entry with default RST values to auto-populate // ModePreset is a mode entry with default RST values to auto-populate
@@ -535,10 +550,15 @@ type App struct {
ampsMu sync.Mutex // guards ampInsts ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping // qsoRecManual marks a take the operator started by hand while automatic
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign) // recording is OFF. Such a take opened the sound devices itself, so it must
scp *scp.Manager // Super Check Partial / N+1 callsign master list // close them again when it ends — an operator who records one contact does
// not expect their microphone held open for the rest of the session.
qsoRecManual atomic.Bool
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
scp *scp.Manager // Super Check Partial / N+1 callsign master list
// NET Control: persistent net definitions/rosters (global JSON) + the live // NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO). // session (in-memory only — active stations currently in QSO).
@@ -833,7 +853,11 @@ func (a *App) startup(ctx context.Context) {
// Route CAT/OmniRig debug lines into the unified app log (they used to go // Route CAT/OmniRig debug lines into the unified app log (they used to go
// to a separate cat.log in the old HamLog folder, which users couldn't find). // to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
// A recorder that captures nothing must reach the OPERATOR, not just the log:
// they are mid-QSO, and by the time they notice at save time the audio is
// gone for good.
audio.AlertSink = func(format string, args ...any) { a.toast(fmt.Sprintf(format, args...)) }
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
db.LogSink = applog.Printf // which schema migrations ran, and how long they took db.LogSink = applog.Printf // which schema migrations ran, and how long they took
@@ -1205,6 +1229,15 @@ func (a *App) startup(ctx context.Context) {
} }
}) })
a.qsoRec = audio.NewRecorder() a.qsoRec = audio.NewRecorder()
if a.audioMgr != nil {
// A running monitor picks the new level up immediately: the operator is
// listening while they move the slider, and asking them to stop and
// restart it to hear the change is how a working control gets reported
// as broken.
if cfg, err := a.GetAudioSettings(); err == nil {
a.audioMgr.SetMonitorGain(cfg.FromGain)
}
}
a.startQSORecorderIfEnabled() a.startQSORecorderIfEnabled()
// NET Control store (global JSON, shared across logbooks). // NET Control store (global JSON, shared across logbooks).
@@ -5505,15 +5538,77 @@ func (a *App) DeleteQSO(id int64) error {
if a.qso == nil { if a.qso == nil {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
a.deleteRemoteCopies([]int64{id})
return a.qso.Delete(a.ctx, id) return a.qso.Delete(a.ctx, id)
} }
// deleteRemoteCopies withdraws QSOs from QRZ.com and Club Log, when the
// operator has asked for that.
//
// Called BEFORE the local delete, because both services need the QSO's own
// fields to find their copy — Club Log matches on callsign, exact time and
// band, and QRZ needs the logid stored on the record. Once the row is gone
// there is nothing left to ask with.
//
// Failures never block the local delete. The operator asked for this QSO to go;
// a refusing website is not a reason to keep it, and the log says what happened
// so it can be sorted out on the site afterwards.
func (a *App) deleteRemoteCopies(ids []int64) {
if a.qso == nil || len(ids) == 0 {
return
}
prefix := ""
if a.profileHasGroup(markerExtsvc) {
prefix = a.profileScope()
}
m, err := a.getManyScoped(prefix, keyExtDeleteRemote)
if err != nil || m[keyExtDeleteRemote] != "1" {
return
}
cfg := a.loadExternalServices()
doQRZ := strings.TrimSpace(cfg.QRZ.APIKey) != ""
doClublog := strings.TrimSpace(cfg.Clublog.Email) != "" && cfg.Clublog.Password != ""
if !doQRZ && !doClublog {
return
}
for _, id := range ids {
q, err := a.qso.GetByID(a.ctx, id)
if err != nil || q.Callsign == "" {
continue
}
if doQRZ {
logID := ""
if q.Extras != nil {
logID = q.Extras["APP_OPSLOG_QRZ_LOGID"]
}
if strings.TrimSpace(logID) == "" {
// Uploaded before OpsLog kept the identifier, or never uploaded.
// QRZ has no delete-by-callsign-and-date, so say so plainly rather
// than let the operator believe it was withdrawn.
applog.Printf("extsvc: QSO %d (%s) has no QRZ logid — remove it on qrz.com by hand", id, q.Callsign)
} else if msg, err := extsvc.DeleteQRZ(a.ctx, nil, cfg.QRZ.APIKey, []string{logID}); err != nil {
applog.Printf("extsvc: QRZ delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
} else {
applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
}
}
if doClublog {
if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
} else {
applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
}
}
}
}
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the // DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
// number actually deleted. // number actually deleted.
func (a *App) DeleteQSOs(ids []int64) (int64, error) { func (a *App) DeleteQSOs(ids []int64) (int64, error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
} }
a.deleteRemoteCopies(ids)
return a.qso.DeleteMany(a.ctx, ids) return a.qso.DeleteMany(a.ctx, ids)
} }
@@ -6653,7 +6748,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, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -6672,6 +6767,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
YaesuPort: m[keyCATYaesuPort], YaesuPort: m[keyCATYaesuPort],
YaesuBaud: 38400, YaesuBaud: 38400,
KenwoodPort: m[keyCATKenwoodPort], KenwoodPort: m[keyCATKenwoodPort],
KenwoodHost: m[keyCATKenwoodHost],
KenwoodBaud: 9600, KenwoodBaud: 9600,
IcomPort: m[keyCATIcomPort], IcomPort: m[keyCATIcomPort],
IcomBaud: 115200, IcomBaud: 115200,
@@ -6827,6 +6923,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort), keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud), keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort), keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud), keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
@@ -6870,6 +6967,7 @@ type AudioSettings struct {
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100 FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
MicGain int `json:"mic_gain"` // mic mix level %, default 100 MicGain int `json:"mic_gain"` // mic mix level %, default 100
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100 TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
QSOPlayGain int `json:"qso_play_gain"` // QSO-recording playback level %, default 100
} }
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints // ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
@@ -6879,14 +6977,14 @@ func (a *App) ListAudioOutputDevices() ([]audio.Device, error) { return audio.Li
// GetAudioSettings returns the stored audio config (preroll defaults to 8s). // GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) { func (a *App) GetAudioSettings() (AudioSettings, error) {
out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100} out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100, QSOPlayGain: 100}
if a.settings == nil { if a.settings == nil {
return out, nil return out, nil
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice, keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat, keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain) keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -6917,6 +7015,9 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 { if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
out.TXGain = n out.TXGain = n
} }
if n, _ := strconv.Atoi(m[keyAudioQSOPlayGain]); n > 0 && n <= 400 {
out.QSOPlayGain = n
}
return out, nil return out, nil
} }
@@ -6948,6 +7049,9 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
} }
// Up to 400 %: a mic recorded quietly needs real amplification, and the // Up to 400 %: a mic recorded quietly needs real amplification, and the
// clamp in the player keeps it from wrapping into noise. // clamp in the player keeps it from wrapping into noise.
if s.QSOPlayGain <= 0 || s.QSOPlayGain > 400 {
s.QSOPlayGain = 100
}
if s.TXGain <= 0 || s.TXGain > 400 { if s.TXGain <= 0 || s.TXGain > 400 {
s.TXGain = 100 s.TXGain = 100
} }
@@ -6965,6 +7069,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
keyAudioFromGain: strconv.Itoa(s.FromGain), keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain), keyAudioMicGain: strconv.Itoa(s.MicGain),
keyAudioTXGain: strconv.Itoa(s.TXGain), keyAudioTXGain: strconv.Itoa(s.TXGain),
keyAudioQSOPlayGain: strconv.Itoa(s.QSOPlayGain),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -6972,6 +7077,12 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
} }
// Apply device/preroll/enable changes to the running recorder. // Apply device/preroll/enable changes to the running recorder.
a.startQSORecorderIfEnabled() a.startQSORecorderIfEnabled()
// And to a monitor ALREADY RUNNING: the operator is listening while they
// move the slider. Making them stop and restart the monitor to hear the
// change is how a working control gets reported as doing nothing.
if a.audioMgr != nil {
a.audioMgr.SetMonitorGain(s.FromGain)
}
return nil return nil
} }
@@ -7032,14 +7143,17 @@ func (a *App) qsoRecDir() string {
// be e-mailed later), and auto-sends it to the contacted operator when enabled // be e-mailed later), and auto-sends it to the contacted operator when enabled
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP); // and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
// q must have its ID set. // q must have its ID set.
// recordableMode reports whether a QSO mode is worth an audio recording // recordableMode reports whether a QSO mode is worth an audio recording: voice
// only voice (SSB/AM/FM) and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry no // and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry only modem tones, which
// useful audio, so they are never recorded. // nobody will ever replay, so they are never recorded.
//
// CW was excluded for a while because SmartSDR does not route the operator's own
// sidetone through DAX, making the recording sound one-sided. The audio path is
// open all the same and the other station IS captured — which is the half worth
// keeping. Operator's call.
func recordableMode(mode string) bool { func recordableMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) { switch strings.ToUpper(strings.TrimSpace(mode)) {
// CW is intentionally excluded: SmartSDR doesn't route CW audio through DAX, case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
// so the recording is empty/useless. Phone modes only.
case "SSB", "USB", "LSB", "AM", "FM", "DV":
return true return true
} }
return false return false
@@ -7054,10 +7168,20 @@ func (a *App) saveQSORecording(q *qso.QSO) {
applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r) applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
} }
}() }()
// Every exit from here ends the take, including the digital-mode discard and
// the snapshot failure below — so release the devices from one place rather
// than remembering to do it at each return.
defer a.stopManualQSORecorder()
// Both of these were SILENT returns. When an operator reports "the recording
// was not saved", the log has to say which of the two happened — no take was
// running, or the mode disqualified it — because they are different problems
// with different fixes and neither leaves a file behind to inspect.
if a.qsoRec == nil || !a.qsoRec.Active() { if a.qsoRec == nil || !a.qsoRec.Active() {
applog.Printf("qso-rec: nothing saved for %s — no recording was running at log time", q.Callsign)
return return
} }
if !recordableMode(q.Mode) { if !recordableMode(q.Mode) {
applog.Printf("qso-rec: nothing saved for %s — mode %q carries no audio worth keeping", q.Callsign, q.Mode)
a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
return return
} }
@@ -7531,12 +7655,175 @@ func (a *App) QSOAudioResetClock() bool {
return a.qsoRec.Active() return a.qsoRec.Active()
} }
// QSOAudioManualReady reports whether a recording can be started BY HAND: the
// capture devices are configured, but automatic recording is off.
//
// It is what decides whether the entry strip offers a record button. Without
// it the button would appear for operators who have no sound card configured
// and could only ever produce an error.
func (a *App) QSOAudioManualReady() bool {
if a.qsoRec == nil {
return false
}
cfg, _ := a.GetAudioSettings()
return !cfg.QSORecord && strings.TrimSpace(cfg.FromRadio) != ""
}
// QSOAudioManualStart begins a recording when automatic recording is off.
//
// With the automatic recorder disabled the capture engine is not merely idle,
// it is not running at all — so this starts it first. That costs the pre-roll:
// an automatic recording opens with the seconds BEFORE the callsign was typed,
// a manual one can only start now. Better to say that in a comment than to have
// an operator wonder why one file has the DX's call in it and the other does
// not.
func (a *App) QSOAudioManualStart() bool {
if a.qsoRec == nil {
return false
}
if !a.qsoRec.Running() {
cfg, _ := a.GetAudioSettings()
if strings.TrimSpace(cfg.FromRadio) == "" {
return false
}
if err := a.qsoRec.Start(cfg.FromRadio, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
applog.Printf("qso-rec: manual start failed: %v", err)
return false
}
fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
if cfg.FromGain == 0 {
fromGain = 1
}
if cfg.MicGain == 0 {
micGain = 1
}
a.qsoRec.SetGains(fromGain, micGain)
a.qsoRecManual.Store(true)
applog.Printf("qso-rec: manual recording started by the operator")
}
a.qsoRec.BeginQSO()
return a.qsoRec.Active()
}
// stopManualQSORecorder releases the sound devices after a hand-started take,
// but only if this session opened them. When automatic recording is on the
// engine belongs to the app and must keep running for the next contact.
func (a *App) stopManualQSORecorder() {
if a.qsoRec == nil || !a.qsoRecManual.Swap(false) {
return
}
a.qsoRec.Stop()
applog.Printf("qso-rec: manual recording finished — sound devices released")
}
// QSOAudioStop freezes the recording without ending it, so it can be played
// back and still be saved with the QSO afterwards.
func (a *App) QSOAudioStop() bool {
if a.qsoRec == nil {
return false
}
return a.qsoRec.PauseQSO()
}
// QSOAudioResume continues a frozen recording, appending to what is there.
func (a *App) QSOAudioResume() bool {
if a.qsoRec == nil {
return false
}
return a.qsoRec.ResumeQSO()
}
// QSOAudioPlayOnAir transmits the recording made so far, through the DVK's
// output device and PTT.
//
// The situation this is for: you are working a station, you have been recording,
// and they ask to hear it. You stop, you send it to them, and the recording is
// still saved with the QSO.
//
// It goes out over the AIR. So it refuses unless the take has been stopped
// first: playing while still recording would feed the transmitted audio back
// into the same take, and on a rig monitoring its own transmission that is a
// loop. Stopping is the operator's statement that the take is finished, and it
// is one click they have already made.
func (a *App) QSOAudioPlayOnAir() error {
// Every refusal below is LOGGED as well as returned. "I click play and
// nothing happens" is what an operator sees when a function returns an error
// into a promise the interface swallowed — and the difference between "no
// output device configured" and "the take was empty" cannot be guessed from
// the outside.
if a.qsoRec == nil || a.audioMgr == nil {
applog.Printf("qso-rec: play refused — audio subsystem not initialised")
return fmt.Errorf("audio not initialized")
}
if !a.qsoRec.Paused() {
applog.Printf("qso-rec: play refused — the recording is still running (stop it first)")
return fmt.Errorf("stop the recording before playing it on the air")
}
pcm, err := a.qsoRec.PeekQSO()
if err != nil {
applog.Printf("qso-rec: play refused — %v", err)
return err
}
cfgEarly, _ := a.GetAudioSettings()
if strings.TrimSpace(cfgEarly.ToRadio) == "" {
// The recorder needs an INPUT from the radio; playing back needs an
// OUTPUT into it, which is a different device and may well be unset on a
// station that only ever recorded.
// Name the FIELD, not the concept. An operator who had just emptied
// "Recording mic" read this as "you cannot play back unless you also
// record yourself" — a restriction that does not exist. The mic is an
// input; this is about the output that feeds the transmitter.
applog.Printf("qso-rec: play refused — Settings → Audio → \"To Radio (TX out)\" is empty (from_radio=%q, mic=%q)",
cfgEarly.FromRadio, cfgEarly.RecordingDevice)
return fmt.Errorf("Settings → Audio → \"To Radio (TX out)\" is empty — that is the output that feeds the transmitter (the recording mic has nothing to do with playing back)")
}
// A plain WAV in the data dir, overwritten each time: the player takes a
// path, and MP3 encoding would add seconds to something the operator is
// waiting on with the transmitter keyed.
path := filepath.Join(a.dataDir, "qso_playback.wav")
if err := audio.WritePCM(path, pcm); err != nil {
return fmt.Errorf("prepare playback: %w", err)
}
cfg := cfgEarly
if err := a.pttKey(cfg); err != nil {
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
// Keep going — the audio still reaches the rig and the operator may use VOX.
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
a.pttMu.Lock()
a.dvkPttKeyed = true
a.pttMu.Unlock()
}
if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.QSOPlayGain); err != nil {
applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err)
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
a.dvkPttKeyed = false
a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
}
return err
}
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
return nil
}
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO // QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
// abandoned without logging). // abandoned without logging).
func (a *App) QSOAudioCancel() { func (a *App) QSOAudioCancel() {
if a.qsoRec != nil { if a.qsoRec != nil {
// Say so when a take is actually thrown away. This fires when the callsign
// is cleared — including when a clicked spot replaces it — and until now
// it was silent, so a recording that vanished mid-QSO left the log showing
// only its absence at save time.
if a.qsoRec.Active() {
applog.Printf("qso-rec: in-progress recording discarded (callsign cleared)")
}
a.qsoRec.DiscardQSO() a.qsoRec.DiscardQSO()
} }
a.stopManualQSORecorder()
} }
// RestartQSORecorder applies new audio settings to the running recorder. // RestartQSORecorder applies new audio settings to the running recorder.
@@ -8631,6 +8918,42 @@ func (a *App) DVKStop() {
} }
} }
// AudioApplyLevels applies the RX and mic levels IMMEDIATELY, without saving.
//
// A level is set by ear, by dragging: the sliders only touched the settings
// panel state, so the value reached the audio engine when the operator clicked
// Save — and while they were listening and dragging, 10%% sounded exactly like
// 300%%. The control was not weak, it was not connected to anything yet.
//
// Persisting still happens on Save. This is the live half.
func (a *App) AudioApplyLevels(fromPct, micPct int) {
// PERSIST as well as apply. Starting a recording re-reads the stored
// settings and calls SetGains with them, so a live-only value was thrown
// away the moment a take began: the monitor followed the slider while two
// recordings made at 10%% and 300%% came out identical.
if a.settings != nil {
if fromPct > 0 {
_ = a.settings.Set(a.ctx, keyAudioFromGain, strconv.Itoa(fromPct))
}
if micPct > 0 {
_ = a.settings.Set(a.ctx, keyAudioMicGain, strconv.Itoa(micPct))
}
}
if a.audioMgr != nil {
a.audioMgr.SetMonitorGain(fromPct)
}
if a.qsoRec != nil {
f, m := float64(fromPct)/100, float64(micPct)/100
if fromPct <= 0 {
f = 1
}
if micPct <= 0 {
m = 1
}
a.qsoRec.SetGains(f, m)
}
}
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you // AudioStartMonitor pipes live RX audio from the rig into your speakers so you
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a // hear the radio inside OpsLog. Source = the "From radio" capture device (for a
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening" // USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
@@ -8645,6 +8968,7 @@ func (a *App) AudioStartMonitor() error {
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`) return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
} }
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice) applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
a.audioMgr.SetMonitorGain(cfg.FromGain)
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice) return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
} }
@@ -8934,10 +9258,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode, keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID, keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode) keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
if err != nil { if err != nil {
return out return out
} }
out.DeleteRemote = m[keyExtDeleteRemote] == "1"
out.QRZ = extsvc.ServiceConfig{ out.QRZ = extsvc.ServiceConfig{
APIKey: m[keyExtQRZAPIKey], APIKey: m[keyExtQRZAPIKey],
ForceStationCallsign: m[keyExtQRZForceCall], ForceStationCallsign: m[keyExtQRZForceCall],
@@ -9079,8 +9404,13 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if cfg.Cloudlog.AutoUpload { if cfg.Cloudlog.AutoUpload {
clgAuto = "1" clgAuto = "1"
} }
delRemote := "0"
if cfg.DeleteRemote {
delRemote = "1"
}
scope := a.profileScope() // write under the active profile's prefix scope := a.profileScope() // write under the active profile's prefix
for k, v := range map[string]string{ for k, v := range map[string]string{
keyExtDeleteRemote: delRemote,
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)), keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
keyExtQRZAutoUpload: auto, keyExtQRZAutoUpload: auto,
@@ -9876,6 +10206,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner) mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
const qrzMatchWindow = 10 * time.Minute const qrzMatchWindow = 10 * time.Minute
qrzSkippedOtherCall := 0 qrzSkippedOtherCall := 0
unconfirmed := 0 // QSOs whose wrongly-set QRZ flag was taken back
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is // QRZ confirmations are QRZ-specific (not award-valid), so NEW is
// judged only against other QRZ confirmations. // judged only against other QRZ confirmations.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"}) sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
@@ -9902,6 +10233,25 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
allKeys[k] = true allKeys[k] = true
} }
if !qrzRecordConfirmed(rec) { if !qrzRecordConfirmed(rec) {
// QRZ says this one is NOT confirmed. If our log says it is, that
// is a mark OpsLog set wrongly before it learned which field
// carries QRZ's answer — take it back rather than leave a QSO
// counting towards an award it has not earned.
//
// Only on an EXPLICIT no: a record with no app_qrzlog_status at
// all is QRZ saying nothing, and silence is not a retraction.
if st := strings.TrimSpace(rec["app_qrzlog_status"]); st != "" {
if q, ok := adif.RecordToQSO(rec); ok && q.Callsign != "" {
if id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow); found && alreadyQrz[id] {
if e := a.qso.ClearQRZConfirmed(ctx, id); e == nil {
delete(alreadyQrz, id)
unconfirmed++
emit(fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s ### NOT CONFIRMED ON QRZ — flag cleared ###",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode))
}
}
}
}
return nil return nil
} }
q, ok := adif.RecordToQSO(rec) q, ok := adif.RecordToQSO(rec)
@@ -9996,6 +10346,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
sort.Strings(keys) sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", "))) emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
if unconfirmed > 0 {
// Worth its own line and not a footnote: these QSOs were showing as
// confirmed a moment ago, and an award total may move because of it.
emit(fmt.Sprintf("Cleared %d QSO(s) that QRZ.com does NOT confirm — they had been marked wrongly by an earlier version", unconfirmed))
}
if perr != nil { if perr != nil {
emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.") emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).") emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
@@ -10150,18 +10505,20 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is // "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is
// most of it. // most of it.
// //
// A QRZ confirmation is QRZ's own statement, and only two fields carry it: // ONE field carries it: app_qrzlog_status, where C means confirmed.
// //
// app_qrzlog_status = C QRZ's confirmed marker on the record // qrzcom_qso_download_status was accepted too, and that was wrong. It is not a
// qrzcom_qso_download_status = Y the ADIF field QRZ sets for it // confirmation: QRZ sets it to Y on everything it hands back. An operator's own
// fetch showed both fields on the same record —
// //
// Anything else is a claim from some other source and must not be read as a QRZ // <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
// confirmation — this status feeds award slots, so a false Y is a QSO counted as //
// confirmed when it is not. // — a QSO QRZ says is NOT confirmed, which OpsLog then marked confirmed. That
// is how a whole download turned green (2026-07-31).
//
// This status feeds award slots, so a false Y is a QSO counted as confirmed when
// it is not — the one error worth being strict about here.
func qrzRecordConfirmed(rec adif.Record) bool { func qrzRecordConfirmed(rec adif.Record) bool {
if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") {
return true
}
return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C") return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C")
} }
@@ -10262,6 +10619,23 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
return changed, nil return changed, nil
} }
// emitBulkProgress reports how far a bulk update has got.
//
// The QRZ pass is the one that needs it: it is a NETWORK round trip per QSO, so
// 102 contacts imported from a contest take a couple of minutes during which
// nothing moved on screen and the only honest reading was "it has frozen". The
// cty.dat and ClubLog passes are local and usually instant, but they report too
// — a progress bar that appears only when things are slow teaches people that
// its absence means trouble.
func (a *App) emitBulkProgress(op string, processed, total int, call string) {
wruntime.EventsEmit(a.ctx, "bulkupdate:progress", map[string]any{
"op": op,
"processed": processed,
"total": total,
"call": call,
})
}
// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per // UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
// the configured providers) for each QSO id and overwrites the geographic // the configured providers) for each QSO id and overwrites the geographic
// + entity fields (country, continent, DXCC, zones, grid, state, county) // + entity fields (country, continent, DXCC, zones, grid, state, county)
@@ -10272,13 +10646,23 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
return 0, fmt.Errorf("not initialized") return 0, fmt.Errorf("not initialized")
} }
changed := 0 changed := 0
for _, id := range ids { total := len(ids)
a.emitBulkProgress("qrz", 0, total, "")
defer a.emitBulkProgress("qrz", total, total, "")
for i, id := range ids {
q, err := a.qso.GetByID(a.ctx, id) q, err := a.qso.GetByID(a.ctx, id)
if err != nil || q.Callsign == "" { if err != nil || q.Callsign == "" {
a.emitBulkProgress("qrz", i+1, total, "")
continue continue
} }
// Reported BEFORE the lookup, so the callsign on screen is the one being
// waited on rather than the one already finished — with a slow provider
// that is the difference between "working on F4BPO" and a name that
// lags a QSO behind whatever is actually taking the time.
a.emitBulkProgress("qrz", i, total, q.Callsign)
r, err := a.lookup.Lookup(a.ctx, q.Callsign) r, err := a.lookup.Lookup(a.ctx, q.Callsign)
if err != nil { if err != nil {
a.emitBulkProgress("qrz", i+1, total, "")
continue continue
} }
if r.Country != "" { if r.Country != "" {
@@ -10330,6 +10714,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
if err := a.qso.Update(a.ctx, q); err == nil { if err := a.qso.Update(a.ctx, q); err == nil {
changed++ changed++
} }
a.emitBulkProgress("qrz", i+1, total, "")
} }
if changed > 0 { if changed > 0 {
a.invalidateAwardStats() a.invalidateAwardStats()
@@ -10531,6 +10916,17 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if a.qso == nil { if a.qso == nil {
return return
} }
// Keep the identifier the service handed back. QRZ deletes ONLY by logid —
// its API has no delete-by-callsign-and-date — so a QSO uploaded without
// recording this can never be withdrawn from QRZ afterwards except by hand.
// It costs one small write per upload and it is the only chance to get it.
if strings.TrimSpace(logID) != "" && id != 0 {
key := "APP_OPSLOG_" + strings.ToUpper(string(svc)) + "_LOGID"
if err := a.qso.SetExtra(ctx, id, key, strings.TrimSpace(logID)); err != nil {
applog.Printf("extsvc: store %s logid for QSO %d: %v", svc, id, err)
}
}
var err error var err error
switch svc { switch svc {
case extsvc.ServiceQRZ: case extsvc.ServiceQRZ:
@@ -11935,6 +12331,106 @@ func (a *App) FlexApplyBandAntenna(band string) error {
}) })
} }
// keyFlexBandPower stores the per-band, per-mode TX power map (global,
// machine-local — it describes this station's amplifier and antennas).
const keyFlexBandPower = "flex.band_power"
// FlexBandPower is the TX power to set on a band, per class of mode.
//
// Three classes rather than per-mode: what decides the power is the duty cycle
// and the amplifier's tolerance for it, not the mode's name. FT8 and RTTY hurt
// an amplifier the same way; SSB and AM do not.
//
// Zero means "leave the power alone" — an empty box must not silently mean
// zero watts, and an operator who configures 20 m only does not want every
// other band reset when they get there.
type FlexBandPower struct {
Phone int `json:"phone"`
CW int `json:"cw"`
Digi int `json:"digi"`
}
// GetFlexBandPower returns the band→power map (band key uppercased, "20M").
func (a *App) GetFlexBandPower() (map[string]FlexBandPower, error) {
out := map[string]FlexBandPower{}
if a.settings == nil {
return out, nil
}
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandPower)
if strings.TrimSpace(v) == "" {
return out, nil
}
_ = json.Unmarshal([]byte(v), &out)
return out, nil
}
// SaveFlexBandPower persists the band→power map.
func (a *App) SaveFlexBandPower(m map[string]FlexBandPower) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(m)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyFlexBandPower, string(b))
}
// flexPowerClass sorts an ADIF mode into the three classes the power table uses.
// An unknown mode returns "" and changes nothing — guessing would be setting a
// transmit power from a name we do not recognise.
func flexPowerClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB", "AM", "FM", "DV":
return "phone"
case "CW", "CWR":
return "cw"
case "FT8", "FT4", "JT65", "JT9", "JS8", "RTTY", "PSK", "PSK31", "PSK63",
"BPSK", "QPSK", "MFSK", "OLIVIA", "CONTESTI", "DATA", "DIGITALVOICE",
"DIGU", "DIGL", "Q65", "MSK144", "WSPR", "FST4", "FST4W", "ARDOP", "VARA":
return "digi"
}
return ""
}
// FlexApplyBandPower sets the configured TX power for a band and mode.
//
// Called on band AND mode changes: the whole point is that switching to FT8 on
// a band where 1.5 kW is fine for SSB does not put 1.5 kW into a 100% duty-cycle
// signal. No mapping, or a zero, leaves the radio alone.
func (a *App) FlexApplyBandPower(band, mode string) error {
if a.cat == nil {
return nil
}
band = strings.ToUpper(strings.TrimSpace(band))
class := flexPowerClass(mode)
if band == "" || class == "" {
return nil
}
m, _ := a.GetFlexBandPower()
e, ok := m[band]
if !ok {
return nil
}
pct := 0
switch class {
case "phone":
pct = e.Phone
case "cw":
pct = e.CW
case "digi":
pct = e.Digi
}
if pct <= 0 {
return nil
}
if pct > 100 {
pct = 100
}
applog.Printf("flex: %s %s → TX power %d%%", band, class, pct)
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(pct) })
}
// RIT/XIT on the active slice. The offset is kept by the radio when the switch is // RIT/XIT on the active slice. The offset is kept by the radio when the switch is
// off, so turning it back on restores it — the UI must not zero it on toggle. // off, so turning it back on restores it — the UI must not zero it on toggle.
func (a *App) FlexSetRIT(on bool) error { func (a *App) FlexSetRIT(on bool) error {
@@ -12259,7 +12755,14 @@ func (a *App) reloadCAT() {
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs). // same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
// One IF; frame carries frequency, mode, VFO and split, so the poll costs a // One IF; frame carries frequency, mode, VFO and split, so the poll costs a
// single round trip where OmniRig needed a rig file to describe each one. // single round trip where OmniRig needed a rig file to describe each one.
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)) // A network address wins over the COM port when both are filled: it is the
// more deliberate setting, and silently preferring the wire would leave an
// operator staring at a host they typed and a radio that never answers.
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
} else {
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault))
}
case "icom": case "icom":
// Native Icom CI-V over the radio's USB serial port (local control). // Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol the network backend reuses for remote. // Same civ protocol the network backend reuses for remote.
@@ -14478,6 +14981,17 @@ func (a *App) SetCIVTrace(on bool) {
cat.SetCIVTrace(on) cat.SetCIVTrace(on)
} }
// CIVTraceEnabled and WinkeyerTraceEnabled report whether each trace is running.
//
// The settings dialog needs them. Its checkboxes are React state initialised to
// false on every mount, so a trace left on came back UNTICKED while it was still
// running: the operator ticks the box to \"switch it on\", which switches it off,
// and the log they send then contains no trace at all. Reported by a Xiegu user.
func (a *App) CIVTraceEnabled() bool { return cat.CIVTraceEnabled() }
// WinkeyerTraceEnabled — same, for the keyer trace.
func (a *App) WinkeyerTraceEnabled() bool { return winkeyer.TraceEnabled() }
// WinkeyerConnect opens the serial link using the saved config. // WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error { func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil { if a.winkeyer == nil {
+96
View File
@@ -1,4 +1,100 @@
[ [
{
"version": "0.22.5",
"date": "",
"en": [
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips.",
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
"The \"From radio\" and mic levels take effect as you drag the slider, and the RX level now applies to what you HEAR through OpsLog, not only to recordings.",
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
"The callsign field is wider, taking the room from the RST pair.",
"MP3 recordings no longer carry a hiss of their own: the 16→32 kHz conversion mirrored the whole spectrum only 7 dB down. It is now 57 dB down.",
"Replaying a QSO recording on the air has its own level, separate from the voice keyer: a recording comes from a receiver line output and needs far less gain than a message spoken into a microphone.",
"A quiet DX cluster no longer looks like a broken one: silence stopped counting as a disconnection, so a node with few spots keeps its session, its login and its filters."
],
"fr": [
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes.",
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
"Les niveaux « From radio » et micro agissent pendant que vous déplacez le curseur, et le niveau RX s'applique désormais à ce que vous ENTENDEZ dans OpsLog, pas seulement aux enregistrements.",
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
"Le champ indicatif est plus large, la place venant de la paire RST.",
"Les enregistrements MP3 ne portent plus un souffle qui leur est propre : la conversion 16→32 kHz recopiait tout le spectre à seulement 7 dB en dessous. Il est désormais à 57 dB.",
"La relecture d'un enregistrement de QSO a son propre niveau, distinct du manipulateur vocal : une prise vient de la sortie ligne d'un récepteur et demande bien moins de gain qu'un message dit au micro.",
"Un cluster calme ne passe plus pour un cluster mort : le silence ne compte plus comme une déconnexion, donc un nœud avec peu de spots garde sa session, son login et ses filtres."
]
},
{
"version": "0.22.4",
"date": "",
"en": [
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter.",
"QSO recording works on CW again (no sidetone).",
"Playing a recording on the air now says why when it cannot.",
"The play-on-air button is hidden on CW.",
"CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.",
"FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.",
"Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.",
"The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.",
"QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log.",
"Awards: a mode filter (All / CW / Phone / Digital) that stacks with the worked/confirmed one — so \"worked on CW but not confirmed on CW\" is one click."
],
"fr": [
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.",
"L'enregistrement des QSO fonctionne à nouveau en CW (sans le signal d'écoute).",
"L'émission d'un enregistrement indique désormais pourquoi elle échoue.",
"Le bouton d'émission de l'enregistrement est masqué en CW.",
"Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.",
"FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.",
"Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.",
"Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.",
"Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal.",
"Diplômes : un filtre par mode (Tous / CW / Phonie / Numérique) qui se cumule avec le filtre contacté/confirmé — « contacté en CW mais pas confirmé en CW » se lit en un clic."
]
},
{
"version": "0.22.3",
"date": "",
"en": [
"Kenwood backend tested end to end against the built-in TS-2000 emulator.",
"Kenwood: split detected from FR/FT when the radio omits it from its status frame.",
"CAT protocol trace now covers the Kenwood backend, not only CI-V.",
"Updating selected QSOs from the callsign databases shows a progress bar, without blocking the rest of OpsLog. Menu entry renamed \"Update from the callsign databases\".",
"Grey line on the world map: day/night terminator and twilight band, button at the top right.",
"The CAT and WinKeyer trace checkboxes now show whether the trace is really running.",
"A radio reporting LSB or USB now selects SSB in the entry form.",
"Kenwood CAT over the network: give a host:port for a serial bridge instead of a COM port.",
"CAT backends renamed by how the radio is reached: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB), Icom (CI-V USB), Icom (CI-V network), TCI.",
"With automatic recording off, a red dot beside the callsign records the contact by hand.",
"A recording can be stopped and transmitted to the station being worked, like a voice-keyer message.",
"Award references can be sorted by reference or by description.",
"Deleting a QSO can also withdraw it from QRZ.com and Club Log — Settings → External services, off by default.",
"QSL and upload status columns are coloured: Y green, N red, R blue.",
"Settings → General chooses between US / FR / Standard for dates. Display only.",
"New award: DARC DOK Award (DLD)."
],
"fr": [
"Backend Kenwood testé de bout en bout contre l’émulateur TS-2000 intégré.",
"Kenwood : split détecté via FR/FT quand la radio ne le renseigne pas dans sa trame d’état.",
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V.",
"La mise à jour des QSO sélectionnés depuis les annuaires affiche une barre de progression, sans bloquer le reste dOpsLog. Entrée de menu renommée « Mettre à jour depuis les annuaires ».",
"Ligne grise sur la carte du monde : terminateur jour/nuit et bande de crépuscule, bouton en haut à droite.",
"Les cases de trace CAT et WinKeyer reflètent désormais l’état réel de la trace.",
"Une radio annonçant LSB ou USB sélectionne désormais SSB dans la saisie.",
"CAT Kenwood par le réseau : indiquez un hôte:port de pont série au lieu dun port COM.",
"Backends CAT renommés selon la façon dont la radio est jointe : OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, réseau), Xiegu (USB), Icom (CI-V USB), Icom (CI-V réseau), TCI.",
"Enregistrement automatique désactivé : un rond rouge à côté de lindicatif enregistre le contact à la main.",
"Un enregistrement peut être arrêté puis émis vers la station travaillée, comme un message du manipulateur vocal.",
"Les références dun diplôme peuvent être triées par référence ou par description.",
"Supprimer un QSO peut aussi le retirer de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut.",
"Colonnes de statut QSL et denvoi colorées : Y vert, N rouge, R bleu.",
"Réglages → Général : choix US / FR / Standard pour les dates. Affichage seulement.",
"Nouveau diplôme : DARC DOK Award (DLD)."
]
},
{ {
"version": "0.22.2", "version": "0.22.2",
"date": "2026-07-30", "date": "2026-07-30",
+25
View File
@@ -0,0 +1,25 @@
package main
import "testing"
// Which class of power a mode falls into.
//
// This decides how much power leaves the amplifier, so an error here is not a
// display bug: a digital mode sorted as phone gets the SSB setting, which is
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
func TestFlexPowerClass(t *testing.T) {
for _, c := range []struct{ mode, want string }{
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
// Unknown or empty: no class, and the caller changes nothing. Setting a
// transmit power from a name we do not recognise is not a good guess.
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
} {
if got := flexPowerClass(c.mode); got != c.want {
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
}
}
}
+287 -59
View File
@@ -17,7 +17,7 @@ import {
SMTPConfigured, SendLogToDeveloper, SMTPConfigured, SendLogToDeveloper,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
@@ -44,6 +44,7 @@ import {
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
GetAwardDefs, GetAwardDefs,
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp, GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec, ReportLiveActivity, LiveLastQSOAgeSec,
@@ -99,6 +100,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs'; import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel'; import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
@@ -135,7 +137,15 @@ const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALV
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in // Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex, // app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
// so neither is recorded (no REC badge / timer for them). // so neither is recorded (no REC badge / timer for them).
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']); // Modes a VOICE keyer may transmit on. Not a recording list — the DVK plays
// speech, and sending it on CW or a data slot keys the rig with a human voice.
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
// Modes worth recording. Phone, plus CW: the audio path stays open in CW, so
// the other station's signal is captured. Your own sidetone is not in it —
// SmartSDR does not route it through DAX — and that is accepted. Digital modes
// stay out: their audio is a modem tone nobody will ever replay.
const RECORDABLE_MODES = new Set([...PHONE_MODES, 'CW']);
const emptyDetails: DetailsState = { const emptyDetails: DetailsState = {
state: '', cnty: '', address: '', state: '', cnty: '', address: '',
@@ -168,11 +178,7 @@ function parseAwardRefs(s: any): Record<string, string> {
} }
function fmtDateUTC(s: any): string { function fmtDateUTC(s: any): string {
if (!s) return ''; return formatDateTimeUTC(s);
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
} }
function fmtFreq(hz?: number): string { function fmtFreq(hz?: number): string {
if (!hz) return ''; if (!hz) return '';
@@ -387,11 +393,40 @@ function WindowControls() {
); );
} }
// LockPad — the padlock toggle shown in a lockable field's label. Its icon
// matches the state, so a glance says which fields are immune to CAT updates
// and to the live clock.
//
// It lives HERE, at module level, and not inside App. Defined inside, React saw
// a new component type on every render of App — and App re-renders about four
// times a second while the CAT polls — so the button was unmounted and rebuilt
// each time. It flickered under the pointer and swallowed clicks, because the
// node being clicked no longer existed by the time the click landed.
function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle: () => void }) {
const Icon = on ? Lock : Unlock;
return (
<button
type="button"
tabIndex={-1}
onClick={onToggle}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn(
'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',
)}
>
<Icon className="size-3" />
</button>
);
}
export default function App() { export default function App() {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) === // === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS); const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES); const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
const modesRef = useRef(modes);
useEffect(() => { modesRef.current = modes; }, [modes]);
const [modePresets, setModePresets] = useState<ModePreset[]>([]); const [modePresets, setModePresets] = useState<ModePreset[]>([]);
// === Entry === // === Entry ===
@@ -447,27 +482,6 @@ export default function App() {
return next; return next;
}); });
}; };
// Small padlock toggle rendered inside each lockable field's label. Match
// the icon to the current state so the user can tell at a glance which
// fields are immune to CAT updates / live clock.
function LockBtn({ k, title }: { k: LockKey; title: string }) {
const on = locks[k];
const Icon = on ? Lock : Unlock;
return (
<button
type="button"
tabIndex={-1}
onClick={() => toggleLock(k)}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
className={cn(
'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',
)}
>
<Icon className="size-3" />
</button>
);
}
const [band, setBand] = useState('20m'); const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB'); const [mode, setMode] = useState('SSB');
const [freqMhz, setFreqMhz] = useState(''); const [freqMhz, setFreqMhz] = useState('');
@@ -766,16 +780,59 @@ export default function App() {
// Elapsed recording time (seconds) shown next to the red dot, ticking once a // Elapsed recording time (seconds) shown next to the red dot, ticking once a
// second while a recording is in progress. // second while a recording is in progress.
const [recSeconds, setRecSeconds] = useState(0); const [recSeconds, setRecSeconds] = useState(0);
// Mirrored in a ref so the clock can RESUME from where it stopped rather than
// restart, without the timer effect depending on the value it sets.
const recSecondsRef = useRef(0);
useEffect(() => { recSecondsRef.current = recSeconds; }, [recSeconds]);
// Bumped on every (re)start so the timer resets even when `recording` was // Bumped on every (re)start so the timer resets even when `recording` was
// already true (jumping spot→spot keeps recording=true but starts a fresh take). // already true (jumping spot→spot keeps recording=true but starts a fresh take).
const [recTick, setRecTick] = useState(0); const [recTick, setRecTick] = useState(0);
// True when the sound devices are configured but automatic recording is OFF —
// the only case where a record button makes sense. Asking the backend rather
// than reading a preference here keeps the two from drifting apart.
const [manualRecReady, setManualRecReady] = useState(false);
// A stopped take is still a take: the audio is kept and will be saved with the
// QSO. This only says the clock has stopped and the recording can be played.
const [recStopped, setRecStopped] = useState(false);
const stopRecording = () => {
QSOAudioStop().then((ok) => { if (ok) setRecStopped(true); }).catch(() => {});
};
const resumeRecording = () => {
QSOAudioResume().then((ok) => { if (ok) { setRecStopped(false); setRecTick((t) => t + 1); } }).catch(() => {});
};
// One button, two jobs: play, and stop what is playing.
//
// A second press used to RESTART the message from the beginning. What an
// operator wants there is to cut it short — they have heard enough, or they
// pressed it by mistake with the transmitter keyed — and then to be able to
// send it again straight away. Stopping releases the PTT through the same
// path the natural end uses.
const playRecordingOnAir = () => {
if (dvkStat.playing) { DVKStop(); return; }
QSOAudioPlayOnAir().catch((e: any) => setError(String(e?.message ?? e)));
};
const refreshManualRecReady = () => { QSOAudioManualReady().then(setManualRecReady).catch(() => {}); };
useEffect(() => { refreshManualRecReady(); }, []);
const startManualRecording = () => {
QSOAudioManualStart().then((active) => {
setRecording(active);
setRecTick((t) => t + 1);
if (!active) setError(t('rec.manualFailed'));
}).catch((e: any) => setError(String(e?.message ?? e)));
};
useEffect(() => { useEffect(() => {
if (!recording) { setRecSeconds(0); return; } if (!recording) { setRecSeconds(0); return; }
// A stopped take freezes the clock where it is: it must show the length of
// the file, not the time since the QSO began.
if (recStopped) return;
const from = recSecondsRef.current;
const start = Date.now(); const start = Date.now();
setRecSeconds(0); const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [recording, recTick]); }, [recording, recTick, recStopped]);
// recTick means "a fresh take" — that is the only case where the clock returns
// to zero, as opposed to resuming after a stop.
useEffect(() => { setRecSeconds(0); recSecondsRef.current = 0; setRecStopped(false); }, [recTick]);
// The callsign the in-progress recording belongs to (uppercased; '' = none). // The callsign the in-progress recording belongs to (uppercased; '' = none).
// Lets us restart from zero when the operator edits the call to a different // Lets us restart from zero when the operator edits the call to a different
// station mid-recording, instead of continuing the old take. // station mid-recording, instead of continuing the old take.
@@ -1198,7 +1255,7 @@ export default function App() {
useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]); useEffect(() => { dvkAutoCqSecsRef.current = dvkAutoCqSecs; localStorage.setItem('opslog.dvkAutoCqSecs', String(dvkAutoCqSecs)); }, [dvkAutoCqSecs]);
// The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig // The DVK is a VOICE keyer — transmitting it on CW/FT8/RTTY would key the rig
// with speech on a data slot. Only allow it on phone modes. // with speech on a data slot. Only allow it on phone modes.
const isPhoneMode = (m: string) => RECORDABLE_MODES.has((m || '').toUpperCase()); const isPhoneMode = (m: string) => PHONE_MODES.has((m || '').toUpperCase());
const modeRef = useRef(mode); const modeRef = useRef(mode);
useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { modeRef.current = mode; }, [mode]);
function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; } function stopDvkAutoCq() { dvkAutoCqSlotRef.current = -1; dvkAutoCqGenRef.current++; }
@@ -1280,7 +1337,12 @@ export default function App() {
// Status filter chips. Empty set = show every status (including // Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass. // already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked'; type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter')); // What the cluster can be FILTERED on. A superset of the entity status: a new
// county or a new park is not a DXCC state, it is another dimension of the
// same spot — which is why the grid shows them as separate badges. Filtering
// is an OR across all of them, as it already was for a worked callsign.
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
// Mode filter chips. Empty set = show every mode. Categories map the // Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital). // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
type SpotModeCat = 'SSB' | 'CW' | 'DATA'; type SpotModeCat = 'SSB' | 'CW' | 'DATA';
@@ -1518,6 +1580,8 @@ export default function App() {
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true); const [importApplyCty, setImportApplyCty] = useState(true);
const [importApplyStation, setImportApplyStation] = useState(false); const [importApplyStation, setImportApplyStation] = useState(false);
// Progress of a bulk update (QRZ.com / cty.dat / ClubLog) over selected QSOs.
const [bulkProgress, setBulkProgress] = useState<{ op: string; processed: number; total: number; call: string } | null>(null);
// Field remapping, off unless the operator adds a row. Contest software puts // Field remapping, off unless the operator adds a row. Contest software puts
// the exchange where its own module keeps it rather than where ADIF says: the // the exchange where its own module keeps it rather than where ADIF says: the
// RSGB IOTA contest exports the island reference in STATE, which imports as a // RSGB IOTA contest exports the island reference in STATE, which imports as a
@@ -2042,9 +2106,29 @@ export default function App() {
// a 599 left from a CW QSO would stick when jumping to an SSB spot. // a 599 left from a CW QSO would stick when jumping to an SSB spot.
function applyModeFromSpot(m: string) { function applyModeFromSpot(m: string) {
if (!m) return; if (!m) return;
setMode(m); const logged = catModeToLoggedMode(m);
setMode(logged);
rstUserEditedRef.current = false; rstUserEditedRef.current = false;
applyModePreset(m); applyModePreset(logged);
}
// catModeToLoggedMode maps what a radio reports onto a mode this station
// actually logs.
//
// Radios report the SIDEBAND — a Kenwood or Yaesu on 40 m answers LSB, not
// SSB. The mode list is SSB, so nothing matched and the selector sat empty
// while the frequency tracked perfectly: "the mode is not recognised".
//
// It is also the ADIF-correct direction. LSB and USB are SUBMODEs there, not
// modes; MODE=LSB is not a valid ADIF value and is what would be sent to LoTW
// and eQSL. So this protects the upload as much as the selector.
//
// A station that has deliberately put LSB or USB in its own mode list keeps
// them: the list is the operator's statement of what they log.
function catModeToLoggedMode(m: string): string {
const list = modesRef.current;
if (!m || list.includes(m)) return m;
if ((m === 'LSB' || m === 'USB') && list.includes('SSB')) return 'SSB';
return m;
} }
function applyModePreset(m: string) { function applyModePreset(m: string) {
if (rstUserEditedRef.current) return; if (rstUserEditedRef.current) return;
@@ -2211,8 +2295,9 @@ export default function App() {
else if (s.mode) nextMode = s.mode; else if (s.mode) nextMode = s.mode;
} }
if (nextMode) { if (nextMode) {
setMode(nextMode); const logged = catModeToLoggedMode(nextMode);
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits) setMode(logged);
applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
} }
} }
} }
@@ -2238,6 +2323,25 @@ export default function App() {
FlexApplyBandAntenna(b).catch(() => {}); FlexApplyBandAntenna(b).catch(() => {});
}, [band, catState.backend, locks.band]); }, [band, catState.backend, locks.band]);
// Per-band, per-mode TX power. Applied on band AND mode changes — the point is
// that moving to FT8 on a band where full power is fine for SSB does not put
// full power into a 100% duty-cycle signal.
//
// Not skipped on a locked band, unlike the antennas: the lock means "do not
// let the rig drag my log entry around", not "let the amplifier take whatever
// the last mode left it at".
const lastPwrRef = useRef('');
useEffect(() => {
if (catState.backend !== 'flex') return;
const b = band.trim();
const m = mode.trim();
if (!b || !m) return;
const key = b + '/' + m;
if (key === lastPwrRef.current) return;
lastPwrRef.current = key;
FlexApplyBandPower(b, m).catch(() => {});
}, [band, mode, catState.backend]);
// Cluster live wiring: hydrate per-server status + saved server list, // Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events. // then subscribe to push events.
async function reloadClusterMeta() { async function reloadClusterMeta() {
@@ -2402,6 +2506,14 @@ export default function App() {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
}); });
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
const total = Number(p?.total ?? 0);
const processed = Number(p?.processed ?? 0);
// The closing event (processed === total) clears the overlay: the work is
// done, and a bar left at 100% would have to be dismissed by hand.
if (total > 0 && processed >= total) { setBulkProgress(null); return; }
setBulkProgress({ op: String(p?.op ?? ''), processed, total, call: String(p?.call ?? '') });
});
const unsubProg = EventsOn('import:progress', (p: any) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
}); });
@@ -2428,7 +2540,7 @@ export default function App() {
const file = String(p?.file ?? '').replace(/^.*[\\/]/, ''); const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n })); showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
}); });
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); }; return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -2935,9 +3047,9 @@ export default function App() {
} }
async function bulkUpdateFromQRZ(ids: number[]) { async function bulkUpdateFromQRZ(ids: number[]) {
if (ids.length === 0) return; if (ids.length === 0) return;
showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}`);
try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); } try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
catch (e: any) { setError(String(e?.message ?? e)); } catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBulkProgress(null); }
} }
async function bulkUpdateFromClublog(ids: number[]) { async function bulkUpdateFromClublog(ids: number[]) {
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -3604,8 +3716,13 @@ export default function App() {
// single-row strip or the full Log4OM-style columnar layout below. Keeping // single-row strip or the full Log4OM-style columnar layout below. Keeping
// them as shared consts avoids duplicating the (large) per-field JSX + // them as shared consts avoids duplicating the (large) per-field JSX +
// handlers across the two layouts. // handlers across the two layouts.
// The callsign field is WIDER than the fields around it, deliberately: it is
// the one field always typed into, it carries the REC badges on its right, and
// a long portable call (VK9/OH1ABC/MM) must stay readable as it is entered.
// The room comes from the RST pair, which never needs more than five
// characters.
const callsignBlock = ( const callsignBlock = (
<div className="flex flex-col w-44" data-esm="call"> <div className="flex flex-col w-56" 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 && (
@@ -3647,18 +3764,83 @@ export default function App() {
DUPE DUPE
</span> </span>
)} )}
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && ( {/* Not recording, but able to: offer a record button in the slot the
counter will occupy. It disappears the moment recording starts, so the
two never fight over the space and it is only offered on modes that
produce audio worth keeping. */}
{!recording && manualRecReady && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<button <button
type="button" type="button"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={resetRecordingClock} onClick={startManualRecording}
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange" title={t('rec.manualStart')}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted" className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center size-4 rounded-full cursor-pointer hover:bg-destructive-muted"
> >
<span className="size-2 rounded-full bg-destructive animate-pulse" /> <span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</button> </button>
)} )}
{/* A stopped take: play it to the station being worked, or carry on
recording. Both sit where the counter is, which has shifted left to
make room. */}
{/* A stopped take: resume it, and on PHONE only play it to the station
being worked. In CW the transmitter builds its tone from the keyer and
ignores the DAX TX audio path entirely, so the button would key the rig
and send silence. The row itself stays, or a CW operator who stopped
would be left with no way to resume. */}
{recording && recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
{PHONE_MODES.has(mode.toUpperCase()) && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={playRecordingOnAir}
title={dvkStat.playing ? t('rec.stopPlaying') : t('rec.playOnAir')}
className={cn('inline-flex items-center justify-center size-4 rounded',
dvkStat.playing ? 'text-destructive hover:bg-destructive-muted' : 'text-success hover:bg-success/15')}
>
{dvkStat.playing ? <span className="size-2 bg-current" /> : '▶'}
</button>
)}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={resumeRecording}
title={t('rec.resume')}
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
>
<span className="size-2 rounded-full bg-destructive" />
</button>
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground whitespace-nowrap">
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</span>
</span>
)}
{/* Stop and the running counter share ONE flex row. They were two absolute
boxes at guessed offsets and overlapped as soon as the counter passed a
minute a layout that only looked right for the first 60 seconds. */}
{recording && !recStopped && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1.5">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => { e.stopPropagation(); stopRecording(); }}
title={t('rec.stop')}
className="inline-flex items-center justify-center size-4 rounded text-muted-foreground hover:bg-muted"
>
<span className="size-2 bg-current" />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={resetRecordingClock}
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
className="inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
>
<span className="size-2 rounded-full bg-destructive animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</button>
</span>
)}
<Input <Input
ref={callsignRef} ref={callsignRef}
// The call field is marked out at REST, not only on focus: operators were // The call field is marked out at REST, not only on focus: operators were
@@ -3666,7 +3848,10 @@ export default function App() {
// exactly the same. A primary-tinted border plus an inset left bar make // exactly the same. A primary-tinted border plus an inset left bar make
// it the obvious one without adding a colour the theme doesn't own. The // it the obvious one without adding a colour the theme doesn't own. The
// contest-dupe state still overrides it — that one is a warning. // contest-dupe state still overrides it — that one is a warning.
// Room on the right for the REC badges while they are shown, so a long
// callsign runs under them instead of being typed over.
className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card', className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
recording && RECORDABLE_MODES.has(mode.toUpperCase()) && 'pr-24',
'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]', 'border-primary/45 shadow-[inset_3px_0_0_0_var(--primary)]',
contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')} contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground shadow-none')}
value={callsign} value={callsign}
@@ -3684,12 +3869,12 @@ export default function App() {
</div> </div>
); );
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-16" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} /> <Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</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-16" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} /> <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div> </div>
); );
@@ -3723,7 +3908,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')} <LockBtn k="start" title="start time" /></Label> <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>
<Input <Input
readOnly={!locks.start} readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1} tabIndex={locks.start ? 0 : -1}
@@ -3742,7 +3927,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')} <LockBtn k="end" title="end time" /></Label> <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>
<Input <Input
readOnly={!locks.end} readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1} tabIndex={locks.end ? 0 : -1}
@@ -3932,7 +4117,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')} <LockBtn k="band" title="band" /></Label> <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('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>
@@ -3943,7 +4128,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')} <LockBtn k="mode" title="mode" /></Label> <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('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>
@@ -3999,7 +4184,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')} <LockBtn k="freq" title="frequency" /></Label> <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>
<Input <Input
tabIndex={-1} tabIndex={-1}
className="font-mono" className="font-mono"
@@ -4109,7 +4294,10 @@ export default function App() {
// entity status is still new-band/new-slot (the grid flags it WKD CALL), // entity status is still new-band/new-slot (the grid flags it WKD CALL),
// matching the "Hide worked" toggle. Additive: it still matches its own // matching the "Hide worked" toggle. Additive: it still matches its own
// entity status too, so it stays visible under NEW BAND / NEW SLOT. // entity status too, so it stays visible under NEW BAND / NEW SLOT.
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked')); const matches = clusterStatusFilter.has(st)
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
if (!matches) return false; if (!matches) return false;
} }
if (clusterHideWorked) { if (clusterHideWorked) {
@@ -4221,10 +4409,14 @@ export default function App() {
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div> <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{([ {([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' }, { k: 'new' as SpotFilterKey, label: 'NEW', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' }, { k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' }, { k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' }, { k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// Same colours as the badges in the grid — a filter that does not
// look like what it selects has to be learned twice.
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.) // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).map((s) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
@@ -6158,7 +6350,14 @@ export default function App() {
<SettingsModal <SettingsModal
initialSection={settingsSection} initialSection={settingsSection}
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }} onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }} onSaved={() => {
loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady();
// Drop the cached spot statuses. They are computed once per
// call+band+mode and never expire, so a rule change in Settings —
// grouping the digital modes into one slot, above all — left every
// spot already on screen showing the answer to the OLD question.
setSpotStatus({});
}}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }} onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
flexAvailable={catState.backend === 'flex'} flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'} icomAvailable={catState.backend === 'icom'}
@@ -6384,6 +6583,35 @@ export default function App() {
</Dialog> </Dialog>
)} )}
{/* Deliberately NOT a modal. A contest log is thousands of QSOs and one
network round trip each, so this runs for ten minutes \u2014 a dialog would
hold the whole application hostage for the duration, and the operator
has a radio to work. It sits in the corner, above everything, and
stays out of the way. */}
{bulkProgress && (
<div className="fixed bottom-3 right-3 z-50 w-64 rounded-lg border border-border bg-card/95 backdrop-blur shadow-lg px-3 py-2 space-y-1.5">
<div className="text-xs font-medium">{t('bulk.progressTitle')}</div>
{(() => {
const { processed, total, call } = bulkProgress;
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
return (
<>
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full bg-primary rounded-full transition-[width] duration-150', total === 0 && 'w-1/3 animate-pulse')}
style={total > 0 ? { width: `${pct}%` } : undefined}
/>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span className="font-mono">{call || '\u00a0'}</span>
<span className="tabular-nums">{total > 0 ? `${processed} / ${total}` : ''}</span>
</div>
</>
);
})()}
</div>
)}
{importing && ( {importing && (
<Dialog open> <Dialog open>
<DialogContent className="max-w-sm px-6" hideClose> <DialogContent className="max-w-sm px-6" hideClose>
+86 -6
View File
@@ -8,12 +8,15 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor'; import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
type BandCount = { band: string; worked: number; confirmed: number }; type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = { type AwardRef = {
ref: string; name?: string; group?: string; subgrp?: string; ref: string; name?: string; group?: string; subgrp?: string;
worked: boolean; confirmed: boolean; validated: boolean; worked: boolean; confirmed: boolean; validated: boolean;
bands: string[]; confirmed_bands: string[]; validated_bands: string[]; bands: string[]; confirmed_bands: string[]; validated_bands: string[];
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
modes?: string[]; confirmed_modes?: string[];
}; };
type AwardResult = { type AwardResult = {
code: string; name: string; dimension: string; code: string; name: string; dimension: string;
@@ -74,6 +77,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid'); const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all'); const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
// confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet.
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null); const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
const [showMissing, setShowMissing] = useState(false); const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null); const [stats, setStats] = useState<AwardStats | null>(null);
@@ -191,17 +198,62 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
return idx.length ? idx : all; return idx.length ? idx : all;
}, [stats, awardBands]); }, [stats, awardBands]);
// Sort order for the reference table. Reference is the default because that
// is how award lists are published and how an operator reads a paper list;
// description answers the other question — "have I worked anything in
// Alicante" — which reference order scatters across the whole table.
// Remembered through writeUiPref rather than localStorage, so the choice
// travels with a copied data/ folder like every other UI preference here.
const [refSort, setRefSort] = useState<'ref' | 'name'>(
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
const toggleRefSort = (k: 'ref' | 'name') => {
if (k === refSort) {
const d = refSortDir === 'asc' ? 'desc' : 'asc';
setRefSortDir(d);
writeUiPref('opslog.awardRefSortDir', d);
} else {
setRefSort(k);
setRefSortDir('asc');
writeUiPref('opslog.awardRefSort', k);
writeUiPref('opslog.awardRefSortDir', 'asc');
}
};
const filteredRefs = useMemo(() => { const filteredRefs = useMemo(() => {
if (!current) return []; if (!current) return [];
const q = refSearch.trim().toUpperCase(); const q = refSearch.trim().toUpperCase();
return (current.refs ?? []).filter((r) => { const rows = (current.refs ?? []).filter((r) => {
if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
if (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying
// the list.
if (!(r.modes ?? []).includes(modeFilter)) return false;
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
// on CW and confirmed on SSB is still a CW entity to chase, and the
// whole point of the filter is to find those.
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
}
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false; if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
return true; return true;
}); });
}, [current, refSearch, refFilter]); const dir = refSortDir === 'asc' ? 1 : -1;
return rows.sort((a, b) => {
if (refSort === 'name') {
// localeCompare, not a byte comparison: these lists are Spanish, French
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
// than after "Zaragoza".
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
return ( return (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
@@ -325,6 +377,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
</button> </button>
))} ))}
</div> </div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setModeFilter(k)}
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span> <span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
<button <button
onClick={() => setShowMissing(true)} onClick={() => setShowMissing(true)}
@@ -386,8 +446,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}> <table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10"> <thead className="sticky top-0 z-10">
<tr className="bg-card"> <tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th> <th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th> <button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.ref')}
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.description')}
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{gridBands.map((b) => ( {gridBands.map((b) => (
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th> <th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))} ))}
@@ -424,8 +494,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 bg-card"> <thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border"> <tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th> <th className="py-1.5 pr-2 font-medium w-24">
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th> <button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.ref')}
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.name')}
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th> <th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th> <th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th> <th className="py-1.5 font-medium">{t('awp.bands')}</th>
+96
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead'; import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
@@ -115,6 +116,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// pans/zooms freely (e.g. a whole-world view) and the view is remembered // pans/zooms freely (e.g. a whole-world view) and the view is remembered
// across restarts. Default on. // across restarts. Default on.
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0'); const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
// Grey line — the day/night terminator with its twilight band. Off by
// default: it is a working tool for the operator who wants it, not decoration
// for the one who does not.
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
const greylineLayer = useRef<L.LayerGroup | null>(null);
const autoZoomRef = useRef(autoZoom); const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]); useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
@@ -152,6 +159,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
if (m) addBasemap(m, basemap, baseLayer, labelsLayer); if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
}, [basemap]); }, [basemap]);
// Draw the grey line, and keep it moving.
//
// Redrawn every minute: the terminator travels a quarter of a degree in that
// time, which is invisible, but a map left on all day would otherwise be
// silently wrong by evening — and the whole point of the display is knowing
// where the line is NOW.
//
// It lives in its own pane below the overlay pane so the path and beam are
// never hidden behind the shading, and is non-interactive so it can never
// swallow a click meant for the map.
useEffect(() => {
const m = worldMap.current;
if (!m) return;
if (!greyline) {
greylineLayer.current?.remove();
greylineLayer.current = null;
return;
}
if (!m.getPane('greyline')) {
const pane = m.createPane('greyline');
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
pane.style.pointerEvents = 'none';
}
const draw = () => {
const now = new Date();
greylineLayer.current?.remove();
const g = L.layerGroup([], { pane: 'greyline' });
// The rings span 180…+180 once, but this map repeats the world sideways
// (worldCopyJump, and zoomed out several copies are on screen at once). A
// single ring therefore ends in a hard vertical edge wherever a copy
// begins — the shading looked like a rectangle laid over the planet.
//
// So each ring is drawn again shifted a full turn either way. Three copies
// cover every zoom level this map reaches; they cost nothing, being three
// polygons on a canvas layer.
const COPIES = [-360, 0, 360];
const shifted = (ring: [number, number][], by: number): [number, number][] =>
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
// Two bands, drawn dark-to-light so they read as one gradient into night:
// full darkness (Sun below the horizon) and civil twilight (down to 6°),
// which is the band operators actually mean by "grey line".
const nightRing = nightPolygon(now, 0);
const twilightRing = nightPolygon(now, -6);
// The terminator itself, so the line is readable at a glance rather than
// being inferred from where the shading fades. Dropping the last two
// points leaves the terminator alone, without the segment that closes the
// polygon along the dark pole.
const lineRing = nightRing.slice(0, -2);
for (const by of COPIES) {
L.polygon(shifted(twilightRing, by), {
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
}).addTo(g);
L.polygon(shifted(nightRing, by), {
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
}).addTo(g);
L.polyline(shifted(lineRing, by), {
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
}).addTo(g);
}
g.addTo(m);
greylineLayer.current = g;
};
draw();
const id = window.setInterval(draw, 60_000);
return () => { window.clearInterval(id); };
}, [greyline]);
// Redraw overlays whenever the operator/DX grids (or beam) change. // Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => { useEffect(() => {
const wm = worldMap.current, wo = worldOverlay.current; const wm = worldMap.current, wo = worldOverlay.current;
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
> >
Zoom DX Zoom DX
</button> </button>
{/* Grey line toggle, under the zoom button. */}
<button
type="button"
onClick={() => {
const v = !greyline;
setGreyline(v);
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
}}
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Grey line
</button>
{path && ( {path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none"> <div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km <div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
+36 -6
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react'; import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -38,6 +38,12 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
// which used to dismiss the menu the instant it appeared.) // which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) { export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const boxRef = useRef<HTMLDivElement>(null);
// Starts at the cursor; the layout effect corrects it once the real size is
// known. Rendering at the cursor first avoids a visible jump for the common
// case where it already fits.
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
const close = () => onClose(); const close = () => onClose();
@@ -50,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
}; };
}, [menu, onClose]); }, [menu, onClose]);
// Position AFTER measuring.
//
// The menu used to be clamped against a guessed height — 230 px when the
// upload submenu was present, 110 otherwise. It is far taller than that with
// a selection, so right-clicking near the bottom of the window cut half the
// entries off. Its height also depends on which actions the caller passes, so
// no constant can be right for long.
//
// Measured instead: if it does not fit below the cursor it is placed above,
// and failing that pinned to the top with its own scrollbar.
useLayoutEffect(() => {
if (!menu || !boxRef.current) return;
const r = boxRef.current.getBoundingClientRect();
const pad = 6;
let x = menu.x;
let y = menu.y;
if (x + r.width > window.innerWidth - pad) {
x = Math.max(pad, window.innerWidth - r.width - pad);
}
if (y + r.height > window.innerHeight - pad) {
// Above the cursor, if there is room there.
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
}
setPos({ x, y });
}, [menu, onSendTo]);
if (!menu) return null; if (!menu) return null;
const n = menu.ids.length; const n = menu.ids.length;
// Keep the menu on-screen near the cursor.
const x = Math.min(menu.x, window.innerWidth - 248);
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
return ( return (
<div <div
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm" ref={boxRef}
style={{ left: x, top: y }} className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: pos.x, top: pos.y }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground"> <div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
+23 -27
View File
@@ -4,6 +4,8 @@ import {
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme'; import { hamlogGridTheme } from '@/lib/gridTheme';
import { qslStatusCellClass } from '@/lib/qslStatus';
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react'; import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
@@ -85,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`; return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
} }
// Both formatters follow the display preference (Settings → General). What is
// STORED never changes — see lib/dateFormat.
function fmtDateUTC(s: any): string { function fmtDateUTC(s: any): string {
if (!s) return ''; return formatDateTimeUTC(s);
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
} }
function fmtDateOnly(s: any): string { function fmtDateOnly(s: any): string {
if (!s) return ''; return formatDateOnly(s);
const t = String(s).trim();
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(t);
if (isNaN(d.getTime())) return t;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
// Full catalog of selectable columns, grouped for the picker. `defaultVisible` // Full catalog of selectable columns, grouped for the picker. `defaultVisible`
@@ -178,8 +170,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 }, { group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
// ── QSL ── // ── QSL ──
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 }, { group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 }, { group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 }, { group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
@@ -187,32 +179,32 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 }, { group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
// ── LoTW ── // ── LoTW ──
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 }, { group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 }, { group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── eQSL ── // ── eQSL ──
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 }, { group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 }, { group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, { group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: when the QSO's audio recording was e-mailed to the station. // App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ── // ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and, // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
// for QRZ only, a "download status/date" (= it came back confirmed). We // for QRZ only, a "download status/date" (= it came back confirmed). We
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have // relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown. // NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 }, { group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
@@ -303,7 +295,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]); // Rebuild the columns when the format changes: the formatters are captured
// inside the column definitions, so nothing else would notice.
const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
// Right-click: if the clicked row isn't already part of the selection, // Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection. // select just it; then open the bulk-action menu on the whole selection.
+162 -58
View File
@@ -16,7 +16,7 @@ import {
GetTunerGeniusSettings, SaveTunerGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate, GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted, GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
GetSecretStatus, SetPassphrase, RemovePassphrase, GetSecretStatus, SetPassphrase, RemovePassphrase,
@@ -37,6 +37,8 @@ import {
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
SetWinkeyerTrace, SetWinkeyerTrace,
SetCIVTrace, SetCIVTrace,
CIVTraceEnabled,
WinkeyerTraceEnabled,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
@@ -45,7 +47,7 @@ import {
DownloadULSCounties, ULSStatus, BackfillUSCounties, DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -67,6 +69,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme'; import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel'; import { OperatingPanel } from '@/components/OperatingPanel';
@@ -950,81 +953,121 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
); );
} }
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically // FlexBandPanel — everything that follows the band, in ONE row per band:
// when the band changes (frequency change / spot click). Antennas come live from // antennas and TX power.
// the connected FlexRadio; bands come from Lists → Bands. //
function FlexBandAntennasPanel({ bands }: { bands: string[] }) { // They were two tables listing the same bands one under the other, which made
// the operator match rows by eye between them. The two settings are stored and
// applied separately (antennas on band change, power on band or mode change) —
// that is a backend detail, and no reason to split what is one decision per
// band for the person configuring it.
function FlexBandPanel({ bands }: { bands: string[] }) {
const { t } = useI18n();
const [rxList, setRxList] = useState<string[]>([]); const [rxList, setRxList] = useState<string[]>([]);
const [txList, setTxList] = useState<string[]>([]); const [txList, setTxList] = useState<string[]>([]);
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({}); const [ant, setAnt] = useState<Record<string, { rx: string; tx: string }>>({});
const [pwr, setPwr] = useState<Record<string, { phone: number; cw: number; digi: number }>>({});
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
useEffect(() => { useEffect(() => {
GetFlexState().then((s: any) => { GetFlexState().then((s: any) => {
setRxList((s?.ant_list ?? []) as string[]); setRxList((s?.ant_list ?? []) as string[]);
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]); setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
}).catch(() => {}); }).catch(() => {});
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {}); GetFlexBandAntennas().then((m: any) => setAnt(m ?? {})).catch(() => {});
GetFlexBandPower().then((m: any) => setPwr(m ?? {})).catch(() => {});
}, []); }, []);
const set = (band: string, side: 'rx' | 'tx', v: string) => {
const saved = () => { setMsg(t('flxpw.saved')); window.setTimeout(() => setMsg(''), 1200); };
const setAntenna = (band: string, side: 'rx' | 'tx', v: string) => {
const key = band.toUpperCase(); const key = band.toUpperCase();
setMap((m) => { setAnt((m) => {
const cur = m[key] ?? { rx: '', tx: '' }; const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
const next = { ...m, [key]: { ...cur, [side]: v } }; SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
SaveFlexBandAntennas(next as any)
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
.catch((e: any) => setMsg(String(e?.message ?? e)));
return next; return next;
}); });
}; };
const setPower = (band: string, kind: 'phone' | 'cw' | 'digi', v: string) => {
const key = band.toUpperCase();
const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
setPwr((m) => {
const next = { ...m, [key]: { ...(m[key] ?? { phone: 0, cw: 0, digi: 0 }), [kind]: n } };
SaveFlexBandPower(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
return next;
});
};
const antCell = (b: string, side: 'rx' | 'tx', list: string[]) => {
const e = ant[b.toUpperCase()] ?? { rx: '', tx: '' };
return (
<td className="px-2 py-1.5">
<select value={e[side] ?? ''} onChange={(ev) => setAntenna(b, side, ev.target.value)}
className="h-8 w-28 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value=""> none </option>
{list.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
);
};
// The rule marking where band-driven settings end and mode-driven ones begin
// is drawn on the FIRST power cell, header and body alike. It used to be an
// extra empty cell in the body only, which gave the two rows different column
// counts and slid every heading one column left.
const pwrCell = (b: string, kind: 'phone' | 'cw' | 'digi') => {
const e = pwr[b.toUpperCase()] ?? { phone: 0, cw: 0, digi: 0 };
return (
<td className={cn('px-2 py-1.5', kind === 'phone' && 'border-l border-border/60')}>
<Input
type="number" min={0} max={100}
className="h-8 w-16 text-xs"
value={e[kind] ? String(e[kind]) : ''}
placeholder="—"
onChange={(ev) => setPower(b, kind, ev.target.value)}
/>
</td>
);
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<h3 className="text-base font-semibold text-foreground">FlexRadio per-band antennas</h3> <h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
Choose the RX and TX antenna for each band. They're applied automatically when the band
changes (frequency change or clicking a spot).
</p>
</div> </div>
{rxList.length === 0 && ( {rxList.length === 0 && (
<div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-xl"> <div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
No antennas reported yet make sure the FlexRadio is connected (CAT interface), then reopen this panel. {t('flxb.noRadio')}
</div> </div>
)} )}
{bands.length === 0 ? ( {bands.length === 0 ? (
<p className="text-xs text-muted-foreground">No bands configured add them in Lists Bands.</p> <p className="text-xs text-muted-foreground">{t('flxpw.noBands')}</p>
) : ( ) : (
<div className="rounded-md border border-border overflow-hidden max-w-xl"> <div className="rounded-md border border-border overflow-x-auto max-w-3xl">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/40 text-muted-foreground text-xs"> <thead className="bg-muted/40 text-muted-foreground text-xs">
<tr> <tr>
<th className="text-left px-3 py-1.5 font-semibold">Band</th> <th className="text-left px-3 py-1.5 font-semibold">{t('flxpw.band')}</th>
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th> <th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th> <th className="text-left px-2 py-1.5 font-semibold">{t('flxb.txAnt')}</th>
<th className="text-left px-2 py-1.5 font-semibold border-l border-border/60">{t('flxpw.phone')}</th>
<th className="text-left px-2 py-1.5 font-semibold">CW</th>
<th className="text-left px-2 py-1.5 font-semibold">{t('flxpw.digi')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{bands.map((b) => { {bands.map((b) => (
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' }; <tr key={b} className="border-t border-border/50">
return ( <td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
<tr key={b} className="border-t border-border/50"> {antCell(b, 'rx', rxList)}
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td> {antCell(b, 'tx', txList)}
<td className="px-3 py-1.5"> {pwrCell(b, 'phone')}
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)} {pwrCell(b, 'cw')}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono"> {pwrCell(b, 'digi')}
<option value=""> none </option> </tr>
{rxList.map((a) => <option key={a} value={a}>{a}</option>)} ))}
</select>
</td>
<td className="px-3 py-1.5">
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value=""> none </option>
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1093,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
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, 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,
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
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, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -1136,8 +1179,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}); });
const [wkPorts, setWkPorts] = useState<string[]>([]); const [wkPorts, setWkPorts] = useState<string[]>([]);
// Session-only: the byte trace is a diagnostic, never a saved preference. // Session-only: the byte trace is a diagnostic, never a saved preference.
//
// But it must still SHOW its real state. These start false on every mount, so
// a trace already running came back unticked; the operator ticks the box to
// turn it on, which turns it off, and the log they send has no trace in it.
// Reported by a Xiegu user. Hydrated from the backend below.
const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
const [wkTrace, setWkTrace] = useState(false); const [wkTrace, setWkTrace] = useState(false);
const [civTrace, setCivTrace] = useState(false); const [civTrace, setCivTrace] = useState(false);
useEffect(() => {
CIVTraceEnabled().then((v) => setCivTrace(!!v)).catch(() => {});
WinkeyerTraceEnabled().then((v) => setWkTrace(!!v)).catch(() => {});
}, []);
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch })); const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
// ── Audio (DVK + QSO recorder) ── // ── Audio (DVK + QSO recorder) ──
@@ -1145,13 +1198,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from_radio: string; to_radio: string; recording_device: string; listening_device: string; from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number; qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3'; ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number; tx_gain: number; from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
}; };
type AudioDev = { id: string; name: string; default: boolean }; type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({ const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '', from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav', qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100, tx_gain: 100, from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
}); });
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]); const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]); const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -1276,7 +1329,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
upload_flags: string[]; write_log: boolean; upload_flags: string[]; write_log: boolean;
auto_upload: boolean; upload_mode: string; auto_upload: boolean; upload_mode: string;
}; };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean };
const emptyExtCfg = (): ExtServiceCfg => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
@@ -1284,7 +1337,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
auto_upload: false, upload_mode: 'immediate', auto_upload: false, upload_mode: 'immediate',
}); });
const [extSvc, setExtSvc] = useState<ExternalServices>({ const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false,
}); });
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false); const [qrzTesting, setQrzTesting] = useState(false);
@@ -2516,7 +2569,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</> </>
)} )}
{(catCfg.backend === 'icom' || catCfg.backend === 'xiegu') && ( {['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
<div className="border-t border-border/60 pt-3"> <div className="border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} /> <Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
@@ -2552,6 +2605,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span> <span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
</div> </div>
<div className="space-y-1">
<Label>{t('cat.kenwoodHost')}</Label>
<Input
value={catCfg.kenwood_host || ''}
placeholder="192.168.1.50:4532"
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
</div>
</> </>
)} )}
{catCfg.backend === 'icom' && ( {catCfg.backend === 'icom' && (
@@ -4203,6 +4265,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<ProfileScopeNote profile={activeProfileObj} /> <ProfileScopeNote profile={activeProfileObj} />
{/* Applies to BOTH services, so it sits above the tab strip rather than
inside one tab where it would look like a QRZ-only setting (and be
invisible to anyone who only opens the Club Log tab). */}
<label className="flex items-start gap-2 text-sm cursor-pointer mb-4 max-w-2xl">
<Checkbox
checked={!!extSvc.delete_remote}
onCheckedChange={(c) => setExtSvc((v) => ({ ...v, delete_remote: !!c }))}
className="mt-0.5"
/>
<span>
{t('es.deleteRemote')}
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.deleteRemoteHint')}</span>
</span>
</label>
{/* Tab strip */} {/* Tab strip */}
<div className="flex flex-wrap gap-1 border-b border-border mb-4"> <div className="flex flex-wrap gap-1 border-b border-border mb-4">
{TABS.map((tab) => ( {TABS.map((tab) => (
@@ -5038,13 +5115,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('aud.fromLevel')}</Label> <Label className="text-sm">{t('aud.fromLevel')}</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain} <input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" /> onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span> <span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div> </div>
<Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
</div>
<Label className="text-sm">{t('aud.micLevel')}</Label> <Label className="text-sm">{t('aud.micLevel')}</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain} <input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" /> onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span> <span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div> </div>
</div> </div>
@@ -5187,6 +5270,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</div> </div>
{/* Display only. The log is stored in ISO/ADIF whatever is chosen here
that is what ADIF specifies and what sorts correctly, and a
stored format that followed a preference would make the log
unreadable the day the preference changed. */}
<div className="flex items-center gap-3">
<Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
['fr', t('gen.dateFR'), '30-07-2026 14:25'],
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
<button key={code} type="button" title={sample}
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{label}
<span className="text-[10px] font-mono opacity-70">{sample}</span>
</button>
))}
</div>
</div>
<ThemeSelector /> <ThemeSelector />
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -5533,7 +5637,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
antgenius: AntGeniusPanelSettings, antgenius: AntGeniusPanelSettings,
tunergenius: TunerGeniusPanelSettings, tunergenius: TunerGeniusPanelSettings,
pgxl: PGXLPanelSettings, pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />, flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
audio: AudioPanel, audio: AudioPanel,
}; };
+4 -1
View File
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types'; import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid'; import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu'; import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs'; import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid). // Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]); const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
+91
View File
@@ -0,0 +1,91 @@
// How dates are DISPLAYED. Nothing here touches what is stored.
//
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
// upload. This is the reading layer only, because an operator reading
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
// and that is a display problem, not a data one.
//
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
// it is what ADIF specifies, and a log whose stored format followed a UI
// preference would become unreadable the day the preference changed.
import { writeUiPref } from '@/lib/uiPref';
export type DateFormat = 'iso' | 'fr' | 'us';
const KEY = 'opslog.dateFormat';
function read(): DateFormat {
const v = localStorage.getItem(KEY);
return v === 'fr' || v === 'us' ? v : 'iso';
}
let current: DateFormat = read();
// A minimal store so every grid and panel re-renders the moment the format
// changes, rather than showing the old one until the next restart.
const listeners = new Set<() => void>();
export function getDateFormat(): DateFormat {
return current;
}
export function setDateFormat(f: DateFormat): void {
current = f;
writeUiPref(KEY, f);
listeners.forEach((l) => l());
}
export function subscribeDateFormat(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// reloadDateFormat re-reads the stored value — for after the portable prefs
// have been synced from the database at startup, which happens AFTER this
// module's first read.
export function reloadDateFormat(): void {
const v = read();
if (v !== current) {
current = v;
listeners.forEach((l) => l());
}
}
const pad = (n: number) => String(n).padStart(2, '0');
// order arranges the three parts. Separators stay '-' in every format: a slash
// in the French one would be conventional but it also invites the reader to
// take it for a US date, which is exactly the confusion being fixed.
function order(y: number, m: number, d: number): string {
switch (current) {
case 'fr':
return `${pad(d)}-${pad(m)}-${y}`;
case 'us':
return `${pad(m)}-${pad(d)}-${y}`;
}
return `${y}-${pad(m)}-${pad(d)}`;
}
// formatDateTimeUTC renders a timestamp with the time, in UTC.
//
// UTC is not negotiable and is not part of the preference: a logbook is kept in
// UTC, and a date shown in local time would disagree with the QSO's own record
// twice a year.
export function formatDateTimeUTC(s: unknown): string {
if (!s) return '';
const d = new Date(String(s));
if (isNaN(d.getTime())) return String(s);
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
// formatDateOnly renders a date with no time. It accepts both the ADIF form
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
export function formatDateOnly(s: unknown): string {
if (!s) return '';
const t = String(s).trim();
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
const d = new Date(t);
if (isNaN(d.getTime())) return t;
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
+130
View File
@@ -0,0 +1,130 @@
// Grey line — the day/night terminator, and the twilight band around it where
// HF propagation briefly does things it does at no other time.
//
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
// used by NOAA's solar calculator): good to well under a degree, which is far
// finer than the band being drawn. No ephemeris, no network, no dependency —
// this has to work in a field shack with no internet.
//
// Everything here is pure so it can be tested against known positions; the
// drawing lives in the map component.
const DEG = Math.PI / 180;
// julianDay for a date, including the fraction of the day.
export function julianDay(d: Date): number {
return d.getTime() / 86400000 + 2440587.5;
}
// Sun's declination and Greenwich hour angle, both in degrees.
//
// Returned together because they are computed from the same intermediates and
// every caller needs both — splitting them into two exported functions would
// double the work at each call for the sake of a tidier signature.
export function sunPosition(date: Date): { dec: number; gha: number } {
const jd = julianDay(date);
const n = jd - 2451545.0; // days from J2000.0
// Mean longitude and mean anomaly of the Sun.
const L = (280.460 + 0.9856474 * n) % 360;
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
// Ecliptic longitude: the equation of centre applied to the mean longitude.
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
// Obliquity of the ecliptic, slowly decreasing.
const eps = (23.439 - 0.0000004 * n) * DEG;
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
// Right ascension, kept in the same quadrant as the ecliptic longitude —
// atan2 alone lands 180° out for half the year, which mirrors the whole
// terminator about the prime meridian. Six months of a plausible-looking
// wrong answer is exactly the kind of bug that survives review.
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
if (ra < 0) ra += 360;
// Greenwich mean sidereal time, in degrees.
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
let gha = gmst - ra;
gha = ((gha % 360) + 360) % 360;
return { dec, gha };
}
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
// sits at `altitude` degrees above (or below) the horizon.
//
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
// of the grey line as operators use the term.
//
// Returns null where no such latitude exists at that longitude — inside the
// polar day or polar night, where the Sun never crosses that altitude at all.
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
const ha = (gha + lonDeg) * DEG; // local hour angle
const d = dec * DEG;
const h = altitude * DEG;
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
//
// That has TWO solutions per meridian, and picking the wrong one is not a
// small error — it mirrors the answer about the equator. New York at 02:00
// local in December came out as daylight because the branch chosen put the
// terminator at +63° where it belongs at 63°: a shaded map that looks
// entirely plausible and is exactly wrong.
//
// So anchor on the geometric terminator, where the answer is unambiguous and
// classical, and take whichever branch is nearest it. At altitude 0 that
// reproduces it exactly; for the twilight band it follows it by continuity,
// which is the physical requirement — the twilight curve is a small offset
// from the terminator, never on the other side of the planet.
const A = Math.sin(d);
const B = Math.cos(d) * Math.cos(ha);
const R = Math.hypot(A, B);
if (R === 0) return null;
const s = Math.sin(h) / R;
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
// The classical terminator. Division by zero is deliberate and correct here:
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
// exactly what atan(±Infinity) = ±90° expresses.
const t = Math.tan(d);
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
const fold = (x: number) => {
let v = ((x + 180) % 360 + 360) % 360 - 180;
if (v > 90) v = 180 - v;
if (v < -90) v = -180 - v;
return v;
};
const c1 = fold((Math.asin(s) - phi) / DEG);
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
}
// nightPolygon returns a ring covering the part of the world where the Sun is
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
//
// The ring runs along the terminator from west to east and closes along
// whichever pole is in darkness — which pole that is flips with the season, and
// getting it wrong shades the lit half of the planet.
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
const { dec, gha } = sunPosition(date);
const ring: [number, number][] = [];
for (let lon = -180; lon <= 180; lon += stepDeg) {
const lat = terminatorLatitude(lon, dec, gha, altitude);
// Inside a polar day/night there is no crossing; clamp to the pole so the
// ring stays closed rather than tearing open across the map.
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
}
// Northern summer (positive declination) → the SOUTH pole is dark.
const pole = dec > 0 ? -90 : 90;
ring.push([pole, 180]);
ring.push([pole, -180]);
return ring;
}
+16 -16
View File
@@ -63,7 +63,7 @@ const en: Dict = {
// Language chooser // Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', 'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.', 'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.', 'settings.language': 'Language', 'gen.dateFormat': 'Date display', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'French', 'gen.dateUS': 'US', 'settings.languageHint': 'Interface language.',
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…', 'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh', 'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities', 'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
@@ -135,8 +135,8 @@ const en: Dict = {
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.", 'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
'imp.stationTitle': 'Fill my station fields from my profile', 'imp.stationTitle': 'Fill my station fields from my profile',
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.", 'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
'imp.cancel': 'Cancel', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import', 'imp.cancel': 'Cancel', 'rec.manualStart': 'Record this contact. Automatic recording is off, so capture starts now \u2014 there is no pre-roll of what came before.', 'rec.stop': 'Stop the recording. The audio is kept and still saved with the QSO \u2014 stop it to play it back to the station you are working.', 'rec.resume': 'Carry on recording, appending to what is already captured.', 'rec.playOnAir': 'TRANSMIT the recording to the station you are working: keys the radio and sends it like a voice-keyer message.', 'rec.stopPlaying': 'Stop transmitting the recording', 'rec.manualFailed': 'Recording could not be started \u2014 check the audio devices in Settings.', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
'imp.progressTitle': 'Importing ADIF…', 'imp.progressTitle': 'Importing ADIF…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…', 'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
'imp.complete': 'Import complete.', 'imp.complete': 'Import complete.',
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total', 'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
@@ -277,7 +277,7 @@ const en: Dict = {
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).', 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', 'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body // CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (native CI-V)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (native CAT)', 'cat.optKenwood': 'Kenwood (native)', 'cat.civTrace': 'Log the CI-V protocol', 'cat.civTraceHint': 'Writes every CI-V frame to and from the radio into the log, as hex. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.", 'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)', 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -298,7 +298,7 @@ const en: Dict = {
'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.', 'es.usernameCall': 'Username (callsign)', 'es.eqslUserPh': 'your eQSL.cc login (callsign)', 'es.eqslPwPh': 'eQSL.cc account password', 'es.qthNick': 'QTH nickname', 'es.qthNickPh': 'optional — required only if your eQSL account has several QTHs', 'es.eqslHint': 'The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.',
'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)', 'es.lotwUser': 'LoTW user', 'es.lotwUserPh': 'LoTW website login (for downloading confirmations)', 'es.lotwPw': 'LoTW password', 'es.lotwPwPh': 'LoTW website password', 'es.tqslPath': 'TQSL path', 'es.stationLoc': 'Station location', 'es.pickLoc': '— pick a TQSL location —', 'es.noLoc': 'No TQSL locations found', 'es.reloadLoc': 'Reload locations from TQSL', 'es.lotwForcePh': "e.g. F4BPO/P — leave blank to use the QSO's own call", 'es.lotwForceHint': 'Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls (F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.', 'es.keyPw': 'Key password', 'es.keyPwPh': 'only if your certificate key has a password', 'es.considerUnsent': 'Consider as unsent', 'es.flagN': 'No (N)', 'es.flagR': 'Requested (R)', 'es.lotwUnsentHint': "At app close, every QSO whose LoTW sent status is one of these is signed and uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become Y and won't be re-sent. Must include your default sent status from Confirmations.", 'es.lotwAutoClose': 'Automatic upload on application close', 'es.lotwWriteLog': 'Write TQSL diagnostic log (-t)',
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).', 'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.", 'es.soon': 'soon', 'es.deleteRemote': 'Also delete from QRZ.com and Club Log', 'es.deleteRemoteHint': 'When you delete a QSO here, withdraw it from those services too. QRZ.com can only delete records OpsLog uploaded itself \u2014 older ones must be removed on the website.', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 14 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.', 'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 14 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.', 'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)', 'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
@@ -370,7 +370,7 @@ const en: Dict = {
// Awards (ref picker / ref selector / awards panel / award editor) // Awards (ref picker / ref selector / awards panel / award editor)
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.', 'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.', 'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine', 'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
@@ -398,7 +398,7 @@ const en: Dict = {
'awed.importReplace': 'Replace mine', 'awed.importReplace': 'Replace mine',
'awed.importCopy': 'Import as {code}-2', 'awed.importCopy': 'Import as {code}-2',
// QSO modals (context menu / bulk edit / QSL manager / QSO edit) // QSO modals (context menu / bulk edit / QSL manager / QSO edit)
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…', 'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from the callsign databases', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportSelectedFields': 'Export selected — choose fields… ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.', 'exp.title': 'Export to ADIF', 'exp.desc': 'Choose which fields to write.',
'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).', 'exp.stdTitle': 'Standard ADIF fields', 'exp.stdDesc': 'Official ADIF 3.1.7 fields only — best for uploading to other logbooks (LoTW, QRZ, Club Log…).',
'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.', 'exp.allTitle': 'All OpsLog fields', 'exp.allDesc': 'Everything, including OpsLog-specific and app-defined tags — a full backup you can re-import here.',
@@ -424,7 +424,7 @@ const en: Dict = {
'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).', 'aud.txOn': 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.', 'aud.txHint': 'Live mic → rig with PTT (USB now; network TX later).',
'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…', 'aud.recorder': 'QSO recorder', 'aud.recordEvery': 'Record every QSO to an audio file (From Radio + your mic)', 'aud.recFolder': 'Recordings folder', 'aud.browse': 'Browse…',
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)', 'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.', 'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO', 'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
'aud.dvkTitle': 'Voice keyer messages (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR', 'aud.dvkTitle': 'Voice keyer messages (F1F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh', 'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
@@ -475,7 +475,7 @@ const fr: Dict = {
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.', 'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.', 'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour', 'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", 'settings.language': 'Langue', 'gen.dateFormat': 'Affichage des dates', 'gen.dateStandard': 'Standard', 'gen.dateFR': 'Fran\u00e7ais', 'gen.dateUS': 'US', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…', 'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir', 'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités', 'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
@@ -546,8 +546,8 @@ const fr: Dict = {
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.", 'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
'imp.stationTitle': 'Remplir mes champs station depuis mon profil', 'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.", 'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
'imp.cancel': 'Annuler', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer', 'imp.cancel': 'Annuler', 'rec.manualStart': 'Enregistrer ce contact. L\u2019enregistrement automatique est d\u00e9sactiv\u00e9 : la capture commence maintenant, sans les secondes qui pr\u00e9c\u00e8dent.', 'rec.stop': 'Arr\u00eater l\u2019enregistrement. L\u2019audio est conserv\u00e9 et sera enregistr\u00e9 avec le QSO \u2014 arr\u00eatez-le pour le repasser \u00e0 la station travaill\u00e9e.', 'rec.resume': 'Reprendre l\u2019enregistrement, \u00e0 la suite de ce qui est d\u00e9j\u00e0 captur\u00e9.', 'rec.playOnAir': '\u00c9METTRE l\u2019enregistrement vers la station travaill\u00e9e : passe la radio en \u00e9mission et l\u2019envoie comme un message du manipulateur vocal.', 'rec.stopPlaying': 'Arrêter l’émission de lenregistrement', 'rec.manualFailed': 'L\u2019enregistrement n\u2019a pas pu d\u00e9marrer \u2014 v\u00e9rifiez les p\u00e9riph\u00e9riques audio dans les R\u00e9glages.', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
'imp.progressTitle': 'Import ADIF en cours…', 'imp.progressTitle': 'Import ADIF en cours…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…', 'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
'imp.complete': 'Import terminé.', 'imp.complete': 'Import terminé.',
'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total', 'imp.imported': '{n} importé(s)', 'imp.updated': '{n} mis à jour', 'imp.duplicates': '{n} doublon(s)', 'imp.skipped': '{n} ignoré(s)', 'imp.total': '{n} au total',
@@ -678,7 +678,7 @@ const fr: Dict = {
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', 'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (CI-V natif)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (CAT natif)', 'cat.optKenwood': 'Kenwood (natif)', 'cat.civTrace': 'Journaliser le protocole CI-V', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CI-V \u00e9chang\u00e9e avec la radio, en hexad\u00e9cimal. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)', 'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
@@ -698,7 +698,7 @@ const fr: Dict = {
'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.", 'es.usernameCall': 'Identifiant (indicatif)', 'es.eqslUserPh': 'ton identifiant eQSL.cc (indicatif)', 'es.eqslPwPh': 'mot de passe du compte eQSL.cc', 'es.qthNick': 'Surnom QTH', 'es.qthNickPh': 'optionnel — requis seulement si ton compte eQSL a plusieurs QTH', 'es.eqslHint': "Le surnom QTH indique à eQSL sous quel profil de lieu classer le QSO. Laisse vide si tu n'en as qu'un.",
'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)', 'es.lotwUser': 'Utilisateur LoTW', 'es.lotwUserPh': 'identifiant du site LoTW (pour télécharger les confirmations)', 'es.lotwPw': 'Mot de passe LoTW', 'es.lotwPwPh': 'mot de passe du site LoTW', 'es.tqslPath': 'Chemin TQSL', 'es.stationLoc': 'Station location', 'es.pickLoc': '— choisir une Station Location TQSL —', 'es.noLoc': 'Aucune Station Location TQSL trouvée', 'es.reloadLoc': 'Recharger les locations depuis TQSL', 'es.lotwForcePh': "p. ex. F4BPO/P — laisse vide pour utiliser l'indicatif du QSO", 'es.lotwForceHint': "Remplace STATION_CALLSIGN à la signature pour qu'un même certificat signe plusieurs indicatifs (F4BPO, F4BPO/P, TM2Q). Choisis la Station Location correspondante ci-dessus.", 'es.keyPw': 'Mot de passe de la clé', 'es.keyPwPh': 'seulement si la clé de ton certificat a un mot de passe', 'es.considerUnsent': 'Considérer comme non envoyé', 'es.flagN': 'Non (N)', 'es.flagR': 'Demandé (R)', 'es.lotwUnsentHint': "À la fermeture, chaque QSO dont le statut LoTW « envoyé » est l'un de ceux-ci est signé et envoyé dans un même lot TQSL — y compris les QSO importés depuis un ADIF. Les QSO envoyés passent à Y et ne sont pas renvoyés. Doit inclure ton statut « envoyé » par défaut défini dans Confirmations.", 'es.lotwAutoClose': "Envoi automatique à la fermeture de l'application", 'es.lotwWriteLog': 'Écrire le journal de diagnostic TQSL (-t)',
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).", 'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.", 'es.soon': 'bientôt', 'es.deleteRemote': 'Supprimer aussi sur QRZ.com et Club Log', 'es.deleteRemoteHint': 'Quand vous supprimez un QSO ici, le retirer aussi de ces services. QRZ.com ne peut supprimer que les enregistrements envoy\u00e9s par OpsLog \u2014 les plus anciens doivent \u00eatre retir\u00e9s sur le site.', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 14 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce nest pas fait.', 'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 14 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce nest pas fait.',
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.', 'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)', 'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
@@ -763,7 +763,7 @@ const fr: Dict = {
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.', 'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes', 'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
@@ -790,7 +790,7 @@ const fr: Dict = {
'awed.importKeepMine': 'Garder le mien', 'awed.importKeepMine': 'Garder le mien',
'awed.importReplace': 'Remplacer le mien', 'awed.importReplace': 'Remplacer le mien',
'awed.importCopy': 'Importer en {code}-2', 'awed.importCopy': 'Importer en {code}-2',
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis les annuaires', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.', 'exp.title': 'Exporter en ADIF', 'exp.desc': 'Choisissez les champs à écrire.',
'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour lenvoi vers dautres carnets (LoTW, QRZ, Club Log…).', 'exp.stdTitle': 'Champs ADIF standard', 'exp.stdDesc': 'Uniquement les champs officiels ADIF 3.1.7 — idéal pour lenvoi vers dautres carnets (LoTW, QRZ, Club Log…).',
'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.', 'exp.allTitle': 'Tous les champs OpsLog', 'exp.allDesc': 'Tout, y compris les champs spécifiques à OpsLog et les balises applicatives — une sauvegarde complète ré-importable ici.',
@@ -815,7 +815,7 @@ const fr: Dict = {
'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).", 'aud.txOn': 'ÉMISSION — micro → Vers la radio, PTT activé. Cliquez pour arrêter.', 'aud.txHint': "Micro direct → poste avec PTT (USB pour l'instant ; TX réseau plus tard).",
'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…', 'aud.recorder': 'Enregistreur de QSO', 'aud.recordEvery': 'Enregistrer chaque QSO dans un fichier audio (Depuis la radio + votre micro)', 'aud.recFolder': 'Dossier des enregistrements', 'aud.browse': 'Parcourir…',
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)', 'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.', 'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO", 'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
'aud.dvkTitle': 'Messages du manipulateur vocal (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série', 'aud.dvkTitle': 'Messages du manipulateur vocal (F1F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser', 'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
+41
View File
@@ -0,0 +1,41 @@
// Colouring for QSL / confirmation status letters.
//
// These columns are read by COLOUR before they are read as letters: an operator
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
// hundred times.
//
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
// and a column of coloured boxes would shout louder than the callsign it
// belongs to.
//
// The classes are semantic tokens, not fixed colours, so all four themes follow
// and the contrast stays right on the light ones.
// ADIF confirmation values, and what they mean to the operator:
// Y confirmed / sent
// N not sent, not received
// R requested — queued, waiting on the other station or the service
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
// good news nor bad, and colouring it would put it in one camp or the
// other.
//
// Anything else — a blank, or a status some other logger wrote — is left alone
// rather than guessed at.
export function qslStatusClass(value: unknown): string {
switch (String(value ?? '').trim().toUpperCase()) {
case 'Y':
return 'text-success';
case 'N':
return 'text-destructive';
case 'R':
return 'text-info';
}
return '';
}
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
// status columns already used so the letters stay in a straight column.
export function qslStatusCellClass(p: { value?: unknown }): string {
const c = qslStatusClass(p?.value);
return c ? 'font-mono ' + c : 'font-mono';
}
+11 -2
View File
@@ -5,7 +5,7 @@
// folder (data/) to another machine. These helpers mirror them into the DB // folder (data/) to another machine. These helpers mirror them into the DB
// settings table (ui.* keys, like the grid columns) so the whole setup is // settings table (ui.* keys, like the grid columns) so the whole setup is
// identical after a copy. // identical after a copy.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App'; import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
// Keys that must travel with data/ (DB is the portable source of truth; the // Keys that must travel with data/ (DB is the portable source of truth; the
// localStorage copy is just a fast, synchronous cache). // localStorage copy is just a fast, synchronous cache).
@@ -28,6 +28,9 @@ const PORTABLE_KEYS = [
'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.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite) 'opslog.mapBasemap', // world map basemap (light / street / satellite)
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
// Cluster filter selections — restored on reopen. // Cluster filter selections — restored on reopen.
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands', 'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
@@ -62,5 +65,11 @@ export async function syncPortablePrefs(): Promise<void> {
// Use it everywhere these keys are written instead of localStorage.setItem. // Use it everywhere these keys are written instead of localStorage.setItem.
export function writeUiPref(key: string, value: string): void { export function writeUiPref(key: string, value: string): void {
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ } try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ }); SetUIPref(key, value).catch((e: any) => {
// The cache still holds it, so the interface behaves — but the BACKEND
// reads some of these keys too (digital-mode grouping decides how the
// cluster judges a slot). A write that reaches localStorage and not the
// database makes the two disagree, and used to do so in silence.
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
});
} }
+5
View File
@@ -6,6 +6,7 @@ import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n' import { I18nProvider } from './lib/i18n'
import { ThemeProvider, initTheme } from './lib/theme' import { ThemeProvider, initTheme } from './lib/theme'
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary' import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
import { reloadDateFormat } from './lib/dateFormat'
const container = document.getElementById('root') const container = document.getElementById('root')
@@ -18,6 +19,10 @@ syncPortablePrefs().finally(() => {
// Stamp the persisted theme onto <html> before render so the correct // Stamp the persisted theme onto <html> before render so the correct
// palette is applied immediately (portable pref hydrated just above). // palette is applied immediately (portable pref hydrated just above).
initTheme() initTheme()
// The date format module read localStorage at import time, which is BEFORE
// the portable prefs were pulled from the database. Re-read it now, or a
// copied data/ folder would show ISO until the next launch.
reloadDateFormat()
// Catch what a boundary cannot: errors thrown from timers, event handlers and // Catch what a boundary cannot: errors thrown from timers, event handlers and
// rejected promises. Installed before the first render so a fault during // rejected promises. Installed before the first render so a fault during
// startup is reported too. // startup is reported too.
+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.22.2'; export const APP_VERSION = '0.22.6';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+22
View File
@@ -61,6 +61,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
export function AudioMonitorActive():Promise<boolean>; export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>; export function AudioStartMonitor():Promise<void>;
@@ -91,6 +93,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>; export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CIVTraceEnabled():Promise<boolean>;
export function CWDecoderRunning():Promise<boolean>; export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>; export function ChatAvailable():Promise<boolean>;
@@ -215,6 +219,8 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>; export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
export function FlexBackspaceCW(arg1:number):Promise<void>; export function FlexBackspaceCW(arg1:number):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>; export function FlexMox(arg1:boolean):Promise<void>;
@@ -417,6 +423,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>; export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
export function GetFlexState():Promise<cat.FlexTXState>; export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>; export function GetIcomState():Promise<cat.IcomTXState>;
@@ -747,10 +755,20 @@ export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>; export function QSOAudioCancel():Promise<void>;
export function QSOAudioManualReady():Promise<boolean>;
export function QSOAudioManualStart():Promise<boolean>;
export function QSOAudioPlayOnAir():Promise<void>;
export function QSOAudioResetClock():Promise<boolean>; export function QSOAudioResetClock():Promise<boolean>;
export function QSOAudioRestart():Promise<boolean>; export function QSOAudioRestart():Promise<boolean>;
export function QSOAudioResume():Promise<boolean>;
export function QSOAudioStop():Promise<boolean>;
export function QuitApp():Promise<void>; export function QuitApp():Promise<void>;
export function RecomputeAllAwardRefs():Promise<number>; export function RecomputeAllAwardRefs():Promise<number>;
@@ -837,6 +855,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>; export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>; export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>; export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
@@ -1037,6 +1057,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
export function WinkeyerStop():Promise<void>; export function WinkeyerStop():Promise<void>;
export function WinkeyerTraceEnabled():Promise<boolean>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>; export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
export function YaesuSendCW(arg1:string):Promise<void>; export function YaesuSendCW(arg1:string):Promise<void>;
+44
View File
@@ -70,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
} }
export function AudioApplyLevels(arg1, arg2) {
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
}
export function AudioMonitorActive() { export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive'](); return window['go']['main']['App']['AudioMonitorActive']();
} }
@@ -130,6 +134,10 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
export function CIVTraceEnabled() {
return window['go']['main']['App']['CIVTraceEnabled']();
}
export function CWDecoderRunning() { export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning'](); return window['go']['main']['App']['CWDecoderRunning']();
} }
@@ -378,6 +386,10 @@ export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1); return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
} }
export function FlexApplyBandPower(arg1, arg2) {
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
}
export function FlexBackspaceCW(arg1) { export function FlexBackspaceCW(arg1) {
return window['go']['main']['App']['FlexBackspaceCW'](arg1); return window['go']['main']['App']['FlexBackspaceCW'](arg1);
} }
@@ -782,6 +794,10 @@ export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas'](); return window['go']['main']['App']['GetFlexBandAntennas']();
} }
export function GetFlexBandPower() {
return window['go']['main']['App']['GetFlexBandPower']();
}
export function GetFlexState() { export function GetFlexState() {
return window['go']['main']['App']['GetFlexState'](); return window['go']['main']['App']['GetFlexState']();
} }
@@ -1442,6 +1458,18 @@ export function QSOAudioCancel() {
return window['go']['main']['App']['QSOAudioCancel'](); return window['go']['main']['App']['QSOAudioCancel']();
} }
export function QSOAudioManualReady() {
return window['go']['main']['App']['QSOAudioManualReady']();
}
export function QSOAudioManualStart() {
return window['go']['main']['App']['QSOAudioManualStart']();
}
export function QSOAudioPlayOnAir() {
return window['go']['main']['App']['QSOAudioPlayOnAir']();
}
export function QSOAudioResetClock() { export function QSOAudioResetClock() {
return window['go']['main']['App']['QSOAudioResetClock'](); return window['go']['main']['App']['QSOAudioResetClock']();
} }
@@ -1450,6 +1478,14 @@ export function QSOAudioRestart() {
return window['go']['main']['App']['QSOAudioRestart'](); return window['go']['main']['App']['QSOAudioRestart']();
} }
export function QSOAudioResume() {
return window['go']['main']['App']['QSOAudioResume']();
}
export function QSOAudioStop() {
return window['go']['main']['App']['QSOAudioStop']();
}
export function QuitApp() { export function QuitApp() {
return window['go']['main']['App']['QuitApp'](); return window['go']['main']['App']['QuitApp']();
} }
@@ -1622,6 +1658,10 @@ export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1); return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
} }
export function SaveFlexBandPower(arg1) {
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
}
export function SaveListsSettings(arg1) { export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1); return window['go']['main']['App']['SaveListsSettings'](arg1);
} }
@@ -2022,6 +2062,10 @@ export function WinkeyerStop() {
return window['go']['main']['App']['WinkeyerStop'](); return window['go']['main']['App']['WinkeyerStop']();
} }
export function WinkeyerTraceEnabled() {
return window['go']['main']['App']['WinkeyerTraceEnabled']();
}
export function WorkedBefore(arg1, arg2) { export function WorkedBefore(arg1, arg2) {
return window['go']['main']['App']['WorkedBefore'](arg1, arg2); return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
} }
+10
View File
@@ -506,6 +506,8 @@ export namespace award {
worked: boolean; worked: boolean;
confirmed: boolean; confirmed: boolean;
validated: boolean; validated: boolean;
modes: string[];
confirmed_modes: string[];
bands: string[]; bands: string[];
confirmed_bands: string[]; confirmed_bands: string[];
validated_bands: string[]; validated_bands: string[];
@@ -523,6 +525,8 @@ export namespace award {
this.worked = source["worked"]; this.worked = source["worked"];
this.confirmed = source["confirmed"]; this.confirmed = source["confirmed"];
this.validated = source["validated"]; this.validated = source["validated"];
this.modes = source["modes"];
this.confirmed_modes = source["confirmed_modes"];
this.bands = source["bands"]; this.bands = source["bands"];
this.confirmed_bands = source["confirmed_bands"]; this.confirmed_bands = source["confirmed_bands"];
this.validated_bands = source["validated_bands"]; this.validated_bands = source["validated_bands"];
@@ -1297,6 +1301,7 @@ export namespace extsvc {
hrdlog: ServiceConfig; hrdlog: ServiceConfig;
eqsl: ServiceConfig; eqsl: ServiceConfig;
cloudlog: ServiceConfig; cloudlog: ServiceConfig;
delete_remote: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ExternalServices(source); return new ExternalServices(source);
@@ -1310,6 +1315,7 @@ export namespace extsvc {
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig); this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.delete_remote = source["delete_remote"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1602,6 +1608,7 @@ export namespace main {
from_gain: number; from_gain: number;
mic_gain: number; mic_gain: number;
tx_gain: number; tx_gain: number;
qso_play_gain: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AudioSettings(source); return new AudioSettings(source);
@@ -1622,6 +1629,7 @@ export namespace main {
this.from_gain = source["from_gain"]; this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
this.tx_gain = source["tx_gain"]; this.tx_gain = source["tx_gain"];
this.qso_play_gain = source["qso_play_gain"];
} }
} }
export class AutostartLaunchResult { export class AutostartLaunchResult {
@@ -1906,6 +1914,7 @@ export namespace main {
xiegu_addr: number; xiegu_addr: number;
yaesu_port: string; yaesu_port: string;
yaesu_baud: number; yaesu_baud: number;
kenwood_host: string;
kenwood_port: string; kenwood_port: string;
kenwood_baud: number; kenwood_baud: number;
icom_port: string; icom_port: string;
@@ -1944,6 +1953,7 @@ export namespace main {
this.xiegu_addr = source["xiegu_addr"]; this.xiegu_addr = source["xiegu_addr"];
this.yaesu_port = source["yaesu_port"]; this.yaesu_port = source["yaesu_port"];
this.yaesu_baud = source["yaesu_baud"]; this.yaesu_baud = source["yaesu_baud"];
this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"]; this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"]; this.kenwood_baud = source["kenwood_baud"];
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
+27
View File
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
} }
return fallback return fallback
} }
// DeviceName resolves an endpoint id to its friendly name.
//
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
// straight at the DAX panel.
//
// Falls back to the id when the endpoint cannot be found, which is itself worth
// seeing: a device that has disappeared explains an empty recording too.
func DeviceName(id string) string {
if id == "" {
return "(none)"
}
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
devs, err := list()
if err != nil {
continue
}
for _, d := range devs {
if d.ID == id {
return d.Name
}
}
}
return id
}
+11 -1
View File
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
} }
} }
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY // autoConvert lets WASAPI resample between the device format and ours.
//
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
// it selects the CHEAP converter, meant for cases where quality does not matter.
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
// 8 kHz back into the audio band — and a receiver hiss is mostly high
// frequencies. The result was a recording where the noise sat ON TOP of the
// voice, while the same audio heard live had the voice well clear of it.
//
// Without the flag Windows uses its normal converter, which filters first.
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the // recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed. // stop channel is closed.
+89 -10
View File
@@ -5,16 +5,28 @@ package audio
import ( import (
"fmt" "fmt"
"sync" "sync"
"time"
) )
// Manager owns the DVK record/playback lifecycle: at most one recording and // Manager owns the DVK record/playback lifecycle: at most one recording and
// one playback at a time. Device ids are passed per call so the host can route // one playback at a time. Device ids are passed per call so the host can route
// recording to the mic and playback to the rig (or the preview speakers). // recording to the mic and playback to the rig (or the preview speakers).
type Manager struct { type Manager struct {
mu sync.Mutex mu sync.Mutex
recStop chan struct{} recStop chan struct{}
recDone chan recResult recDone chan recResult
playStop chan struct{} // monGainPct scales what the RX monitor plays, 100 = as captured.
//
// The "From radio" slider used to reach only the QSO recorder, so an
// operator listening through OpsLog heard no difference between 10% and
// 150% — the setting looked broken because it was, for the thing they were
// listening to.
monGainPct int
playStop chan struct{}
// playDone closes when the playback goroutine has returned and the audio
// device is free again. Without it, the next Play raced the old one for the
// device and lost.
playDone chan struct{}
monStop chan struct{} // RX monitor passthrough (capture → render) monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig) txStop chan struct{} // TX audio passthrough (mic → rig)
@@ -125,18 +137,38 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8) pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
} }
} }
// Waits for any previous playback to have RELEASED THE DEVICE.
//
// It used to only signal the old one to stop and start a new one at once.
// The old goroutine still held the WASAPI render client for a moment, so the
// new client could not start: it returned immediately, the PTT was keyed and
// released a tenth of a second later, and nothing came out. On the air that
// looked like "press play again and you must wait the whole length of the
// message before it will play at all".
m.StopPlayback() m.StopPlayback()
stop := make(chan struct{}) stop := make(chan struct{})
done := make(chan struct{})
m.mu.Lock() m.mu.Lock()
m.playStop = stop m.playStop = stop
m.playDone = done
m.mu.Unlock() m.mu.Unlock()
go func() { go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop) // The error was discarded. A device that refuses to start returns here
// instantly, the PTT is released 120 ms later, and NOTHING says why —
// which is exactly what a station heard as "it plays once, then never
// again": the call succeeded, the sound did not.
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
}
m.mu.Lock() m.mu.Lock()
if m.playStop == stop { if m.playStop == stop {
m.playStop = nil m.playStop = nil
} }
if m.playDone == done {
m.playDone = nil
}
m.mu.Unlock() m.mu.Unlock()
close(done) // the device is free from here
m.notify() m.notify()
}() }()
m.notify() m.notify()
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
func (m *Manager) StopPlayback() { func (m *Manager) StopPlayback() {
m.mu.Lock() m.mu.Lock()
stop := m.playStop stop := m.playStop
done := m.playDone
m.playStop = nil m.playStop = nil
m.mu.Unlock() m.mu.Unlock()
if stop != nil { if stop == nil {
close(stop) return
m.notify()
} }
close(stop)
// Wait for the goroutine to release the device — that is the whole point of
// stopping before starting again. Bounded: a wedged WASAPI call must not
// freeze the caller, which here is the operator clicking a button.
if done != nil {
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
LogSink("audio: previous playback did not release the device within 1.5 s")
}
}
m.notify()
} }
// ---- RX audio monitor (Phase 2: USB codec passthrough) -------------------- // ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Unlock() m.mu.Unlock()
if capture { if capture {
// Producer: capture the rig's USB audio into the ring. // Producer: capture the rig's USB audio into the ring, at the level the
// operator set. Applied HERE rather than on the render side so the
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
// that stream is already scaled by the radio.
go func() { go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) }) _ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
}() }()
} }
// Consumer: render the ring to the output device at the internal 16 kHz mono. // Consumer: render the ring to the output device at the internal 16 kHz mono.
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
return nil return nil
} }
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
// Takes effect on the next captured chunk — no need to restart the monitor.
func (m *Manager) SetMonitorGain(pct int) {
if pct <= 0 {
pct = 100
}
m.mu.Lock()
m.monGainPct = pct
m.mu.Unlock()
}
// scaleMonitor applies the monitor level to one captured chunk, returning a
// buffer the ring may keep. At unity it hands the chunk straight back: the
// common case must not pay for a copy 30 times a second.
func (m *Manager) scaleMonitor(chunk []byte) []byte {
m.mu.Lock()
pct := m.monGainPct
m.mu.Unlock()
if pct == 0 || pct == 100 {
return chunk
}
g := float64(pct) / 100
out := make([]byte, len(chunk))
copy(out, chunk)
for i := 0; i+1 < len(out); i += 2 {
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
v = scalePCM(v, g)
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
return out
}
// StopMonitor stops the RX monitor passthrough. // StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() { func (m *Manager) StopMonitor() {
m.mu.Lock() m.mu.Lock()
+70 -7
View File
@@ -3,6 +3,7 @@
package audio package audio
import ( import (
"math"
"os" "os"
"github.com/braheezy/shine-mp3/pkg/mp3" "github.com/braheezy/shine-mp3/pkg/mp3"
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
return enc.Write(f, stereo) return enc.Write(f, stereo)
} }
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz). // upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
// filter that makes the operation legitimate.
//
// It used to interpolate linearly — each new sample the average of its
// neighbours. That is an upsampler in name only: every component at f is
// mirrored to 16 kHz f, measured just 7 dB down (see the test). On speech it
// adds brightness; on receiver noise, which is broadband, it lays a second
// noise floor across the top of the band. Operators heard it as a hiss louder
// than the voice, present in the MP3 and absent from the WAV of the very same
// audio.
//
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
// microseconds of work on an eight-second recording — nothing, next to the MP3
// encode that follows.
func upsample2(in []int16) []int16 { func upsample2(in []int16) []int16 {
if len(in) == 0 { if len(in) == 0 {
return in return in
} }
out := make([]int16, len(in)*2) out := make([]int16, len(in)*2)
for i := range in { half := len(upsampleFIR) / 2
out[2*i] = in[i] for i := range out {
if i+1 < len(in) { var acc float64
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2) // The zero-stuffed signal is non-zero only at even indices, so only
} else { // every other tap contributes — the loop skips the zeros rather than
out[2*i+1] = in[i] // multiplying by them.
start := (i - half + 1) &^ 1 // first even index in the window
for j := start; j <= i+half; j += 2 {
k := i - j + half
if k < 0 || k >= len(upsampleFIR) {
continue
}
src := j / 2
if src < 0 || src >= len(in) {
continue
}
acc += float64(in[src]) * upsampleFIR[k]
} }
// ×2 for the energy lost to zero-stuffing, then clamp.
v := acc * 2
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
} }
return out return out
} }
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
// images begin.
var upsampleFIR = func() []float64 {
const n = 63
const fc = 0.25 // cycles/sample at the new rate
h := make([]float64, n)
mid := (n - 1) / 2
var sum float64
for i := 0; i < n; i++ {
m := float64(i - mid)
var v float64
if m == 0 {
v = 2 * fc
} else {
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
}
// Hamming window: a rectangular one would ring and put the stopband
// only ~21 dB down, which is not enough to be worth the filter.
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
h[i] = v
sum += v
}
for i := range h {
h[i] /= sum // unity gain at DC
}
return h
}()
+56
View File
@@ -0,0 +1,56 @@
package audio
import (
"testing"
"time"
)
// Playing a stopped take must not consume it.
//
// Reported on the air: the recording plays once, then nothing — "as if it threw
// the sound away after playing it". PeekQSO is what the playback reads, and it
// is meant to COPY; TakeQSO is the one that clears.
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
r.acc = make([]int16, 4000)
for i := range r.acc {
r.acc[i] = int16(i % 500)
}
first, err := r.PeekQSO()
if err != nil {
t.Fatalf("first peek: %v", err)
}
second, err := r.PeekQSO()
if err != nil {
t.Fatalf("second peek: %v — the take was consumed by the first", err)
}
if len(first) != len(second) {
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
}
// And the copy must be independent: the caller holds these bytes while the
// recorder may still be appending to its own slice.
if len(first) > 0 {
first[0] = ^first[0]
again, _ := r.PeekQSO()
if len(again) > 0 && again[0] == first[0] {
t.Error("PeekQSO handed out its internal buffer, not a copy")
}
}
// Resuming and stopping again keeps everything captured so far.
r.ResumeQSO()
r.mu.Lock()
r.acc = append(r.acc, make([]int16, 1000)...)
r.mu.Unlock()
r.PauseQSO()
third, err := r.PeekQSO()
if err != nil {
t.Fatalf("peek after resume: %v", err)
}
if len(third) <= len(second) {
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
}
_ = time.Now
}
+254 -23
View File
@@ -15,6 +15,14 @@ import (
// Defaults to a no-op so the package is usable without wiring. // Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {} var LogSink = func(string, ...any) {}
// AlertSink receives the few audio problems an operator must see WHILE they are
// operating, not afterwards in a log file — a capture device that opens but
// never streams being the one that matters: the recording is silently empty and
// nothing says so until the QSO is logged and gone.
//
// Set to a toast emitter at startup; a no-op keeps the package standalone.
var AlertSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged // recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't // event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal // catch a hard Windows access violation from the WASAPI layer — those are fatal
@@ -42,16 +50,31 @@ type Recorder struct {
prerollSamples int prerollSamples int
// Per-source sample queues (guarded by srcMu), drained by the mixer. // Per-source sample queues (guarded by srcMu), drained by the mixer.
srcMu sync.Mutex srcMu sync.Mutex
bufA []int16 // From Radio bufA []int16 // From Radio
bufB []int16 // mic bufB []int16 // mic
twoSrc bool // When each source last delivered samples. A configured device that never
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu // produces anything is not an error anywhere — the capture call just sits
gainB float64 // mic gain // there — so the only way to notice is to watch the clock.
lastA, lastB time.Time
// startedAt is when capture began — the reference for a source that has not
// delivered anything at all yet.
startedAt time.Time
// deadB (deadA) latches once a source has been declared silent, so the
// warning is logged once rather than 25 times a second.
deadA, deadB bool
twoSrc bool
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
gainB float64 // mic gain
// Mixed output state (guarded by mu). // Mixed output state (guarded by mu).
ring []int16 // last prerollSamples of mixed audio ring []int16 // last prerollSamples of mixed audio
active bool active bool
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
// captured so far is kept and the take still ends normally when the QSO is
// logged. It is what lets an operator stop, play the recording back on the
// air to the station they are working, and still have it saved with the QSO.
paused bool
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO) acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
} }
@@ -110,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
if prerollSec < 0 { if prerollSec < 0 {
prerollSec = 0 prerollSec = 0
} }
// Does the configured endpoint still EXIST?
//
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
// or removed comes back with a different id. The old one then opens without
// complaint on some drivers and simply never streams — which is
// indistinguishable from a quiet band until the recording turns out empty.
// Checking the list takes milliseconds and answers it outright.
if devs, derr := ListInputDevices(); derr == nil {
known := func(id string) bool {
for _, d := range devs {
if d.ID == id {
return true
}
}
return false
}
if fromDev != "" && !known(fromDev) {
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
}
if micDev != "" && micDev != fromDev && !known(micDev) {
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
}
}
r.prerollSamples = prerollSec * sampleRate r.prerollSamples = prerollSec * sampleRate
r.twoSrc = micDev != "" && micDev != fromDev r.twoSrc = micDev != "" && micDev != fromDev
r.stopCh = make(chan struct{}) r.stopCh = make(chan struct{})
r.running = true r.running = true
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil r.startedAt = time.Now()
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
stop := r.stopCh stop := r.stopCh
twoSrc := r.twoSrc twoSrc := r.twoSrc
r.mu.Unlock() r.mu.Unlock()
@@ -124,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)") defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) { // The error was discarded here. A device that cannot be opened — renamed,
// unplugged, held by another program — then looked exactly like a device
// that is merely quiet, and the recording came out empty with nothing in
// the log to say why.
if err := captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
r.bufA = append(r.bufA, s...) r.bufA = append(r.bufA, s...)
r.lastA = time.Now()
r.srcMu.Unlock() r.srcMu.Unlock()
}) }); err != nil {
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
}
}() }()
if twoSrc { if twoSrc {
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
defer r.wg.Done() defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)") defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) { if err := captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk) s := bytesToInt16(chunk)
r.srcMu.Lock() r.srcMu.Lock()
r.bufB = append(r.bufB, s...) r.bufB = append(r.bufB, s...)
r.lastB = time.Now()
r.srcMu.Unlock() r.srcMu.Unlock()
}) }); err != nil {
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
}
}() }()
} }
// Name the devices being recorded FROM, once, at the start.
//
// A station with two radios has two sets of endpoints, and the recorder will
// happily capture the one that is not being listened to: the result is a
// file full of hiss with no relation to what the operator hears, and nothing
// anywhere said which receiver it came from.
if twoSrc {
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
} else {
LogSink("recorder: capturing %q", DeviceName(fromDev))
}
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
// all — a DAX channel with no stream behind it does exactly that, and it is
// indistinguishable from silence until the recording turns out to be empty
// at the end of a QSO. Say it once, three seconds in, while there is still
// time to fix the setup.
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder watchdog")
select {
case <-stop:
return
case <-time.After(3 * time.Second):
}
r.srcMu.Lock()
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
r.srcMu.Unlock()
if aQuiet {
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
}
// The mic is only worth a warning when the RADIO is silent too. On CW the
// mic channel legitimately delivers nothing, and the mixer has already
// said "recording the radio alone" — repeating it as an alarm made the log
// read as if something were broken while the recording was going fine.
if twoSrc && bQuiet && aQuiet {
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
}
}()
// Mixer goroutine. // Mixer goroutine.
r.wg.Add(1) r.wg.Add(1)
go func() { go func() {
@@ -166,15 +267,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
// mixTick drains the source queues, mixes what's available, and appends to the // mixTick drains the source queues, mixes what's available, and appends to the
// ring + active accumulation. // ring + active accumulation.
// deadSourceAfter is how long a source may deliver nothing before the recorder
// carries on without it. Long enough not to trip on a scheduling hiccup, short
// enough that almost nothing is lost from the source that IS working.
const deadSourceAfter = 1500 * time.Millisecond
func (r *Recorder) mixTick() { func (r *Recorder) mixTick() {
r.srcMu.Lock() r.srcMu.Lock()
var mixed []int16 var mixed []int16
if r.twoSrc { if r.twoSrc {
// A source that has been silent for a while is treated as ABSENT and the
// other one is recorded alone.
//
// Two sources used to mean min(len(A), len(B)) samples: if one device
// delivered nothing, NOTHING was recorded, and the drift guard below
// then threw the live source away a second at a time. That is exactly
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
// path is not running — and the operator got "recording was empty" after
// a whole QSO. Half a recording is worth having; silence is not.
now := time.Now()
// A source is dry when it has been quiet for too long — and a source that
// has NEVER delivered is measured from when capture started, because it
// has no last-delivery time of its own.
//
// The first version required a source to have spoken at least once. That
// covers a device that stops, but not the one that actually happens: a
// DAX Mic channel that is simply switched off never delivers a single
// sample, so it stayed "not yet dry" forever and took the whole recording
// down with it. Three empty CW recordings, with the radio audio streaming
// perfectly the entire time.
dry := func(last time.Time) bool {
if last.IsZero() {
return now.Sub(r.startedAt) > deadSourceAfter
}
return now.Sub(last) > deadSourceAfter
}
aDry, bDry := dry(r.lastA), dry(r.lastB)
if bDry && !aDry && len(r.bufA) > 0 {
if !r.deadB {
r.deadB = true
LogSink("recorder: the second audio source is silent — recording the radio alone")
}
mixed = make([]int16, len(r.bufA))
for i, v := range r.bufA {
mixed[i] = scaleSample(v, r.gainA)
}
r.bufA = r.bufA[:0]
r.bufB = r.bufB[:0]
r.srcMu.Unlock()
r.store(mixed)
return
}
if aDry && !bDry && len(r.bufB) > 0 {
if !r.deadA {
r.deadA = true
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
}
mixed = make([]int16, len(r.bufB))
for i, v := range r.bufB {
mixed[i] = scaleSample(v, r.gainB)
}
r.bufA = r.bufA[:0]
r.bufB = r.bufB[:0]
r.srcMu.Unlock()
r.store(mixed)
return
}
n := len(r.bufA) n := len(r.bufA)
if len(r.bufB) < n { if len(r.bufB) < n {
n = len(r.bufB) n = len(r.bufB)
} }
if n > 0 { if n > 0 {
// Both alive again after one was written off.
r.deadA, r.deadB = false, false
mixed = make([]int16, n) mixed = make([]int16, n)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB)) mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
@@ -182,12 +347,20 @@ func (r *Recorder) mixTick() {
r.bufA = append(r.bufA[:0], r.bufA[n:]...) r.bufA = append(r.bufA[:0], r.bufA[n:]...)
r.bufB = append(r.bufB[:0], r.bufB[n:]...) r.bufB = append(r.bufB[:0], r.bufB[n:]...)
} }
// Drift guard: if the clocks diverge, drop the excess so the two // Drift guard: two sound cards run on their own clocks, so drop the
// sources stay roughly aligned (≤1 s skew). // excess to keep them within a second of each other.
if d := len(r.bufA) - len(r.bufB); d > sampleRate { //
r.bufA = append(r.bufA[:0], r.bufA[d:]...) // ONLY while both are actually running. A starved source is not drift: it
} else if d < -sampleRate { // made this guard throw away the radio audio a second at a time during
r.bufB = append(r.bufB[:0], r.bufB[-d:]...) // the grace period, so the opening of every recording was lost even
// though it had been captured. Waiting costs nothing now — whatever is
// buffered is written whole the moment the silent source is written off.
if len(r.bufA) > 0 && len(r.bufB) > 0 {
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
} else if d < -sampleRate {
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
}
} }
} else if len(r.bufA) > 0 { } else if len(r.bufA) > 0 {
mixed = make([]int16, len(r.bufA)) mixed = make([]int16, len(r.bufA))
@@ -198,6 +371,12 @@ func (r *Recorder) mixTick() {
} }
r.srcMu.Unlock() r.srcMu.Unlock()
r.store(mixed)
}
// store appends mixed samples to the pre-roll ring and, when a take is running,
// to the take itself.
func (r *Recorder) store(mixed []int16) {
if len(mixed) == 0 { if len(mixed) == 0 {
return return
} }
@@ -206,7 +385,7 @@ func (r *Recorder) mixTick() {
if len(r.ring) > r.prerollSamples { if len(r.ring) > r.prerollSamples {
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...) r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
} }
if r.active { if r.active && !r.paused {
r.acc = append(r.acc, mixed...) r.acc = append(r.acc, mixed...)
} }
r.mu.Unlock() r.mu.Unlock()
@@ -221,7 +400,7 @@ func (r *Recorder) BeginQSO() {
return return
} }
r.acc = append([]int16(nil), r.ring...) r.acc = append([]int16(nil), r.ring...)
r.active = true r.active, r.paused = true, false
} }
// RestartQSO begins a fresh accumulation even if one is already active — // RestartQSO begins a fresh accumulation even if one is already active —
@@ -236,7 +415,7 @@ func (r *Recorder) RestartQSO() {
return return
} }
r.acc = append([]int16(nil), r.ring...) r.acc = append([]int16(nil), r.ring...)
r.active = true r.active, r.paused = true, false
} }
// ResetQSOClock restarts the active accumulation from ZERO — discarding // ResetQSOClock restarts the active accumulation from ZERO — discarding
@@ -266,7 +445,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
return nil, fmt.Errorf("no active recording") return nil, fmt.Errorf("no active recording")
} }
samples := r.acc samples := r.acc
r.acc, r.active = nil, false r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock() r.mu.Unlock()
if len(samples) == 0 { if len(samples) == 0 {
return nil, fmt.Errorf("recording was empty") return nil, fmt.Errorf("recording was empty")
@@ -295,7 +474,7 @@ func (r *Recorder) SaveQSO(path string) error {
// DiscardQSO drops the active accumulation without saving (callsign cleared). // DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() { func (r *Recorder) DiscardQSO() {
r.mu.Lock() r.mu.Lock()
r.acc, r.active = nil, false r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock() r.mu.Unlock()
} }
@@ -313,7 +492,7 @@ func (r *Recorder) Stop() {
close(stop) close(stop)
r.wg.Wait() r.wg.Wait()
r.mu.Lock() r.mu.Lock()
r.ring, r.acc, r.active = nil, nil, false r.ring, r.acc, r.active, r.paused = nil, nil, false, false
r.mu.Unlock() r.mu.Unlock()
r.srcMu.Lock() r.srcMu.Lock()
r.bufA, r.bufB = nil, nil r.bufA, r.bufB = nil, nil
@@ -346,3 +525,55 @@ func int16sToBytes(s []int16) []byte {
} }
return b return b
} }
// PauseQSO freezes the active take without ending it: nothing more is recorded,
// nothing is thrown away, and logging the QSO still writes the file.
//
// This exists for one situation, which is common enough on the bands to be
// worth the state: you are working a station, you have been recording, and they
// ask to hear it. You stop, you play it back to them on the air, and the
// recording is still saved with the QSO afterwards.
func (r *Recorder) PauseQSO() bool {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return false
}
r.paused = true
return true
}
// ResumeQSO continues an interrupted take, appending to what is already there.
func (r *Recorder) ResumeQSO() bool {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return false
}
r.paused = false
return true
}
// Paused reports whether the active take is frozen.
func (r *Recorder) Paused() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.active && r.paused
}
// PeekQSO returns what has been captured so far WITHOUT ending the take.
//
// Unlike TakeQSO this keeps the audio, because the take is going to be played
// back and then still saved with the QSO. The copy is deliberate: the caller
// gets bytes it can hold while the recorder keeps appending to its own slice.
func (r *Recorder) PeekQSO() ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return nil, fmt.Errorf("no active recording")
}
if len(r.acc) == 0 {
return nil, fmt.Errorf("recording is empty")
}
return int16sToBytes(append([]int16(nil), r.acc...)), nil
}
+123
View File
@@ -0,0 +1,123 @@
package audio
import (
"testing"
"time"
)
// A silent second source must not silence the recording.
//
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
// recorded. The whole QSO ended in "recording was empty".
func TestMixerSurvivesASilentSource(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
// The radio has been talking; the mic has not spoken for well over the
// grace period.
r.bufA = make([]int16, 800)
for i := range r.bufA {
r.bufA[i] = 1000
}
r.lastA = time.Now()
r.lastB = time.Now().Add(-5 * time.Second)
r.mixTick()
if len(r.acc) == 0 {
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
}
if r.acc[0] != 1000 {
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
}
}
// While BOTH sources are alive the two are mixed, as before.
func TestMixerStillMixesBothSources(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.bufA = []int16{100, 100, 100}
r.bufB = []int16{50, 50, 50}
now := time.Now()
r.lastA, r.lastB = now, now
r.mixTick()
if len(r.acc) != 3 {
t.Fatalf("recorded %d samples, want 3", len(r.acc))
}
if r.acc[0] != 150 {
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
}
}
// Just after capture starts, a source that has not delivered yet is NOT written
// off: the first samples take a moment to arrive, and declaring the mic dead at
// once would drop the opening of every recording.
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.startedAt = time.Now()
r.bufA = []int16{100, 100}
r.mixTick()
if len(r.acc) != 0 {
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
}
}
// A source that has NEVER delivered is written off once enough time has passed.
//
// This is the case that actually happens, and the one the first version missed:
// a DAX Mic channel switched off delivers not one sample, so it had no
// last-delivery time and stayed forever "not yet dry" — taking the whole
// recording down with it while the radio audio streamed perfectly.
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.startedAt = time.Now().Add(-5 * time.Second)
r.bufA = []int16{700, 700, 700}
r.lastA = time.Now()
// lastB stays zero: the mic has never produced anything at all.
r.mixTick()
if len(r.acc) != 3 {
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
}
if r.acc[0] != 700 {
t.Errorf("sample = %d, want 700", r.acc[0])
}
}
// Nothing captured during the grace period is thrown away.
//
// The drift guard exists for two sound cards running on their own clocks. A
// STARVED source is not drift, and treating it as such discarded the radio
// audio a second at a time while the mixer was still waiting for the mic — so
// the opening of every CW recording was lost although it had been captured.
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.startedAt = time.Now()
// Three seconds of radio audio arrive while the mic says nothing at all.
r.bufA = make([]int16, 3*sampleRate)
for i := range r.bufA {
r.bufA[i] = 500
}
r.lastA = time.Now()
r.mixTick() // still inside the grace period: nothing is written yet…
if len(r.acc) != 0 {
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
}
if len(r.bufA) != 3*sampleRate {
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
}
// …and once the mic is written off, everything captured comes through.
r.startedAt = time.Now().Add(-5 * time.Second)
r.mixTick()
if len(r.acc) != 3*sampleRate {
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
}
}
+79
View File
@@ -0,0 +1,79 @@
package audio
import (
"math"
"testing"
)
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
// much energy is at 10 kHz" without pulling in an FFT.
func goertzel(x []int16, fs, freq float64) float64 {
w := 2 * math.Pi * freq / fs
c := 2 * math.Cos(w)
var s1, s2 float64
for _, v := range x {
s := float64(v) + c*s1 - s2
s2, s1 = s1, s
}
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
}
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
//
// MP3 recordings came back with a hiss that is not on the air and is not in the
// WAV of the same audio — "louder than the voice", from a station that hears the
// voice well clear of the noise. The MP3 path is the only one that resamples,
// and it did so by linear interpolation: every component at f is mirrored to
// 16 kHz f, which for broadband receiver noise means a second noise floor
// spread across the top of the band.
func TestUpsampleSuppressesTheImage(t *testing.T) {
const fs = 16000.0
const tone = 6000.0 // its image lands at 16000 6000 = 10 kHz
in := make([]int16, 4096)
for i := range in {
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
}
out := upsample2(in)
if len(out) != 2*len(in) {
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
}
wanted := goertzel(out, 2*fs, tone)
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
if wanted == 0 {
t.Fatal("the tone itself did not survive upsampling")
}
db := 20 * math.Log10(image/wanted)
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
if db > -35 {
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
}
}
// The filter must not change the level, or every existing recording would come
// back quieter (or clipped) after the fix — a second surprise on top of the one
// being repaired.
func TestUpsampleKeepsTheLevel(t *testing.T) {
const fs = 16000.0
in := make([]int16, 4096)
for i := range in {
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
}
rms := func(x []int16) float64 {
var acc float64
for _, v := range x {
acc += float64(v) * float64(v)
}
return math.Sqrt(acc / float64(len(x)))
}
// Skip the filter's warm-up and tail, where the window is only partly fed.
out := upsample2(in)
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
ratio := got / want
t.Logf("level after upsampling: ×%.3f", ratio)
if ratio < 0.9 || ratio > 1.1 {
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
}
}
+7 -7
View File
@@ -33,13 +33,13 @@ func writeWAV(path string, pcm []byte) error {
put(uint32(36 + dataLen)) put(uint32(36 + dataLen))
f.WriteString("WAVE") f.WriteString("WAVE")
f.WriteString("fmt ") f.WriteString("fmt ")
put(uint32(16)) // PCM fmt chunk size put(uint32(16)) // PCM fmt chunk size
put(uint16(1)) // WAVE_FORMAT_PCM put(uint16(1)) // WAVE_FORMAT_PCM
put(uint16(channels)) // put(uint16(channels)) //
put(uint32(sampleRate)) // put(uint32(sampleRate)) //
put(uint32(bytesPerSec)) // byte rate put(uint32(bytesPerSec)) // byte rate
put(uint16(blockAlign)) // put(uint16(blockAlign)) //
put(uint16(bitsPerSample)) // put(uint16(bitsPerSample)) //
f.WriteString("data") f.WriteString("data")
put(uint32(dataLen)) put(uint32(dataLen))
_, err = f.Write(pcm) _, err = f.Write(pcm)
+48 -8
View File
@@ -334,13 +334,19 @@ type BandCount struct {
// Ref is one reference's status within an award. // Ref is one reference's status within an award.
type Ref struct { type Ref struct {
Ref string `json:"ref"` Ref string `json:"ref"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"` SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"` Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"` Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"` Validated bool `json:"validated"`
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
// "DIGI". An operator asking "which entities have I worked on CW but not
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
// and a list of twenty mode names would not answer the question they asked.
Modes []string `json:"modes"`
ConfirmedModes []string `json:"confirmed_modes"`
Bands []string `json:"bands"` Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_bands"` ConfirmedBands []string `json:"confirmed_bands"`
ValidatedBands []string `json:"validated_bands"` ValidatedBands []string `json:"validated_bands"`
@@ -360,11 +366,33 @@ type Result struct {
Error string `json:"error,omitempty"` // e.g. bad regexp pattern Error string `json:"error,omitempty"` // e.g. bad regexp pattern
} }
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
// under any class — better than inventing one, since these classes drive a
// filter that decides what the operator is shown.
func ModeClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW", "CWR":
return "CW"
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
return "PHONE"
case "":
return ""
}
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
// would mean a new mode silently vanishing from the filter the day it
// appears, which is worse than treating an unknown data mode as data.
return "DIGI"
}
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil. // NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
type NameResolver func(field, ref string) string type NameResolver func(field, ref string) string
type refAgg struct { type refAgg struct {
bands map[string]struct{} bands map[string]struct{}
modes map[string]struct{}
confirmedModes map[string]struct{}
confirmedBands map[string]struct{} confirmedBands map[string]struct{}
validatedBands map[string]struct{} validatedBands map[string]struct{}
anyConfirmed bool anyConfirmed bool
@@ -481,22 +509,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
continue continue
} }
band := strings.ToLower(strings.TrimSpace(q.Band)) band := strings.ToLower(strings.TrimSpace(q.Band))
modeClass := ModeClass(q.Mode)
isConf := confirmed(q, d.Confirm) isConf := confirmed(q, d.Confirm)
isVal := confirmed(q, d.Validate) isVal := confirmed(q, d.Validate)
for _, ref := range refs { for _, ref := range refs {
a := agg[i][ref] a := agg[i][ref]
if a == nil { if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}} a = &refAgg{
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
validatedBands: map[string]struct{}{},
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
}
agg[i][ref] = a agg[i][ref] = a
} }
if band != "" { if band != "" {
a.bands[band] = struct{}{} a.bands[band] = struct{}{}
} }
if modeClass != "" {
a.modes[modeClass] = struct{}{}
}
if isConf { if isConf {
a.anyConfirmed = true a.anyConfirmed = true
if band != "" { if band != "" {
a.confirmedBands[band] = struct{}{} a.confirmedBands[band] = struct{}{}
} }
if modeClass != "" {
a.confirmedModes[modeClass] = struct{}{}
}
} }
if isVal { if isVal {
a.anyValidated = true a.anyValidated = true
@@ -525,6 +564,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
r.Validated++ r.Validated++
} }
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated, rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)} Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
labelRef(&rf, d, ref, rl, hasList, nameOf) labelRef(&rf, d, ref, rl, hasList, nameOf)
r.Refs = append(r.Refs, rf) r.Refs = append(r.Refs, rf)
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
package award
import "testing"
// The three classes an operator thinks in — the ones the awards filter offers.
//
// "Which entities have I worked on CW but not confirmed" is the question this
// serves, so the classes must match how the question is asked: CW, phone, data.
// Which data mode it was does not enter into it.
func TestModeClass(t *testing.T) {
for _, c := range []struct{ mode, want string }{
{"CW", "CW"}, {"cw", "CW"}, {" CW ", "CW"}, {"CWR", "CW"},
{"SSB", "PHONE"}, {"USB", "PHONE"}, {"LSB", "PHONE"},
{"AM", "PHONE"}, {"FM", "PHONE"}, {"DV", "PHONE"},
{"FT8", "DIGI"}, {"FT4", "DIGI"}, {"RTTY", "DIGI"}, {"PSK31", "DIGI"},
{"JS8", "DIGI"}, {"Q65", "DIGI"}, {"OLIVIA", "DIGI"},
// A mode nobody has heard of yet is data, not nothing: the alternative
// is a new digital mode silently vanishing from the filter the day it
// appears.
{"SOMETHINGNEW", "DIGI"},
// No mode at all is not a class, and must not be counted as one.
{"", ""}, {" ", ""},
} {
if got := ModeClass(c.mode); got != c.want {
t.Errorf("ModeClass(%q) = %q, want %q", c.mode, got, c.want)
}
}
}
+76 -5
View File
@@ -35,7 +35,12 @@ type Flex struct {
model string model string
gotHandle bool gotHandle bool
slices map[int]*flexSlice slices map[int]*flexSlice
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
// overrides the radio's own "active" flag, and only an OpsLog click changes
// it — activating a slice on the radio's front panel or in SmartSDR does
// not, by design (see mainSliceLocked).
pinnedSlice int
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab) tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state amp flexAmp // external amplifier (PowerGenius XL) state
micProfiles []string // available mic profiles (SmartSDR "profile mic list") micProfiles []string // available mic profiles (SmartSDR "profile mic list")
@@ -183,6 +188,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
} }
} }
@@ -968,7 +974,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
// map order returned a RANDOM active slice each call → the operating frequency // map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever. // flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably. // Deterministic order = the lowest-indexed active slice wins, stably.
// An explicit choice made in OpsLog wins over everything, including the
// radio's active flag. It is dropped only when that slice stops being in
// use — a slice the operator closed is not a choice any more.
if f.pinnedSlice >= 0 {
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
return f.pinnedSlice, s
}
}
firstInUse := -1 firstInUse := -1
chosen := -1
for _, idx := range f.sortedSliceIdxLocked() { for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx] s := f.slices[idx]
if !s.inUse { if !s.inUse {
@@ -978,11 +994,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
firstInUse = idx firstInUse = idx
} }
if s.active { if s.active {
return idx, s chosen = idx
break
} }
} }
if firstInUse >= 0 { if chosen < 0 {
return firstInUse, f.slices[firstInUse] chosen = firstInUse
}
if chosen < 0 {
return -1, nil
}
// In SPLIT, stay on the RECEIVE slice.
//
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
// frequency is touched, so OpsLog followed the transmitter. But the operator
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
// the filter and the DSP they are adjusting all belong there, and following
// the TX slice hands them the controls for a receiver they are not using.
//
// Only in split, and only when nothing was pinned above — clicking slice B
// in OpsLog still selects it and keeps it.
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
return rxIdx, rxS
}
}
return chosen, f.slices[chosen]
}
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
// the same band, at a different frequency, in the same class (phone with phone,
// CW with CW — so SSB alongside FT8 on one band is not a split).
//
// Lowest index first, so the answer is stable; map order is randomised in Go
// and gave a different partner on each poll. Caller holds f.mu.
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
if txS == nil {
return -1, nil
}
bt := BandFromHz(txS.freqHz)
ct := flexSplitClass(txS.mode)
if bt == "" || ct == "" {
return -1, nil
}
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
return idx, s
}
} }
return -1, nil return -1, nil
} }
@@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
if !exists { if !exists {
return fmt.Errorf("flex: no slice %d", idx) return fmt.Errorf("flex: no slice %d", idx)
} }
// Remember it: this is the operator speaking, and it must survive the radio
// moving its own active flag to the transmitter during split.
f.mu.Lock()
f.pinnedSlice = idx
f.mu.Unlock()
f.send(fmt.Sprintf("slice s %d active=1", idx)) f.send(fmt.Sprintf("slice s %d active=1", idx))
return nil return nil
} }
@@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState {
} }
} }
sort.Ints(sidx) sort.Ints(sidx)
mainIdx, _ := f.mainSliceLocked()
for _, i := range sidx { for _, i := range sidx {
s := f.slices[i] s := f.slices[i]
st.Slices = append(st.Slices, FlexSliceInfo{ st.Slices = append(st.Slices, FlexSliceInfo{
@@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState {
FreqHz: s.freqHz, FreqHz: s.freqHz,
Mode: flexModeToADIF(s.mode), Mode: flexModeToADIF(s.mode),
Band: BandFromHz(s.freqHz), Band: BandFromHz(s.freqHz),
Active: s.active, // The slice OpsLog is working with, which is not always the one the
// radio has focused — in split OpsLog stays on RX. Reporting the
// radio's flag here would highlight one slice while every control
// acted on another.
Active: i == mainIdx,
TX: s.tx, TX: s.tx,
}) })
} }
+68
View File
@@ -0,0 +1,68 @@
package cat
import "testing"
// In split, OpsLog stays on the RECEIVE slice.
//
// SmartSDR moves its "active" flag onto the TX slice as soon as the transmit
// frequency is touched. Following it hands the operator the S-meter, audio
// level, filter and DSP of a receiver they are not listening to — while the DX
// they are working is on the other slice.
func TestMainSliceStaysOnRXInSplit(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
// Working a DX on 14.100, transmitting up on 14.200. The radio has focused
// the transmitter.
f := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
0: mk(14_100_000, "USB", false, false), // where the DX is heard
1: mk(14_200_000, "USB", true, true), // where we transmit, radio-focused
}}
idx, s := f.mainSliceLocked()
if idx != 0 || s == nil || s.freqHz != 14_100_000 {
t.Errorf("main slice = %d (%v Hz) — want slice 0, the RX side on 14.100", idx, s.freqHz)
}
// Simplex: the radio's focus is authoritative again, nothing to prefer.
f2 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
0: mk(14_100_000, "USB", false, false),
1: mk(14_200_000, "USB", true, true),
}}
f2.slices[0].inUse = false // only the TX slice is in use → simplex
if idx, _ := f2.mainSliceLocked(); idx != 1 {
t.Errorf("simplex main slice = %d, want 1 — the only slice in use", idx)
}
// Two same-band slices in DIFFERENT classes are not a split (SSB + FT8), so
// there is nothing to prefer and the radio's focus stands.
f3 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
0: mk(14_074_000, "DIGU", false, false),
1: mk(14_200_000, "USB", true, true),
}}
if idx, _ := f3.mainSliceLocked(); idx != 1 {
t.Errorf("SSB+FT8 main slice = %d, want 1 — that is not a split", idx)
}
}
// A slice picked IN OPSLOG wins over everything, including the split rule and
// the radio's own focus. Picking one on the radio does not move OpsLog.
func TestPinnedSliceWins(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
f := &Flex{pinnedSlice: 1, slices: map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false), // radio-focused
1: mk(14_200_000, "USB", false, true), // chosen in OpsLog
}}
if idx, _ := f.mainSliceLocked(); idx != 1 {
t.Errorf("main slice = %d — an explicit choice in OpsLog must win", idx)
}
// A pin on a slice that is no longer in use is not a choice any more: it
// must not strand OpsLog on a receiver that has been closed.
f.slices[1].inUse = false
if idx, _ := f.mainSliceLocked(); idx != 0 {
t.Errorf("main slice = %d — a closed slice cannot stay pinned", idx)
}
}
+154 -5
View File
@@ -29,7 +29,9 @@ package cat
// from the radio, and the rig file decides what a "Freq" property means. // from the radio, and the rig file decides what a "Freq" property means.
import ( import (
"errors"
"fmt" "fmt"
"net"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -43,11 +45,26 @@ import (
type Kenwood struct { type Kenwood struct {
portName string portName string
baud int baud int
digital string // mode name logged for data (FT8 by default) // host is "address:port" for a serial link reached over the network — a
// ser2net daemon, an Ethernet-serial adapter, a Raspberry Pi in the shack.
//
// This is NOT Kenwood's own network protocol. A TS-890 or TS-990 speaks
// KNS/ARCP over its Ethernet socket, with a session and authentication, and
// that is a different piece of work needing one of those radios to confirm
// it. What this covers is the same CAT byte stream over a socket instead of
// a wire, which is how most operators actually put a rig on the network.
host string
digital string // mode name logged for data (FT8 by default)
mu sync.Mutex mu sync.Mutex
port serial.Port port serial.Port
// dialPort, when set, replaces serial.Open. It exists so the backend can be
// driven against internal/catemu — which already SPEAKS this dialect to
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
// pair. Written for a Kenwood backend nobody here owns a rig to test.
dialPort func() (serial.Port, error)
model string model string
curFreq int64 curFreq int64
curRXFreq int64 curRXFreq int64
@@ -56,6 +73,14 @@ type Kenwood struct {
unsupported map[string]bool unsupported map[string]bool
} }
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
// a COM port (ser2net and friends).
func NewKenwoodTCP(hostPort, digital string) *Kenwood {
k := NewKenwood("", 0, digital)
k.host = strings.TrimSpace(hostPort)
return k
}
func NewKenwood(portName string, baud int, digital string) *Kenwood { func NewKenwood(portName string, baud int, digital string) *Kenwood {
if baud <= 0 { if baud <= 0 {
baud = 9600 // TS-590 factory default; TS-890 ships at 115200 baud = 9600 // TS-590 factory default; TS-890 ships at 115200
@@ -71,8 +96,8 @@ func (k *Kenwood) Name() string { return "kenwood" }
func (k *Kenwood) Connect() error { func (k *Kenwood) Connect() error {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
if k.portName == "" { if k.portName == "" && k.host == "" {
return fmt.Errorf("kenwood: no serial port configured") return fmt.Errorf("kenwood: no serial port or network address configured")
} }
// Close any handle still held before opening another: Connect runs again on // Close any handle still held before opening another: Connect runs again on
// every reconnect, and Windows opens a serial port exclusively, so a leaked // every reconnect, and Windows opens a serial port exclusively, so a leaked
@@ -82,8 +107,11 @@ func (k *Kenwood) Connect() error {
_ = k.port.Close() _ = k.port.Close()
k.port = nil k.port = nil
} }
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud}) p, err := k.openPort()
if err != nil { if err != nil {
if k.host != "" {
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
}
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err) return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
} }
p.SetReadTimeout(300 * time.Millisecond) p.SetReadTimeout(300 * time.Millisecond)
@@ -112,9 +140,19 @@ func (k *Kenwood) Connect() error {
} }
if !answered { if !answered {
k.model = "" k.model = ""
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
}
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud) return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
} }
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model) // Name what was actually connected to. A log reading "connected on @ 0 baud"
// after a network connect is the kind of line that sends someone hunting a
// serial fault that does not exist.
if k.host != "" {
debugLog.Printf("kenwood: connected to %s (network serial bridge), model=%q", k.host, k.model)
} else {
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
}
return nil return nil
} }
@@ -154,6 +192,34 @@ func (k *Kenwood) ReadState() (RigState, error) {
// IF reports the frequency of the VFO in USE (what the operator hears). // IF reports the frequency of the VFO in USE (what the operator hears).
rx := f.FreqHz rx := f.FreqHz
tx := rx tx := rx
// IF's split bit is not filled in by every rig that speaks this dialect —
// reported on a Flex through its Kenwood CAT emulation, where the frequency
// reads perfectly and split never appears. So ask the question directly as
// well: split IS "the transmit VFO differs from the receive VFO", which is
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
//
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
// this costs two short commands per poll only where it actually works.
split := f.Split
if !split {
rxv, rxOK := k.askVFO("FR;")
txv, txOK := k.askVFO("FT;")
if rxOK && txOK && rxv != txv {
split = true
// Trust FR over IF for which VFO is in use: they were asked in the
// same breath, and a rig that leaves the split bit empty may be just
// as vague about the VFO field.
// f.VFO too, not just the reported state: the block below picks the
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
// disagreeing would read the transmit frequency off the wrong dial.
if rxv == "A" || rxv == "B" {
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
}
}
}
f.Split = split
if f.Split { if f.Split {
// The transmit VFO is the other one. Read it rather than assume, and fall // The transmit VFO is the other one. Read it rather than assume, and fall
// back to simplex if it cannot be read: a wrong TX frequency is written // back to simplex if it cannot be read: a wrong TX frequency is written
@@ -229,6 +295,7 @@ func (k *Kenwood) write(cmd string) error {
if k.port == nil { if k.port == nil {
return fmt.Errorf("kenwood: not connected") return fmt.Errorf("kenwood: not connected")
} }
traceText("kenwood", "TX", cmd)
_, err := k.port.Write([]byte(cmd)) _, err := k.port.Write([]byte(cmd))
return err return err
} }
@@ -264,6 +331,7 @@ func (k *Kenwood) ask(cmd string) (string, error) {
} }
frame := string(buf[:i+1]) frame := string(buf[:i+1])
buf = buf[i+1:] buf = buf[i+1:]
traceText("kenwood", "RX", frame)
if frame == "?;" { if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops // The rig rejected the command. Remember it so the poll loop stops
// paying a 600 ms timeout for it on every cycle. // paying a 600 ms timeout for it on every cycle.
@@ -411,3 +479,84 @@ var kenwoodModels = map[string]string{
"024": "TS-990S", "024": "TS-990S",
"025": "TS-890S", "025": "TS-890S",
} }
// openPort opens the serial link, or whatever dialPort provides in a test.
func (k *Kenwood) openPort() (serial.Port, error) {
if k.dialPort != nil {
return k.dialPort()
}
if k.host != "" {
c, err := net.DialTimeout("tcp", k.host, 5*time.Second)
if err != nil {
return nil, err
}
return &tcpSerial{conn: c}, nil
}
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
}
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
// code path whether the rig is on a wire or on the network.
//
// The modem-control methods are no-ops rather than errors: a bridge has no DTR
// to raise, and failing them would break a caller that sets them defensively.
type tcpSerial struct{ conn net.Conn }
func (t *tcpSerial) Read(p []byte) (int, error) {
n, err := t.conn.Read(p)
// A read deadline expiring is this transport's "no data yet", exactly what a
// serial read timeout means to the caller — not a dead link. Reporting it as
// an error would make ask() abandon a rig that is merely thinking.
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return n, nil
}
}
return n, err
}
func (t *tcpSerial) Write(p []byte) (int, error) { return t.conn.Write(p) }
func (t *tcpSerial) Close() error { return t.conn.Close() }
func (t *tcpSerial) SetReadTimeout(d time.Duration) error {
if d <= 0 {
return t.conn.SetReadDeadline(time.Time{})
}
return t.conn.SetReadDeadline(time.Now().Add(d))
}
func (t *tcpSerial) SetMode(*serial.Mode) error { return nil }
func (t *tcpSerial) Drain() error { return nil }
func (t *tcpSerial) ResetInputBuffer() error { return nil }
func (t *tcpSerial) ResetOutputBuffer() error { return nil }
func (t *tcpSerial) SetDTR(bool) error { return nil }
func (t *tcpSerial) SetRTS(bool) error { return nil }
func (t *tcpSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
return &serial.ModemStatusBits{}, nil
}
func (t *tcpSerial) Break(time.Duration) error { return nil }
// askVFO asks FR; or FT; and returns "A" or "B".
//
// The reply is FR0; / FR1; — the digit right after the two-letter command.
// Anything else (a rig that answers with more fields, or not at all) returns
// false, and the caller keeps whatever IF said rather than inventing a split.
func (k *Kenwood) askVFO(cmd string) (string, bool) {
r, err := k.ask(cmd)
if err != nil {
return "", false
}
want := cmdPrefix(cmd)
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
if body == "" {
return "", false
}
switch body[0] {
case '0':
return "A", true
case '1':
return "B", true
}
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
// nothing is better than mapping it onto A or B and reporting a split that
// does not exist.
return "", false
}
+357
View File
@@ -0,0 +1,357 @@
package cat
import (
"fmt"
"strings"
"sync"
"testing"
"time"
"go.bug.st/serial"
)
// The Kenwood backend driven against a rig that answers, with no hardware.
//
// Nobody here owns a Kenwood, and a backend that has never completed a single
// exchange is a guess however carefully it was written. But this repository
// already contains the other half of the conversation: internal/catemu ANSWERS
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
// The responder below replies exactly as catemu does — same FA/FB widths, same
// 38-character IF layout, same ID019 — so the two halves are checked against
// each other rather than against my reading of a manual.
//
// What this proves: the round trip completes, the model is identified, and
// frequency, mode, VFO and split come back correct, including the second read
// that split requires. What it cannot prove: that a real TS-590 answers on the
// same timings, or that its firmware fills every IF field the way the
// documentation says. Those still need a radio.
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
// meaning; the rest satisfy the interface.
type fakeSerial struct {
mu sync.Mutex
toRig *strings.Builder // what the backend has written
fromRig []byte // what the rig has queued for the backend
answer func(cmd string) string
closed bool
}
func (f *fakeSerial) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.closed {
return 0, fmt.Errorf("closed")
}
f.toRig.Write(p)
// A rig answers as each terminated command arrives.
for {
s := f.toRig.String()
i := strings.IndexByte(s, ';')
if i < 0 {
break
}
cmd := s[:i+1]
rest := s[i+1:]
f.toRig.Reset()
f.toRig.WriteString(rest)
if r := f.answer(cmd); r != "" {
f.fromRig = append(f.fromRig, r...)
}
}
return len(p), nil
}
func (f *fakeSerial) Read(p []byte) (int, error) {
// A real port blocks until data or timeout; the backend polls, so returning
// (0, nil) on an empty buffer models a read timeout with no bytes.
for i := 0; i < 50; i++ {
f.mu.Lock()
if f.closed {
f.mu.Unlock()
return 0, fmt.Errorf("closed")
}
if len(f.fromRig) > 0 {
n := copy(p, f.fromRig)
f.fromRig = f.fromRig[n:]
f.mu.Unlock()
return n, nil
}
f.mu.Unlock()
time.Sleep(time.Millisecond)
}
return 0, nil
}
func (f *fakeSerial) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.closed = true
return nil
}
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
func (f *fakeSerial) Drain() error { return nil }
func (f *fakeSerial) ResetInputBuffer() error { return nil }
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
func (f *fakeSerial) SetDTR(bool) error { return nil }
func (f *fakeSerial) SetRTS(bool) error { return nil }
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
return &serial.ModemStatusBits{}, nil
}
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
func (f *fakeSerial) Break(time.Duration) error { return nil }
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
// the Kenwood digit; split selects which VFO transmits.
type ts2000 struct {
vfoA, vfoB int64
mode byte
onB bool
split bool
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
// split bit — the behaviour reported on a Flex through its Kenwood CAT
// emulation, where the frequency reads perfectly and split never appears.
lazyIF bool
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
noVFOCmds bool
seen []string
}
func (r *ts2000) answer(cmd string) string {
r.seen = append(r.seen, cmd)
cur := r.vfoA
vfoDigit := byte('0')
if r.onB {
cur, vfoDigit = r.vfoB, '1'
}
switch {
case cmd == "ID;":
return "ID019;" // TS-2000, exactly what catemu reports
case cmd == "FA;":
return fmt.Sprintf("FA%011d;", r.vfoA)
case cmd == "FB;":
return fmt.Sprintf("FB%011d;", r.vfoB)
case cmd == "IF;":
split := byte('0')
if r.split && !r.lazyIF {
split = '1'
}
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
return ""
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
return ""
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
r.mode = cmd[2]
return ""
case cmd == "FR;" || cmd == "FT;":
if r.noVFOCmds {
return "?;" // rejected, as a rig that does not know the command answers
}
// FR is the receive VFO, FT the transmit one. They differ exactly when
// the rig is in split.
v := vfoDigit
if cmd == "FT;" && r.split {
if vfoDigit == '0' {
v = '1'
} else {
v = '0'
}
}
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
}
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
}
func dialTo(rig *ts2000) func() (serial.Port, error) {
return func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
}
}
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = dialTo(rig)
if err := k.Connect(); err != nil {
t.Fatalf("connect: %v", err)
}
defer k.Disconnect()
if k.model != "TS-2000" {
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
}
// Simplex on A.
s, err := k.ReadState()
if err != nil {
t.Fatalf("read: %v", err)
}
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
}
// On B: everything must follow the VFO in use, the fault that took several
// rounds to settle in the Yaesu backend.
rig.onB = true
if s, err = k.ReadState(); err != nil {
t.Fatalf("read: %v", err)
}
if s.FreqHz != 14260000 || s.Vfo != "B" {
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
}
// Tuning must write to the VFO in use, not blindly to FA.
if err := k.SetFrequency(14265000); err != nil {
t.Fatalf("set freq: %v", err)
}
if rig.vfoB != 14265000 {
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
}
if rig.vfoA != 14250000 {
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
}
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
// frequency, so the two must not be swapped — a mistake that writes the
// wrong frequency into every logged QSO.
rig.split = true
if s, err = k.ReadState(); err != nil {
t.Fatalf("read: %v", err)
}
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
s.FreqHz, s.RxFreqHz, s.Split)
}
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
rig.split = false
if err := k.SetMode("CW"); err != nil {
t.Fatalf("set mode: %v", err)
}
if rig.mode != '3' {
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
}
// PTT is a bare command, and the rig must have actually seen it.
if err := k.SetPTT(true); err != nil {
t.Fatalf("ptt: %v", err)
}
if err := k.SetPTT(false); err != nil {
t.Fatalf("ptt off: %v", err)
}
seen := strings.Join(rig.seen, " ")
for _, want := range []string{"AI0;", "TX;", "RX;"} {
if !strings.Contains(seen, want) {
t.Errorf("the rig never received %s — sent: %s", want, seen)
}
}
}
// A port that opens onto silence must be reported as such, not as a connected
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
// time on the Yaesu backend.
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
}
err := k.Connect()
if err == nil {
t.Fatal("a silent port was reported as a connected rig")
}
if !strings.Contains(err.Error(), "not answering") {
t.Errorf("error was %q — it should say the rig is not answering", err)
}
if k.model != "" {
t.Errorf("a stale model survived a failed connect: %q", k.model)
}
}
// Split found through FR/FT when the rig never fills IF's split bit.
//
// Reported on a Flex through its Kenwood CAT emulation: frequency read
// perfectly, split never appeared. Rather than guess at which IF column that
// firmware populates, ask the question that DEFINES split — is the transmit VFO
// a different VFO from the receive one — which is what FR and FT answer, and
// the same rule the Yaesu backend settled on after several wrong turns.
func TestKenwoodSplitFromFRFT(t *testing.T) {
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = dialTo(rig)
if err := k.Connect(); err != nil {
t.Fatalf("connect: %v", err)
}
defer k.Disconnect()
// Simplex: FR and FT agree, and nothing may be invented from that.
s, err := k.ReadState()
if err != nil {
t.Fatalf("read: %v", err)
}
if s.Split {
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
}
// Split on, IF still silent about it: receive on A, transmit on B.
rig.split = true
if s, err = k.ReadState(); err != nil {
t.Fatalf("read: %v", err)
}
if !s.Split {
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
}
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
}
}
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
// bit still works. The fallback must not cost a timeout on every poll, nor
// break the rigs that were already fine.
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = dialTo(rig)
if err := k.Connect(); err != nil {
t.Fatalf("connect: %v", err)
}
defer k.Disconnect()
if _, err := k.ReadState(); err != nil {
t.Fatalf("read: %v", err)
}
asked := 0
for _, c := range rig.seen {
if c == "FR;" || c == "FT;" {
asked++
}
}
if _, err := k.ReadState(); err != nil {
t.Fatalf("read: %v", err)
}
after := 0
for _, c := range rig.seen {
if c == "FR;" || c == "FT;" {
after++
}
}
if after != asked {
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
}
// IF's own split bit still drives the result on such a rig.
rig.split = true
s, err := k.ReadState()
if err != nil {
t.Fatalf("read: %v", err)
}
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
}
}
+34 -18
View File
@@ -75,37 +75,53 @@ func DebugLogPath() string {
return filepath.Join(base, "OpsLog", "cat.log") return filepath.Join(base, "OpsLog", "cat.log")
} }
// ── CI-V byte trace ──────────────────────────────────────────────────────── // ── CAT wire trace ─────────────────────────────────────────
// //
// Opt-in, off by default. Turning it on logs every CI-V frame sent and received // Opt-in, off by default. Turning it on logs every frame sent and received
// as hex, exactly like the WinKeyer protocol trace. // CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
// //
// That trace exists because a fault nobody could reason about — "the keyer sends // It exists because a fault nobody could reason about — "the keyer sends one
// one element then stalls" — was settled in one line the moment the actual bytes // element then stalls" — was settled in one line the moment the actual bytes
// were visible. The CI-V link has now produced the same class of report: a MOX // were visible, and the CAT links have since produced the same class of report:
// button that sets split instead of transmitting, on a rig whose PTT command is // a MOX button that sets split instead of transmitting, and a split the Kenwood
// demonstrably correct in the source. Guessing at that from the command table // backend does not recognise on a rig whose frequency it reads perfectly.
// has already cost this project a wrong fix; the bytes will say what the rig was //
// really asked. // Both are questions about what the RIG actually said. Guessing at that from a
var civTrace atomic.Bool // protocol document has already cost this project a wrong fix that broke the
// case which already worked.
var catTrace atomic.Bool
// SetCIVTrace turns the CI-V byte trace on or off. // SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
// was written for; it now covers the text backends too.
func SetCIVTrace(on bool) { func SetCIVTrace(on bool) {
civTrace.Store(on) catTrace.Store(on)
if on { if on {
debugLog.Printf("civ trace ON — every CI-V frame to and from the rig is logged") debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
} else { } else {
debugLog.Printf("civ trace OFF") debugLog.Printf("wire trace OFF")
} }
} }
// CIVTraceEnabled reports whether the trace is running (for the settings UI). // CIVTraceEnabled reports whether the trace is running (for the settings UI).
func CIVTraceEnabled() bool { return civTrace.Load() } func CIVTraceEnabled() bool { return catTrace.Load() }
// traceCIV logs one frame. dir is "TX" (to the rig) or "RX" (from it). // traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
func traceCIV(dir string, b []byte) { func traceCIV(dir string, b []byte) {
if !civTrace.Load() || len(b) == 0 { if !catTrace.Load() || len(b) == 0 {
return return
} }
debugLog.Printf("civ %s % X", dir, b) debugLog.Printf("civ %s % X", dir, b)
} }
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
//
// The text is QUOTED. A truncated or empty reply must be visible as such
// rather than vanish into the line: "" and ";" look identical unquoted, and
// telling them apart is the whole question when a rig answers a command it
// does not really support.
func traceText(backend, dir, s string) {
if !catTrace.Load() || s == "" {
return
}
debugLog.Printf("%s %s %q", backend, dir, s)
}
+49 -3
View File
@@ -11,6 +11,7 @@ package cluster
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"net" "net"
"regexp" "regexp"
@@ -346,12 +347,29 @@ func (s *session) run() {
} }
} }
// idleTick is how often the read is interrupted so the loop can notice a stop
// request. It is NOT a liveness test — see the read loop.
//
// A var, not a const, so the test that proves a silent node survives can shorten
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
// that gets skipped.
var idleTick = 30 * time.Second
// quietNotice is the silence after which the log says so, once.
const quietNotice = 10 * time.Minute
// runOnce dials, optionally logs in, sends init commands, parses spots. // runOnce dials, optionally logs in, sends init commands, parses spots.
// Returns the moment we marked the link "connected" (zero if dial failed) // Returns the moment we marked the link "connected" (zero if dial failed)
// and the error that ended the session (nil if stopCh). // and the error that ended the session (nil if stopCh).
func (s *session) runOnce() (time.Time, error) { func (s *session) runOnce() (time.Time, error) {
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
conn, err := net.DialTimeout("tcp", addr, 10*time.Second) // KeepAlive is set explicitly rather than left to the package default: it is
// what actually detects a dead peer here, so it should be visible in the
// code that depends on it. The OS probes an idle connection and a genuine
// failure surfaces as an error on Read — which is the only thing that ends
// a session below.
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
conn, err := d.Dial("tcp", addr)
if err != nil { if err != nil {
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err) return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
} }
@@ -423,6 +441,8 @@ func (s *session) runOnce() (time.Time, error) {
} }
var connectedAt time.Time var connectedAt time.Time
var quiet time.Duration // how long the node has said nothing
var pending string // a line cut in half by a read deadline
rd := bufio.NewReader(conn) rd := bufio.NewReader(conn)
for { for {
select { select {
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
default: default:
} }
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second)) // The deadline exists to keep this loop responsive to stopCh, NOT to
line, err := rd.ReadString('\n') // judge the link. A quiet node is a quiet node: some clusters send
// nothing for minutes on a dead band, and tearing the socket down over
// it produced a reconnect every two minutes — which loses the login,
// the filters, and any spot that arrived during the gap.
//
// Only a REAL error ends the session. A dead peer still gets caught:
// TCP keepalive probes an idle connection and its failure arrives here
// as an error, not as a timeout.
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
chunk, err := rd.ReadString('\n')
if err != nil { if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
// ReadString hands back what it HAD read along with the timeout.
// Keep it: a spot line straddling the deadline would otherwise lose
// its first half and arrive as nonsense, or vanish entirely.
pending += chunk
quiet += idleTick
// Said once at the first long silence, so a genuinely mute node is
// visible without a line every tick.
if quiet == quietNotice {
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
}
continue
}
return connectedAt, fmt.Errorf("read: %w", err) return connectedAt, fmt.Errorf("read: %w", err)
} }
quiet = 0
line := pending + chunk
pending = ""
line = strings.TrimRight(line, "\r\n") line = strings.TrimRight(line, "\r\n")
// Strip terminal BELLs (\a) and other control bytes that some nodes // Strip terminal BELLs (\a) and other control bytes that some nodes
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which // (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
+89
View File
@@ -0,0 +1,89 @@
package cluster
import (
"net"
"strconv"
"testing"
"time"
)
// A quiet cluster must not be treated as a broken one.
//
// Reported by an operator whose node sends little: nothing for two minutes and
// OpsLog reconnected — losing the login, the filters, and any spot that landed
// during the gap. The read deadline was being read as a verdict on the link
// rather than as a way to stay responsive to a stop request.
//
// The test holds the connection open, silent for longer than one deadline, then
// sends a spot. A session that survives receives it; one that reconnects does
// not, because this listener never accepts twice.
func TestSilenceDoesNotEndTheSession(t *testing.T) {
// Shorten the tick so the test exercises the real code path in seconds.
defer func(orig time.Duration) { idleTick = orig }(idleTick)
idleTick = time.Second
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
accepted := make(chan net.Conn, 2)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted <- c
}
}()
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
spots := make(chan Spot, 4)
s := &session{
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
onSpot: func(sp Spot) { spots <- sp },
onLine: func(Line) {},
onStatus: func() {},
stopCh: make(chan struct{}),
}
done := make(chan error, 1)
go func() { _, err := s.runOnce(); done <- err }()
conn := <-accepted
defer conn.Close()
// Silent for longer than one deadline: the loop must ride through it.
time.Sleep(3 * idleTick)
select {
case c := <-accepted:
c.Close()
t.Fatal("the session reconnected during the silence")
case err := <-done:
t.Fatalf("the session ended during the silence: %v", err)
default:
}
// Now a spot: it proves the ORIGINAL connection is still the live one.
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
t.Fatal(err)
}
select {
case sp := <-spots:
if sp.DXCall != "OY1CT" {
t.Errorf("spot from the wrong station: %+v", sp)
}
case <-time.After(5 * time.Second):
t.Fatal("the spot sent after the silence never arrived")
}
close(s.stopCh)
<-done
}
+77 -1
View File
@@ -125,6 +125,10 @@ type Decoder struct {
pendHops int // consecutive hops the raw state has disagreed pendHops int // consecutive hops the raw state has disagreed
// Two-cluster element timing (ms). // Two-cluster element timing (ms).
// muLetterGap is this operator's typical gap BETWEEN LETTERS (ms), so the
// word boundary can follow a wide fist instead of chopping up callsigns.
// Zero until enough gaps have been seen.
muLetterGap float64
muDit, muDah float64 muDit, muDah float64
marksSeen int marksSeen int
@@ -195,6 +199,19 @@ const (
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists) charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7) wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
// newOverFloorMs is the shortest silence that may end an over — see newOverMs.
// Past it the speed estimate is dropped: the next voice on the frequency is
// probably somebody else, and the last operator's speed is not evidence
// about them. Two seconds is far longer than any word gap (7 dits is 0.42 s
// even at 10 wpm) and far shorter than a pause between overs.
newOverFloorMs = 600.0
// wordGapRatio places the word boundary relative to the letter gaps this
// operator ACTUALLY sends, for fists whose letter spacing runs wide. The
// fixed 4.6-dit rule turned a 5-dit letter gap into a word, so a callsign
// arrived as "O Y 1 C T" — which is worse than two words run together.
wordGapRatio = 1.6
) )
// New builds a decoder for the given sample rate. onChar receives decoded text // New builds a decoder for the given sample rate. onChar receives decoded text
@@ -260,6 +277,7 @@ func (d *Decoder) Reset() {
d.stableHops, d.pendHops = 0, 0 d.stableHops, d.pendHops = 0, 0
d.bankTick, d.betterHops = 0, 0 d.bankTick, d.betterHops = 0, 0
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0 d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
d.muLetterGap = 0
d.elemMs = d.elemMs[:0] d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false d.charEmitted, d.wordEmitted, d.textSince = true, true, false
} }
@@ -578,6 +596,30 @@ func (d *Decoder) endMark(hops int) {
// steadier than dits in hand keying). // steadier than dits in hand keying).
func (d *Decoder) endSpace(hops int) { func (d *Decoder) endSpace(hops int) {
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
// A long silence ends the over — see newOverMs. Forget the speed: coming back at 14 wpm
// after following someone at 32, every element of the newcomer measured
// longer than the stale dah threshold and the first words decoded as a run
// of T's. Starting from the seed instead, the estimate re-converges within a
// character — which is exactly how a freshly started decoder behaves, and
// that case was always fine.
if d.marksSeen > 0 && ms > d.newOverMs() {
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
d.muLetterGap = 0
d.elemMs = d.elemMs[:0]
return
}
// Letter gaps: everything between a character boundary and a word boundary.
// Kept so the word boundary can follow this operator's own spacing.
if d.marksSeen > 0 && ms > charGapDits*d.muDit && ms < wordGapDits*d.muDit*2 {
if d.muLetterGap == 0 {
d.muLetterGap = ms
} else {
d.muLetterGap += (ms - d.muLetterGap) * 0.2
}
}
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit { if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
return return
} }
@@ -592,6 +634,40 @@ func (d *Decoder) endSpace(hops int) {
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs) d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
} }
// newOverMs is the silence above which the speed estimate is dropped.
//
// It cannot be a fixed duration: 0.75 s is a quick turnaround at 30 wpm but is
// barely a word gap at 10 wpm (7 dits = 0.84 s). So it scales with the current
// estimate, with a floor so a fast operator pausing to think is not mistaken
// for a new station on every hesitation.
func (d *Decoder) newOverMs() float64 {
if v := 12 * d.muDit; v > newOverFloorMs {
return v
}
return newOverFloorMs
}
// wordGapMs is the silence above which a word boundary is declared.
//
// The textbook answer is 4.6 dits — the geometric mean of a 3-dit letter gap
// and a 7-dit word gap. It assumes the operator sends textbook spacing. Many do
// not: a fist that leaves 5 dits between letters had every letter turned into a
// word, so callsigns arrived in pieces.
//
// So the boundary also follows the letter gaps actually observed. Whichever is
// larger wins: a textbook fist keeps the textbook boundary, a wide one gets a
// wider boundary. The cost is that a wide sender's words may run together —
// which is a far smaller price than a callsign broken into letters.
func (d *Decoder) wordGapMs() float64 {
fixed := wordGapDits * d.muDit
if d.muLetterGap > 0 {
if adaptive := wordGapRatio * d.muLetterGap; adaptive > fixed {
return adaptive
}
}
return fixed
}
// spaceProgress emits the pending character / word space LIVE once the current // spaceProgress emits the pending character / word space LIVE once the current
// gap crosses each boundary (instead of waiting for the next mark), so text // gap crosses each boundary (instead of waiting for the next mark), so text
// appears as it is sent. // appears as it is sent.
@@ -601,7 +677,7 @@ func (d *Decoder) spaceProgress() {
d.flushChar() d.flushChar()
d.charEmitted = true d.charEmitted = true
} }
if !d.wordEmitted && gapMs > wordGapDits*d.muDit { if !d.wordEmitted && gapMs > d.wordGapMs() {
if d.textSince && d.onChar != nil { if d.textSince && d.onChar != nil {
d.onChar(" ") d.onChar(" ")
} }
+148
View File
@@ -0,0 +1,148 @@
package cwdecode
import (
"math"
"strings"
"testing"
)
// keyLoose keys a message with SLIGHTLY WIDE letter spacing — the way a great
// many operators actually send. letterGapDits replaces the standard 3.
//
// Same envelope shaping as keyMessage: hard edges would make an easier signal
// than anything on the air, and would prove nothing.
func keyLoose(msg string, fs, wpm int, pitch, amp float64, letterGapDits float64) []int16 {
dot := fs * 1200 / (wpm * 1000)
edge := fs * 5 / 1000
c2m := charToMorse()
var out []float64
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
tone := func(n int) {
for i := 0; i < n; i++ {
g := 1.0
if i < edge {
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
} else if n-1-i < edge {
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
}
out = append(out, amp*g*math.Sin(phase))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
phase += dphi
}
}
silence(fs / 4)
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(4 * dot)
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(dot)
} else {
tone(3 * dot)
}
silence(dot)
}
// The element gap above already contributes one dit.
silence(int((letterGapDits - 1) * float64(dot)))
}
silence(fs / 2)
return toInt16(out)
}
// The first character of an over must decode, not arrive as "?".
//
// Reported on the air: "the first letter or digit often turns into ?". Each
// element is classified against the running dit estimate, and at the start of
// an over that estimate belongs to whatever was decoded last — a different
// operator, at a different speed. The first character pays for it.
func TestFirstCharacterOfAnOver(t *testing.T) {
const fs = 16000
for _, wpm := range []int{15, 22, 30} {
got := decode(t, keyMessage("F4BPO DE OY1CT K", fs, wpm, 700, 9000), 0)
if strings.HasPrefix(got, "?") {
t.Errorf("@%d wpm: decoded %q — the first character was lost", wpm, got)
}
}
}
// A callsign must not be broken up when the sender's letter spacing is a little
// wide.
//
// Reported on the air: "sometimes there are spaces inside the call". A word
// boundary is declared above 4.6 dits of silence, so an operator whose letter
// gaps run to 4.5 dits has every letter turned into a word of its own.
func TestWideLetterSpacingDoesNotSplitTheCall(t *testing.T) {
const fs = 16000
for _, gap := range []float64{3.5, 4.0, 4.5} {
got := decode(t, keyLoose("DE OY1CT K", fs, 20, 700, 9000, gap), 0)
for _, split := range []string{"O Y", "Y 1", "1 C", "C T"} {
if strings.Contains(got, split) {
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken at %q", gap, got, split)
}
}
}
}
// decodeSeq runs several transmissions through the SAME decoder, as happens on
// the air: the estimate carried into an over is whatever the previous station
// left behind.
func decodeSeq(t *testing.T, parts ...[]int16) []string {
t.Helper()
var cur strings.Builder
d := New(16000, func(s string) { cur.WriteString(s) }, nil)
out := make([]string, 0, len(parts))
for _, p := range parts {
cur.Reset()
for i := 0; i < len(p); i += 256 {
end := i + 256
if end > len(p) {
end = len(p)
}
d.Process(p[i:end])
}
out = append(out, strings.ToUpper(cur.String()))
}
return out
}
// A fast station, then a slow one — the real reason the first character is
// lost. A fresh decoder starts from a neutral estimate and adapts within a
// character or two; a decoder that has just followed someone at 30 wpm judges
// the newcomer's first dits against 30 wpm.
func TestFirstCharacterAfterASpeedChange(t *testing.T) {
const fs = 16000
got := decodeSeq(t,
keyMessage("CQ CQ DE DL1ABC K", fs, 32, 700, 9000),
keyMessage("DL1ABC DE OY1CT K", fs, 14, 700, 9000),
)
if strings.HasPrefix(got[1], "?") {
t.Errorf("after 32→14 wpm: %q — the first character of the reply was lost", got[1])
}
if !strings.Contains(got[1], "OY1CT") {
t.Errorf("after 32→14 wpm: %q — want the callsign intact", got[1])
}
}
// Hand-sent letter gaps run wide. Above 4.6 dits every letter becomes a word,
// so a callsign arrives in pieces.
func TestVeryWideLetterSpacing(t *testing.T) {
const fs = 16000
for _, gap := range []float64{5.0, 5.5, 6.0} {
got := decode(t, keyLoose("DE OY1CT K", fs, 18, 700, 9000, gap), 0)
if strings.Contains(got, "O Y") || strings.Contains(got, "Y 1") || strings.Contains(got, "1 C") {
t.Errorf("letter gap %.1f dits: decoded %q — the callsign was broken up", gap, got)
}
}
}
+198
View File
@@ -0,0 +1,198 @@
package extsvc
// Withdrawing a QSO from the external services after it is deleted locally.
//
// The two services could hardly be less alike, and the difference decides what
// is possible for QSOs already in the log:
//
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
// started recording that identifier therefore cannot be withdrawn from
// here; the operator has to do it on the website. QRZ's own documentation
// puts it plainly: "There is no undo from this operation."
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
// the band. Nothing has to have been stored in advance, so this works for
// every QSO in the log, past or future.
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
const clublogDeleteURL = "https://clublog.org/delete.php"
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
//
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
//
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
// the QSO is being deleted locally either way, and "it was already gone from
// QRZ" is the outcome the operator wanted. The message is returned so the
// caller can log what actually happened.
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", fmt.Errorf("qrz: api key not set")
}
ids := make([]string, 0, len(logIDs))
for _, id := range logIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "DELETE")
form.Set("LOGIDS", strings.Join(ids, ","))
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
if err != nil {
return "", fmt.Errorf("qrz: %w", err)
}
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
vals, _ := url.ParseQuery(strings.TrimSpace(body))
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
count := strings.TrimSpace(vals.Get("COUNT"))
switch result {
case "OK", "PARTIAL", "FAIL":
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
case "AUTH":
return "", fmt.Errorf("qrz: the logbook key was refused")
}
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
}
// DeleteClublog removes one QSO from a Club Log logbook.
//
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
// second, so a rounded or local-time value simply finds nothing. band is an
// ADIF band name ("20m"), translated below to the band number Club Log wants.
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
dx := strings.ToUpper(strings.TrimSpace(dxCall))
switch {
case email == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case call == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
case dx == "":
return "", fmt.Errorf("clublog: no callsign to delete")
case whenUTC.IsZero():
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
}
bandID, ok := clublogBandID(band)
if !ok {
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("dxcall", dx)
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
form.Set("bandid", strconv.Itoa(bandID))
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
if err != nil {
return "", fmt.Errorf("clublog: %w", err)
}
msg := strings.TrimSpace(body)
LogSink("clublog: DELETE raw response: %s", msg)
// "QSO Not Deleted" means no record matched. That is not an error worth
// stopping for — the QSO is going away locally regardless, and a QSO absent
// from Club Log is the state being asked for.
return msg, nil
}
// clublogBandID maps an ADIF band name to Club Log's band number.
//
// The list is Club Log's own, and it is deliberately NOT derived from the
// frequency: Club Log accepts these values and no others, so a band outside it
// must be reported rather than approximated to a neighbour.
func clublogBandID(band string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 160, true
case "80m":
return 80, true
case "60m":
return 60, true
case "40m":
return 40, true
case "30m":
return 30, true
case "20m":
return 20, true
case "17m":
return 17, true
case "15m":
return 15, true
case "12m":
return 12, true
case "10m":
return 10, true
case "6m":
return 6, true
case "4m":
return 4, true
case "2m":
return 2, true
case "70cm":
return 70, true
case "23cm":
return 23, true
case "13cm":
return 13, true
}
return 0, false
}
// postForm is the shared request/read for both deletes.
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: timeout}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
b := string(raw)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
}
return b, nil
}
+32
View File
@@ -0,0 +1,32 @@
package extsvc
import "testing"
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
// that maps to the wrong number does not fail — it points the delete at a
// different QSO, or at nothing, silently. So the table is pinned.
func TestClublogBandID(t *testing.T) {
for _, c := range []struct {
band string
want int
}{
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
{"6m", 6}, {"4m", 4}, {"2m", 2},
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
} {
got, ok := clublogBandID(c.band)
if !ok || got != c.want {
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
}
}
// Bands Club Log does not accept must be REFUSED, never approximated to a
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
if id, ok := clublogBandID(bad); ok {
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
}
}
}
+7
View File
@@ -128,6 +128,13 @@ type ExternalServices struct {
HRDLog ServiceConfig `json:"hrdlog"` HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"` EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"` Cloudlog ServiceConfig `json:"cloudlog"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
// it is deleted locally. Off unless the operator turns it on: neither
// service can undo it, and a local delete is not always meant to reach the
// world — a duplicate cleared from a contest log was never meant to be a
// statement about the DX's log.
DeleteRemote bool `json:"delete_remote"`
} }
// UploadResult is the outcome of a single upload attempt. // UploadResult is the outcome of a single upload attempt.
+17
View File
@@ -2506,6 +2506,23 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil return nil
} }
// ClearQRZConfirmed takes back a QRZ confirmation.
//
// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status,
// which QRZ puts on everything it hands back, as QRZ's confirmation. Those
// QSOs count towards award slots, so leaving them green until the operator
// notices is not an option — and nothing else in the app would ever undo them.
func (r *Repo) ClearQRZConfirmed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_download_status = 'N', qrzcom_qso_download_date = NULL,
updated_at = ? WHERE id = ?`,
db.NowISO(), id)
if err != nil {
return fmt.Errorf("clear qrz confirmed %d: %w", id, err)
}
return nil
}
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after // MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
// an eQSL Inbox download. date is an ADIF YYYYMMDD string. // an eQSL Inbox download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
+8
View File
@@ -629,3 +629,11 @@ func isTimeout(err error) bool {
} }
return strings.Contains(strings.ToLower(err.Error()), "timeout") return strings.Contains(strings.ToLower(err.Error()), "timeout")
} }
// TraceEnabled reports whether the protocol trace is running.
//
// The settings dialog needs it: the checkbox is React state that starts false on
// every mount, so a trace switched on stayed on in the backend while the box
// reappeared unticked — the operator ticks it again, which switches it OFF, and
// the log they then send has no trace in it at all.
func TraceEnabled() bool { return traceOn.Load() }
+24 -7
View File
@@ -6,12 +6,22 @@ import (
"hamlog/internal/adif" "hamlog/internal/adif"
) )
// What counts as a QRZ.com confirmation. // What counts as a QRZ.com confirmation: app_qrzlog_status = C, and nothing else.
// //
// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The // Narrowed twice, each time on evidence from an operator's own log.
// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag, //
// which they uploaded to QRZ themselves, so every QSO with a card came back // 2026-07-29 — qsl_rcvd = Y was accepted. That is the operator's own PAPER QSL
// looking QRZ-confirmed. On a log full of paper QSLs that is most of it. // flag, which they uploaded to QRZ themselves, so every QSO with a card came
// back looking QRZ-confirmed.
//
// 2026-07-31 — qrzcom_qso_download_status = Y was accepted, and this test said
// it should be. A fetch showed both fields on ONE record:
//
// <app_qrzlog_status:1>N <qrzcom_qso_download_status:1>Y
//
// QRZ says not confirmed and sets the download field anyway: it marks what was
// handed back, not what was confirmed. Eighteen QSOs, all "UPDATED", all green,
// none of them confirmed on QRZ.
// //
// This status feeds the award slots, so a false Y is a QSO counted as confirmed // This status feeds the award slots, so a false Y is a QSO counted as confirmed
// when it is not — the reason the rule is narrow rather than generous. // when it is not — the reason the rule is narrow rather than generous.
@@ -21,8 +31,7 @@ func TestQRZRecordConfirmed(t *testing.T) {
rec adif.Record rec adif.Record
want bool want bool
}{ }{
// QRZ's own statements. // QRZ's own statement.
{"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true},
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true}, {"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true}, {"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
{"padded", adif.Record{"app_qrzlog_status": " C "}, true}, {"padded", adif.Record{"app_qrzlog_status": " C "}, true},
@@ -33,6 +42,14 @@ func TestQRZRecordConfirmed(t *testing.T) {
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false}, {"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false}, {"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
// The download flag is not an answer: QRZ sets it on everything it returns.
{"downloaded, not confirmed", adif.Record{"qrzcom_qso_download_status": "Y"}, false},
{"the record from the report", adif.Record{
"call": "IQ4J", "band": "17m", "mode": "FT8",
"qrzcom_qso_upload_status": "Y", "app_qrzlog_status": "N",
"qrzcom_qso_download_date": "20260731", "qrzcom_qso_download_status": "Y",
}, false},
// Explicit negatives and nothing at all. // Explicit negatives and nothing at all.
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false}, {"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false}, {"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
+22
View File
@@ -0,0 +1,22 @@
package main
import "testing"
// Which modes produce a recording worth keeping.
//
// CW has moved in and out of this list: it was excluded because SmartSDR does
// not route the operator's own sidetone through DAX, then restored because the
// other station is captured all the same. Pinned so it does not drift back.
func TestRecordableMode(t *testing.T) {
for _, m := range []string{"SSB", "USB", "LSB", "AM", "FM", "DV", "CW", "cw", " CW "} {
if !recordableMode(m) {
t.Errorf("recordableMode(%q) = false — it should be recorded", m)
}
}
// Digital audio is a modem tone nobody replays.
for _, m := range []string{"FT8", "FT4", "RTTY", "PSK31", "JT65", "", "DIGU"} {
if recordableMode(m) {
t.Errorf("recordableMode(%q) = true — digital modes carry no useful audio", m)
}
}
}
+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.22.2" appVersion = "0.22.6"
// 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.