Compare commits

..
39 Commits
Author SHA1 Message Date
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
rouggy 712a124081 chore: release v0.22.2 2026-07-30 15:14:32 +02:00
rouggy 08a932c9c1 fix: drop the per-column filters from the recent-QSO grid
They searched only the rows on screen. With the grid holding the most recent
page, filtering a column for RSGB-IOTA across a 28 000-QSO log returned nothing
— which reads as "the log does not contain it", not "it is not on this page".
That is how an import that had worked perfectly came to be reported as broken:
the tool answered a narrower question than the one being asked, and said so
only by being empty.

The advanced filter queries the whole logbook and is the honest instrument. The
headers also get back the width the filter menu icon was taking, which is the
smaller reason but a welcome one on a grid this dense.

No saved filter can be stranded by this: the grid persists widths, sort and
visibility, never a filter model.
2026-07-30 15:13:25 +02:00
rouggy fee2303625 test: pin the whole ADIF import path, not just the conversion step
CONTEST_ID was reported as not imported. It is imported: this drives an
operator's own IOTA-contest record through a real SQLite logbook and reads the
row back, confirming contest_id=RSGB-IOTA and iota=EU005 in the database.

The conversion-level test was already green while the report stood, which is
exactly why it was not enough — a promoted field can be dropped at any of five
places between the file and the grid, and only the round trip says which. The
answer here turned out to be none of them: the Contest columns are simply hidden
by default in the QSO grid.
2026-07-30 15:07:48 +02:00
rouggy 4d5c5c3eb3 feat: move ADIF fields on import, for contest exports that misplace the exchange
An operator worked the RSGB IOTA contest in another logger and got back records
carrying <STATE:5>EU005: N1MM-class software stores the received exchange in the
column its contest module uses, not the one ADIF reserves. Imported verbatim,
every QSO gains a US-state field reading "EU005" and the IOTA award sees
nothing — the reference is in the log, just not where anything looks for it.

The import dialog gains optional source → destination rows, prefilled with
STATE → IOTA since that is the case that prompted it. Applied to the RAW record
before conversion, so it works for promoted columns and Extras alike and the
destination is parsed exactly as if the file had carried it there.

Two rules, both from the same principle that the file outranks our guess:
  - the source is CLEARED, so a reference does not linger in STATE where it
    would show as the contacted station's US state in the grid and every export;
  - a destination the file already filled is kept, judged against the record as
    it ARRIVED — otherwise the result would depend on Go's map iteration order,
    which is deliberately random.

The swap case in the test is what forced that second rule to be stated: "never
overwrite" and "exchange two fields" cannot both hold silently, so a destination
that is itself a mapping source is treated as an explicit swap and nothing else
is overwritten.
2026-07-30 14:57:05 +02:00
rouggy d322ea9a07 feat: report UI crashes instead of showing a blank window
Two white-screen reports are open — one after logging a 10 GHz QSO, one on
starting a DVK auto-call — and neither could be investigated, for the same
reason: a render error emptied the window and left NOTHING. No line in
opslog.log, no dialog, nothing the operator could send but the words "white
screen". A fault the user cannot report is a fault that cannot be fixed.

So before hunting either trigger, the reporting path:

  - An error boundary catches a render throw, writes the error and component
    stack to the app log through the new LogUIError binding, and shows it on
    screen — selectable, with Copy and Reload.
  - Global handlers catch what a boundary cannot: throws from timers, event
    handlers and rejected promises. An auto-call loop lives entirely in
    callbacks, which is exactly where the second report came from, and those
    leave no trace at all today.

It deliberately does not try to resume: React cannot promise sane state after a
render throw, and a half-working logger would be worse than a clear stop.

This does not fix either crash. It makes the next occurrence arrive with its
cause attached, which is the step that has been missing.
2026-07-30 14:36:19 +02:00
rouggy 75c02ea2a0 feat: CI-V byte trace, for a fault the command table cannot explain
Reported on an IC-9100: the MOX button does not transmit, it sets split. The
source sends the PTT command (0x1C 0x00) and nowhere sends the split one (0x0F),
so the discrepancy is between what OpsLog sends and what the rig acts on — and
that link has already been shown, in the same operator's log, to lose frame sync.

Rather than guess from the command table, this adds what settled the WinKeyer
bug in one line: the actual bytes. Settings → CAT → Log the CI-V protocol writes
every frame in both directions as hex. RX is traced BEFORE framing, since the
bytes as they arrived are what reveals a lost boundary — a decoded view would
hide exactly the fault being hunted.

Session-only, like the keyer trace: it is a diagnostic, and a log full of hex
helps nobody who forgot it was on.

I am not claiming a cause yet. The last time I inferred one from a protocol
document rather than from evidence, I inverted a digit and broke the case that
worked.
2026-07-30 14:30:05 +02:00
rouggy ef84b04c99 fix: CI-V frames read at the wrong offset decoded as plausible frequencies
From F4JND's log: a rig sitting on 145.550 MHz FM produced status lines reading
16445550000, 8364550000, 5817408364 and 12640 Hz between good readings — the last
of those even resolved to a 6 cm band.

BCDToFreq treated the nibbles 0x0A..0x0F as the digits 10..15, so a byte stream
that had lost frame sync still decoded into a number. The same log shows why sync
was lost: PTT round trips timing out and a scope stream the rig kept rejecting
(0x27), both competing with the poll on one serial line.

Non-decimal nibbles are now refused. Each caller keeps its last good value rather
than publishing noise: the frequency reader returns an error, the sub-VFO reader
returns not-ok, and the scope keeps its previous edges. Garbage that LOOKS like a
frequency is worse than a refused frame — it reaches the display, the band slots
and eventually the log, and it is the operator who then has to disprove it.

The test builds its fixture with FreqToBCD rather than by hand, because my
hand-written one was byte-reversed and the test caught it.

This does NOT explain the white screen that was also reported, and I have not
claimed it does: it removes one source of absurd values reaching the UI, which is
worth doing on its own evidence.
2026-07-30 14:05:30 +02:00
rouggy fbd6cf400f docs: open 0.22.2 — five entries described work absent from the 0.22.1 build
The 0.22.1 tag was cut at d98bb2e, and six commits landed after it. Five of them
had appended their entries to the 0.22.1 block, so the released changelog
promised things the released binary does not contain: the 4O3A marks, the
status-bar logbook path, the Yaesu per-VFO mode, ERC rotator support and the
Kenwood backend.

Those five move to a new 0.22.2 block, in the order they were written. The
0.22.1 block is restored to exactly the twelve entries the published tag holds —
verified against `git show v0.22.1:changelog.json` rather than by eye.

Worth noting for next time: entries are appended as work lands, so any commit
after the release tag needs its own block opened first. The mistake is silent —
nothing fails, the What's New dialog simply describes a version that never
shipped those changes.
2026-07-30 12:00:45 +02:00
rouggy b16cb973f2 feat: native Kenwood CAT backend
A sixth CAT backend, talking to a TS-590/890/990/2000 over its serial port with
no OmniRig in between — frequency, mode, VFO, split and PTT. Elecraft K3/K4 and
the "Kenwood" setting on other radios speak the same dialect.

The protocol was not researched from scratch: internal/catemu already ANSWERS
these commands, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
The IF frame layout here is read from the same format string catemu emits, so
the two halves of the repository agree by construction — and the test caught my
own fixture being one character short, which is exactly the error that layout
invites.

Design notes worth keeping:

  - IF; is the poll. One frame carries frequency, TX state, mode, VFO and split,
    so simplex operation costs a single round trip; the other VFO is only asked
    for when split is actually on.
  - FA/FB take ELEVEN digits here where Yaesu uses nine. That is the likeliest
    place to copy the Yaesu backend and be wrong by a factor of a hundred, so it
    has its own test.
  - Every lesson the Yaesu backend learned the hard way is built in from the
    start: replies matched to the command that asked, "?;" remembered so a poll
    stops paying a timeout for an unsupported command, the serial handle closed
    before reopening, and a rig that answers nothing reported as absent rather
    than "connected".

Untested on hardware — I have no Kenwood here. The frame parsing is covered by
tests against catemu's own format.
2026-07-30 11:56:41 +02:00
rouggy da886de189 feat: support ERC rotator controllers through the existing GS-232A backend
An ERC (Easy Rotor Control, incl. ERC Mini) emulates Yaesu GS-232A/B, which is
exactly what the backend written for the microHAM ARCO already speaks — azimuth
out (Maaa), azimuth in (C), stop (S), which is the whole of what was asked for.
So this is two small gaps rather than a new backend:

  - Serial speed was hardcoded to 9600. An ARCO's virtual COM ignores it, but an
    ERC runs at whatever its own configuration sets, and a mismatch reads as a
    dead rotator. It is now selectable beside the COM port.
  - The entry was called "microHAM ARCO", so an ERC owner would never have found
    it. It is named after the PROTOCOL now: "GS-232A controller (microHAM ARCO,
    ERC…)".

The help text states the condition plainly in both languages, because it is the
one thing that will otherwise waste an evening: an ERC left on Hy-Gain DCU-1
speaks a different command set and simply will not answer.

Untested on hardware — I have no ERC here, and the GS-232 path itself is proven
on a real ARCO.
2026-07-30 11:07:57 +02:00
rouggy 7759e09b4e fix: Yaesu mode commands always addressed the main receiver
Reported, and reasoned out from the half that already worked: a spot click now
tunes the right VFO, but it set the mode with MD0 — main — whatever VFO the
operator was on. Working from SUB, the spot changed the mode of the VFO NOT in
use and left the one in use as it was. The author inferred the sub half from
seeing main change, and was right.

The read had the same fault, so the console displayed main's mode while showing
the sub VFO's frequency.

Mode now addresses the receiver in use — MD0 for main, MD1 for sub — in the poll
read, in SetMode, and in the console's raw-mode setter. A rig without a sub
receiver never sees MD1: curVFO only becomes B where the rig reports its receive
VFO in the first place.
2026-07-30 10:10:43 +02:00
rouggy a8990ff29d fix: the status bar named the settings database, not the logbook
Spotted by the author: on SQLite the database chip showed a.dbPath — the
settings/profile database — under a tooltip reading "Local SQLite logbook".

Those became two separate files when the logbook was split out, so an operator
checking where their QSOs live, or which file to copy before a trip, was pointed
at the wrong one. MySQL was already right.

It now resolves the logbook the same way connectLogbook does: the active
profile's own file if it names one, then the default logbook.db, and the settings
db only in the case where it genuinely doubles as the logbook.

Also moves the 4O3A mark beside its menu label instead of the far edge of the
row, as asked.
2026-07-29 23:53:18 +02:00
rouggy e3b810b4ff feat: draw the actual 4O3A mark in the sidebar, not a text badge
The wordmark I put there was not what the author expected: the brand is "4", a
green waveform of stacked lenses, then "3A".

Only the waveform is SVG. The digits stay HTML text so they keep the sidebar's
own typeface at any theme or zoom — an SVG <text> would pick its own font and
drift from the menu around it. Seven ellipses, tallest in the middle, match the
shape of the mark.

Still drawn rather than fetched: an image off the web has no verifiable
provenance, and this stays one small component to swap if the official SVG is
preferred.
2026-07-29 23:37:57 +02:00
rouggy 337392bb6d feat: mark the 4O3A panels in the settings sidebar
Antenna Genius and Tuner Genius now carry a small 4O3A wordmark, so the two
panels that drive that hardware are recognisable without reading the labels.

Drawn inline rather than shipped as an image file, following the language
picker's flags: it scales with the row, follows the theme through currentColor,
and adds no binary asset to the repository.

It is a plain wordmark, not the manufacturer's logo artwork — I did not pull an
image off the web, since I cannot verify the provenance or licence of what I
would find, and redistributing a company's mark inside the product is the
author's call. The component is one function and takes the official SVG in its
place if that is preferred.

Not applied to the Amplifier panel: it covers both the 4O3A PowerGenius XL and
the SPE Expert range, so a single vendor mark there would be wrong.
2026-07-29 23:28:27 +02:00
42 changed files with 11526 additions and 155 deletions
+411 -30
View File
@@ -116,6 +116,9 @@ const (
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
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)
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)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
@@ -178,7 +181,8 @@ const (
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
keyRotatorComPort = "rotator.com_port" // arco serial transport
keyRotatorComPort = "rotator.com_port" // GS-232 serial transport
keyRotatorBaud = "rotator.baud" // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the
@@ -268,7 +272,10 @@ const (
// External services (logbook upload). QRZ.com first; Clublog / LoTW
// 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"
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
@@ -356,21 +363,25 @@ type CATSettings struct {
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
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)
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
// adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP.
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
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
@@ -530,10 +541,15 @@ type App struct {
ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
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
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
// qsoRecManual marks a take the operator started by hand while automatic
// recording is OFF. Such a take opened the sound devices itself, so it must
// 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
// session (in-memory only — active stations currently in QSO).
@@ -1262,6 +1278,27 @@ type StartupStatus struct {
// GetStartupStatus exposes whatever happened during startup so the UI
// can show a useful error instead of just "db not initialized".
// LogUIError records a crash or unhandled error from the interface in the app
// log, with whatever context the window can give.
//
// Until this existed, a render error emptied the window and left NOTHING: no
// line in opslog.log, no dialog. Two separate white-screen reports — one after
// logging a 10 GHz QSO, one on starting an auto-call — could not be diagnosed at
// all, because the only evidence lived in a console the operator had to know to
// open. A fault the user cannot report is a fault that cannot be fixed.
func (a *App) LogUIError(kind, message, stack string) {
msg := strings.TrimSpace(message)
if msg == "" {
msg = "(no message)"
}
applog.Printf("ui %s: %s", strings.TrimSpace(kind), msg)
if st := strings.TrimSpace(stack); st != "" {
// The stack goes in whole: minified frames are still the difference between
// "somewhere in the app" and one component.
applog.Printf("ui stack: %s", st)
}
}
func (a *App) GetStartupStatus() StartupStatus {
return StartupStatus{
OK: a.startupErr == "",
@@ -1893,7 +1930,27 @@ func (a *App) GetDBConnectionInfo() DBConnectionInfo {
}
return DBConnectionInfo{Backend: "mysql", Label: "MySQL"}
}
return DBConnectionInfo{Backend: "sqlite", Label: a.dbPath}
// The LOGBOOK file, not the settings database.
//
// This reported a.dbPath — the settings/profile database — under a tooltip
// reading "Local SQLite logbook". The two are separate files since the logbook
// split, so an operator checking where their QSOs live, or which file to back
// up, was pointed at the wrong one.
//
// Same resolution order as connectLogbook: the active profile's own file if it
// names one, then the default logbook.db, and only if neither exists does the
// settings db genuinely serve as the logbook.
lp := ""
if p, err := a.profiles.Active(a.ctx); err == nil {
lp = strings.TrimSpace(p.DB.Path)
}
if lp == "" {
lp = a.logbookPath
}
if lp == "" {
lp = a.dbPath
}
return DBConnectionInfo{Backend: "sqlite", Label: lp}
}
// connectLogbook opens the logbook connection for a profile's DB target: a
@@ -5459,15 +5516,77 @@ func (a *App) DeleteQSO(id int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
a.deleteRemoteCopies([]int64{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
// number actually deleted.
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
a.deleteRemoteCopies(ids)
return a.qso.DeleteMany(a.ctx, ids)
}
@@ -5879,11 +5998,17 @@ func (a *App) OpenADIFFile() (string, error) {
// Log4OM / LoTW without clobbering fields the file omits
// - "all" : insert every record, duplicates included
//
// fieldMap moves ADIF fields before conversion (source → destination, ADIF tag
// names in any case). Contest exports put the exchange where their module keeps
// it rather than where ADIF says: the RSGB IOTA contest arrives with the island
// reference in STATE, which imports as a US state and leaves the IOTA award
// empty. The destination is only filled when the file left it blank.
//
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
// cty.dat for every record, overriding what the file carries — corrects the
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
// Russia). Everything else in the ADIF is still preserved verbatim.
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) {
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool, fieldMap map[string]string) (adif.ImportResult, error) {
if a.qso == nil {
return adif.ImportResult{}, fmt.Errorf("db not initialized")
}
@@ -5897,6 +6022,18 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
// descriptive metadata and safe to fill (identity fields are still left
// alone, see applyStationDefaults).
im := &adif.Importer{Repo: a.qso}
if len(fieldMap) > 0 {
lower := make(map[string]string, len(fieldMap))
for from, to := range fieldMap {
from = strings.ToLower(strings.TrimSpace(from))
to = strings.ToLower(strings.TrimSpace(to))
if from != "" && to != "" && from != to {
lower[from] = to
}
}
im.FieldMap = lower
applog.Printf("import: field mapping %v", lower)
}
switch dupMode {
case "update":
im.UpdateDuplicates = true
@@ -6589,7 +6726,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, 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 {
return CATSettings{}, err
}
@@ -6607,6 +6744,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
XieguAddr: cat.XieguDefaultAddr,
YaesuPort: m[keyCATYaesuPort],
YaesuBaud: 38400,
KenwoodPort: m[keyCATKenwoodPort],
KenwoodHost: m[keyCATKenwoodHost],
KenwoodBaud: 9600,
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
@@ -6644,6 +6784,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
out.YaesuBaud = n
}
if n, _ := strconv.Atoi(m[keyCATKenwoodBaud]); n > 0 {
out.KenwoodBaud = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n
}
@@ -6697,6 +6840,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.YaesuBaud <= 0 {
s.YaesuBaud = 38400
}
if s.KenwoodBaud <= 0 {
s.KenwoodBaud = 9600
}
if s.IcomBaud <= 0 {
s.IcomBaud = 115200
}
@@ -6754,6 +6900,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
@@ -6980,10 +7129,20 @@ func (a *App) saveQSORecording(q *qso.QSO) {
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() {
applog.Printf("qso-rec: nothing saved for %s — no recording was running at log time", q.Callsign)
return
}
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
return
}
@@ -7457,12 +7616,146 @@ func (a *App) QSOAudioResetClock() bool {
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 {
if a.qsoRec == nil || a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
if !a.qsoRec.Paused() {
return fmt.Errorf("stop the recording before playing it on the air")
}
pcm, err := a.qsoRec.PeekQSO()
if err != nil {
return err
}
// 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, _ := a.GetAudioSettings()
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.TXGain); err != nil {
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
// abandoned without logging).
func (a *App) QSOAudioCancel() {
if a.qsoRec != nil {
a.qsoRec.DiscardQSO()
}
a.stopManualQSORecorder()
}
// RestartQSORecorder applies new audio settings to the running recorder.
@@ -8860,10 +9153,11 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
if err != nil {
return out
}
out.DeleteRemote = m[keyExtDeleteRemote] == "1"
out.QRZ = extsvc.ServiceConfig{
APIKey: m[keyExtQRZAPIKey],
ForceStationCallsign: m[keyExtQRZForceCall],
@@ -9005,8 +9299,13 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if cfg.Cloudlog.AutoUpload {
clgAuto = "1"
}
delRemote := "0"
if cfg.DeleteRemote {
delRemote = "1"
}
scope := a.profileScope() // write under the active profile's prefix
for k, v := range map[string]string{
keyExtDeleteRemote: delRemote,
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
keyExtQRZAutoUpload: auto,
@@ -10188,6 +10487,23 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
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
// the configured providers) for each QSO id and overwrites the geographic
// + entity fields (country, continent, DXCC, zones, grid, state, county)
@@ -10198,13 +10514,23 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
return 0, fmt.Errorf("not initialized")
}
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)
if err != nil || q.Callsign == "" {
a.emitBulkProgress("qrz", i+1, total, "")
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)
if err != nil {
a.emitBulkProgress("qrz", i+1, total, "")
continue
}
if r.Country != "" {
@@ -10256,6 +10582,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
if err := a.qso.Update(a.ctx, q); err == nil {
changed++
}
a.emitBulkProgress("qrz", i+1, total, "")
}
if changed > 0 {
a.invalidateAwardStats()
@@ -10457,6 +10784,17 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if a.qso == nil {
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
switch svc {
case extsvc.ServiceQRZ:
@@ -12180,6 +12518,19 @@ func (a *App) reloadCAT() {
// (a rig file that hides the VFO, a Freq property meaning A on one model
// and B on another); talking to the radio directly removes it.
a.cat.Start(cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault))
case "kenwood":
// Native Kenwood CAT — TS-590/890/990/2000 and everything that speaks the
// 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
// single round trip where OmniRig needed a rig file to describe each one.
// 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":
// Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol the network backend reuses for remote.
@@ -12530,8 +12881,11 @@ type RotatorSettings struct {
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
ComPort string `json:"com_port"` // arco serial transport
Transport string `json:"transport"` // GS-232 controller: "tcp" (LAN) | "serial" (COM)
ComPort string `json:"com_port"` // GS-232 serial transport
// Baud for the serial transport. An ARCO's virtual COM ignores it; an ERC runs
// at the rate set in its own configuration, so it has to be selectable.
Baud int `json:"baud"`
}
// GetRotatorSettings returns the persisted rotator config with defaults.
@@ -12542,7 +12896,7 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
}
m, err := a.settings.GetMany(a.ctx,
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
keyRotatorTransport, keyRotatorComPort)
keyRotatorTransport, keyRotatorComPort, keyRotatorBaud)
if err != nil {
return out, err
}
@@ -12569,6 +12923,10 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
out.Transport = "serial"
}
out.ComPort = m[keyRotatorComPort]
out.Baud = 9600
if b, _ := strconv.Atoi(m[keyRotatorBaud]); b > 0 {
out.Baud = b
}
return out, nil
}
@@ -12602,6 +12960,7 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
keyRotatorNum: strconv.Itoa(s.RotatorNum),
keyRotatorTransport: s.Transport,
keyRotatorComPort: strings.TrimSpace(s.ComPort),
keyRotatorBaud: strconv.Itoa(s.Baud),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -12614,7 +12973,7 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
func arcoClient(s RotatorSettings) *gs232.Client {
if s.Transport == "serial" {
return gs232.NewSerial(s.ComPort)
return gs232.NewSerial(s.ComPort, s.Baud)
}
return gs232.New(s.Host, s.Port)
}
@@ -14379,6 +14738,28 @@ func (a *App) SetWinkeyerTrace(on bool) {
winkeyer.SetTrace(on)
}
// SetCIVTrace turns the byte-level CI-V trace on or off.
//
// Same reasoning as the WinKeyer trace, and prompted by the same shape of
// report: an IC-9100 whose MOX button sets split instead of transmitting, while
// the source demonstrably sends the PTT command (0x1C 0x00) and not the split
// one (0x0F). Something between the two is not what it appears, and on a link
// already shown to lose frame sync, the bytes are the only witness.
func (a *App) SetCIVTrace(on bool) {
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.
func (a *App) WinkeyerConnect() error {
if a.winkeyer == nil {
+68
View File
@@ -1,4 +1,72 @@
[
{
"version": "0.22.3",
"date": "",
"en": [
"The Kenwood backend is now exercised against a rig that answers: OpsLog talks to the TS-2000 emulator it already carries for ACOM amplifiers, so frequency, mode, VFO, split and PTT are checked end to end. Testing on a real Kenwood is still needed, but the dialogue itself is no longer untried.",
"Kenwood: split is now detected from the transmit and receive VFO (FR/FT) when the radio leaves it out of its status frame — the case seen on a Flex in Kenwood CAT mode, where the frequency read correctly but split never appeared. Radios that report it in the status frame are unaffected.",
"The CAT protocol trace covers the Kenwood backend as well, not just CI-V. Settings → CAT.",
"Updating selected QSOs from the callsign databases now shows a progress bar with the callsign being queried, in a corner rather than a dialog so the rest of OpsLog stays usable — a contest log is thousands of contacts and one network round trip each. The menu entry is renamed \"Update from the callsign databases\": it has always queried every configured provider, QRZ.com then HamQTH.",
"The world map can show the grey line: the day/night terminator with its twilight band, redrawn every minute. Button at the top right of the map, off by default, and the choice is remembered.",
"The protocol trace checkboxes (CAT and WinKeyer) now show whether the trace is really running. They came back unticked on a trace that was still on, so ticking the box to enable it actually switched it off and the log sent afterwards contained no trace.",
"A radio reporting LSB or USB now selects SSB in the entry form. Radios report the sideband, the mode list holds SSB, so nothing matched and the mode stayed empty while the frequency tracked correctly. It also keeps the logged mode ADIF-valid: LSB and USB are submodes there, not modes.",
"Kenwood CAT can go over the network: give a host:port instead of a COM port and OpsLog talks to a serial bridge (ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio). This is not the radio's own RJ45, which speaks Kenwood's KNS protocol.",
"The CAT backend list is 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 but the audio devices configured, a red dot appears where the recording counter goes. Click it to record the contact by hand: the counter starts, the dot disappears, and logging the QSO saves the file as usual. The sound devices are released again afterwards.",
"A recording in progress can be stopped and TRANSMITTED to the station being worked, keyed and sent like a voice-keyer message — for when they ask to hear their own signal. The audio is kept and still saved with the QSO, and recording can be resumed.",
"The award reference table can be sorted by reference (the default) or by description — click either heading, click again to reverse. Both the grid and list views follow.",
"Deleting a QSO can now withdraw it from QRZ.com and Club Log as well — Settings → External services, off by default. Club Log matches on callsign, time and band so it works for any QSO; QRZ.com can only remove records OpsLog uploaded itself, since its API deletes by record number only.",
"QSL and upload status columns are coloured: Y green, N red, R blue. Colour only, no badges. Applies everywhere the QSO table is used — recent QSOs, worked before, NET Control and the QSL manager.",
"Settings → General chooses how dates are displayed: Standard (2026-07-30), French (30-07-2026) or US (07-30-2026). Display only — the log is always stored in the ADIF standard format, and exports are unaffected.",
"New award: DARC DOK Award (DLD), the German Deutschland-Diplom, matched on the DOK reference in the QSO."
],
"fr": [
"Le backend Kenwood est désormais éprouvé face à une radio qui répond : OpsLog dialogue avec l'émulateur TS-2000 qu'il embarque déjà pour les amplis ACOM, ce qui vérifie fréquence, mode, VFO, split et PTT de bout en bout. Un essai sur un vrai Kenwood reste nécessaire, mais le dialogue n'est plus non testé.",
"Kenwood : le split est désormais détecté à partir des VFO d'émission et de réception (FR/FT) quand la radio ne le renseigne pas dans sa trame d'état — le cas observé sur un Flex en mode CAT Kenwood, où la fréquence était juste mais le split n'apparaissait jamais. Les radios qui le signalent normalement ne changent pas.",
"La trace du protocole CAT couvre aussi le backend Kenwood, plus seulement le CI-V. Réglages → CAT.",
"La mise à jour des QSO sélectionnés depuis les annuaires affiche désormais une barre de progression avec l'indicatif en cours d'interrogation, dans un coin plutôt qu'en fenêtre : le reste d'OpsLog reste utilisable — un log de concours, c'est des milliers de contacts et un aller-retour réseau pour chacun. L'entrée de menu devient « Mettre à jour depuis les annuaires » : elle a toujours interrogé tous les annuaires configurés, QRZ.com puis HamQTH.",
"La carte du monde peut afficher la ligne grise : le terminateur jour/nuit et sa bande de crépuscule, redessinés chaque minute. Bouton en haut à droite de la carte, désactivé par défaut, et le choix est mémorisé.",
"Les cases de trace du protocole (CAT et WinKeyer) reflètent désormais l'état réel de la trace. Elles réapparaissaient décochées alors que la trace tournait : cocher la case pour l'activer la désactivait en fait, et le journal envoyé ensuite ne contenait aucune trace.",
"Une radio qui annonce LSB ou USB sélectionne désormais SSB dans la saisie. Les radios annoncent la bande latérale, la liste des modes contient SSB : rien ne correspondait et le mode restait vide alors que la fréquence suivait. Cela garde aussi le mode journalisé conforme à l'ADIF, où LSB et USB sont des sous-modes et non des modes.",
"Le CAT Kenwood peut passer par le réseau : indiquez un hôte:port au lieu d'un port COM et OpsLog dialogue avec un pont série (ser2net, boîtier Ethernet-série, Raspberry Pi près de la radio). Il ne s'agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood.",
"La liste des backends CAT est renommée 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.",
"Quand l'enregistrement automatique est désactivé mais que les périphériques audio sont configurés, un rond rouge apparaît à l'emplacement du compteur. Un clic enregistre le contact à la main : le compteur démarre, le rond disparaît, et journaliser le QSO enregistre le fichier comme d'habitude. Les périphériques audio sont ensuite relâchés.",
"Un enregistrement en cours peut être arrêté puis ÉMIS vers la station travaillée, radio passée en émission comme un message du manipulateur vocal — pour quand elle demande à entendre son propre signal. L'audio est conservé et toujours enregistré avec le QSO, et l'enregistrement peut reprendre.",
"Le tableau des références d'un diplôme peut être trié par référence (par défaut) ou par description — cliquez sur l'en-tête, un second clic inverse l'ordre. Les vues grille et liste suivent toutes deux.",
"Supprimer un QSO peut désormais le retirer aussi de QRZ.com et Club Log — Réglages → Services externes, désactivé par défaut. Club Log retrouve le contact par indicatif, heure et bande, donc cela vaut pour n'importe quel QSO ; QRZ.com ne peut retirer que les enregistrements envoyés par OpsLog, son API ne supprimant que par numéro d'enregistrement.",
"Les colonnes de statut QSL et d'envoi sont colorées : Y en vert, N en rouge, R en bleu. Couleur seule, sans pastille. Partout où le tableau des QSO est utilisé — QSO récents, déjà contacté, NET Control et le gestionnaire de QSL.",
"Réglages → Général permet de choisir l'affichage des dates : Standard (2026-07-30), Français (30-07-2026) ou US (07-30-2026). Affichage seulement — le journal reste toujours enregistré au format standard ADIF, et les exports ne changent pas.",
"Nouveau diplôme : DARC DOK Award (DLD), le Deutschland-Diplom allemand, reconnu d'après la référence DOK du QSO."
]
},
{
"version": "0.22.2",
"date": "2026-07-30",
"en": [
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.",
"Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.",
"Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.",
"A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window.",
"ADIF import can move fields as it reads them. Contest software stores the exchange where its own module keeps it — the RSGB IOTA contest exports the island reference in STATE, which imports as a US state and leaves the IOTA award empty. Add a STATE → IOTA row in the import dialog and it lands where the award looks.",
"The recent-QSO grid no longer offers per-column filters. They only ever searched the rows on screen, so an empty result looked like a missing QSO when the log simply held it further back — use the advanced filter, which queries the whole logbook. Column headers gain the width back."
],
"fr": [
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.",
"Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.",
"Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.",
"Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide.",
"L'import ADIF peut déplacer des champs à la lecture. Les logiciels de concours rangent l'échange là où leur module le garde — le contest IOTA de la RSGB exporte la référence d'île dans STATE, importée comme un état américain, et le diplôme IOTA reste vide. Une ligne STATE → IOTA dans la fenêtre d'import et la référence arrive là où le diplôme la cherche.",
"La grille des QSO récents ne propose plus de filtres par colonne. Ils ne cherchaient que dans les lignes affichées : un résultat vide ressemblait à un QSO manquant alors que le journal le contenait plus loin — le filtre avancé, lui, interroge tout le journal. Les en-têtes de colonne récupèrent la place."
]
},
{
"version": "0.22.1",
"date": "2026-07-29",
+229 -22
View File
@@ -44,6 +44,7 @@ import {
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
GetAwardDefs,
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
@@ -99,6 +100,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
@@ -168,11 +170,7 @@ function parseAwardRefs(s: any): Record<string, string> {
}
function fmtDateUTC(s: any): string {
if (!s) return '';
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())}`;
return formatDateTimeUTC(s);
}
function fmtFreq(hz?: number): string {
if (!hz) return '';
@@ -392,6 +390,8 @@ export default function App() {
// === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
const modesRef = useRef(modes);
useEffect(() => { modesRef.current = modes; }, [modes]);
const [modePresets, setModePresets] = useState<ModePreset[]>([]);
// === Entry ===
@@ -766,16 +766,51 @@ export default function App() {
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
// second while a recording is in progress.
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
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
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(() => {});
};
const playRecordingOnAir = () => {
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(() => {
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();
setRecSeconds(0);
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
const id = window.setInterval(() => setRecSeconds(from + Math.floor((Date.now() - start) / 1000)), 1000);
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).
// Lets us restart from zero when the operator edits the call to a different
// station mid-recording, instead of continuing the old take.
@@ -1518,6 +1553,13 @@ export default function App() {
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true);
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
// 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
// US state and leaves the IOTA award empty.
const [importFieldMap, setImportFieldMap] = useState<{ from: string; to: string }[]>([]);
// QRZ profile photo lightbox (full-size, in-app — not the browser).
const [photoModal, setPhotoModal] = useState<string | null>(null);
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
@@ -2037,9 +2079,29 @@ export default function App() {
// a 599 left from a CW QSO would stick when jumping to an SSB spot.
function applyModeFromSpot(m: string) {
if (!m) return;
setMode(m);
const logged = catModeToLoggedMode(m);
setMode(logged);
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) {
if (rstUserEditedRef.current) return;
@@ -2206,8 +2268,9 @@ export default function App() {
else if (s.mode) nextMode = s.mode;
}
if (nextMode) {
setMode(nextMode);
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
const logged = catModeToLoggedMode(nextMode);
setMode(logged);
applyModePreset(logged); // flip 599↔59 on mode change (respects edits)
}
}
}
@@ -2397,6 +2460,14 @@ export default function App() {
const call = String(p?.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) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
});
@@ -2423,7 +2494,7 @@ export default function App() {
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
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
}, []);
@@ -2930,9 +3001,9 @@ export default function App() {
}
async function bulkUpdateFromQRZ(ids: number[]) {
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'); }
catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBulkProgress(null); }
}
async function bulkUpdateFromClublog(ids: number[]) {
if (ids.length === 0) return;
@@ -3365,7 +3436,13 @@ export default function App() {
setImportErrorsOpen(false);
setImportDupsOpen(false);
try {
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
const fm: Record<string, string> = {};
for (const r of importFieldMap) {
const from = r.from.trim().toUpperCase();
const to = r.to.trim().toUpperCase();
if (from && to && from !== to) fm[from] = to;
}
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation, fm);
setImportResult(res);
await refresh();
} catch (e: any) {
@@ -3636,18 +3713,75 @@ export default function App() {
DUPE
</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
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="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"
onClick={startManualRecording}
title={t('rec.manualStart')}
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" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
<span className="size-2.5 rounded-full border border-destructive bg-destructive/40 hover:bg-destructive" />
</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. */}
{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={playRecordingOnAir}
title={t('rec.playOnAir')}
className="inline-flex items-center justify-center size-4 rounded text-success hover:bg-success/15"
>
</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
ref={callsignRef}
// The call field is marked out at REST, not only on focus: operators were
@@ -3655,7 +3789,10 @@ export default function App() {
// 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
// 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',
recording && RECORDABLE_MODES.has(mode.toUpperCase()) && 'pr-24',
'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')}
value={callsign}
@@ -6147,7 +6284,7 @@ export default function App() {
<SettingsModal
initialSection={settingsSection}
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); refreshManualRecReady(); }}
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'}
@@ -6323,6 +6460,47 @@ export default function App() {
</span>
</span>
</label>
{/* Field remapping. Hidden behind a link until asked for: it is the
rare case, and an import dialog that opens onto empty mapping
rows suggests something is required when nothing is. */}
<div className="pt-1 border-t mt-1">
{importFieldMap.length === 0 ? (
<button type="button"
className="text-xs text-primary hover:underline"
onClick={() => setImportFieldMap([{ from: 'STATE', to: 'IOTA' }])}>
{t('imp.mapAdd')}
</button>
) : (
<div className="space-y-1.5">
<div className="text-sm">{t('imp.mapTitle')}</div>
<div className="text-xs text-muted-foreground">{t('imp.mapDesc')}</div>
{importFieldMap.map((row, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input
value={row.from}
placeholder="STATE"
className="h-7 text-xs font-mono uppercase"
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, from: e.target.value } : r))}
/>
<span className="text-muted-foreground text-xs"></span>
<Input
value={row.to}
placeholder="IOTA"
className="h-7 text-xs font-mono uppercase"
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, to: e.target.value } : r))}
/>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs"
onClick={() => setImportFieldMap((m) => m.filter((_, j) => j !== i))}></Button>
</div>
))}
<button type="button"
className="text-xs text-primary hover:underline"
onClick={() => setImportFieldMap((m) => [...m, { from: '', to: '' }])}>
{t('imp.mapMore')}
</button>
</div>
)}
</div>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
@@ -6332,6 +6510,35 @@ export default function App() {
</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 && (
<Dialog open>
<DialogContent className="max-w-sm px-6" hideClose>
+62 -6
View File
@@ -8,6 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
@@ -191,17 +192,52 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
return idx.length ? idx : all;
}, [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(() => {
if (!current) return [];
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 === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
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, refSort, refSortDir]);
return (
<div className="flex h-full min-h-0">
@@ -386,8 +422,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<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="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</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">
<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) => (
<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 +470,18 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<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">{t('awp.name')}</th>
<th className="py-1.5 pr-2 font-medium w-24">
<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-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import { LogUIError } from '../../wailsjs/go/main/App';
// A render error used to empty the window and leave nothing behind: no line in
// opslog.log, no dialog, no way for the operator to say more than "white screen".
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
// auto-call — stayed undiagnosed for weeks for exactly that reason.
//
// So this does two things, in order of importance:
//
// 1. Writes the error and its stack to the app log through LogUIError, so the
// next report arrives WITH the cause instead of a description of a blank
// screen.
// 2. Shows it, with the text selectable and a button to copy it, because the
// person who can act on it is the one looking at the screen.
//
// It deliberately does NOT try to recover the tree. React cannot guarantee the
// state is sane after a render throw, and a half-working logger is worse than
// one that says plainly what happened and offers a reload.
type Props = { children: React.ReactNode };
type State = { error: Error | null; info: string };
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null, info: '' };
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const stack = (info?.componentStack || error.stack || '').trim();
this.setState({ info: stack });
// Never let the reporting path throw: it runs while the app is already broken.
try {
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
} catch {
/* the backend may be gone too — the on-screen copy still works */
}
}
render() {
const { error, info } = this.state;
if (!error) return this.props.children;
const report = `${error.name}: ${error.message}\n\n${info}`;
return (
<div style={{
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
overflow: 'auto', zIndex: 9999,
}}>
<div style={{ maxWidth: 820, width: '100%' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
OpsLog hit an error and stopped drawing
</h1>
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
Your log is safe this is the window, not the database. The details below
are already in the app log; send them with your report.
</p>
<pre style={{
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
}}>{report}</pre>
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
<button
onClick={() => { void navigator.clipboard?.writeText(report); }}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
}}>
Copy the details
</button>
<button
onClick={() => window.location.reload()}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>
Reload the window
</button>
</div>
</div>
</div>
);
}
}
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
// so they leave no trace at all — and an auto-call loop lives entirely in
// callbacks, which is precisely where a white screen was reported.
export function installGlobalErrorLogging() {
const report = (kind: string, message: string, stack?: string) => {
try {
LogUIError(kind, message, stack || '');
} catch {
/* nothing more we can do from here */
}
};
window.addEventListener('error', (e: ErrorEvent) => {
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
});
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const r: any = e.reason;
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
});
}
+96
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
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
// across restarts. Default on.
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);
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);
}, [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.
useEffect(() => {
const wm = worldMap.current, wo = worldOverlay.current;
@@ -293,6 +374,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
>
Zoom DX
</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 && (
<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
+32 -28
View File
@@ -4,6 +4,8 @@ import {
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
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 { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
@@ -85,23 +87,13 @@ function fmtMhzDots(hz?: number): string {
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 {
if (!s) return '';
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())}`;
return formatDateTimeUTC(s);
}
function fmtDateOnly(s: any): string {
if (!s) return '';
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())}`;
return formatDateOnly(s);
}
// 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 },
// ── 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_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' 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 , 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_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 },
@@ -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 },
// ── 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_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' 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 , 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_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 ──
{ 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_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' 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 , 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_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.
{ 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.
{ 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) ──
// 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
// 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.
{ 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.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.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_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_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 , 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_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
// 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,
// select just it; then open the bulk-action menu on the whole selection.
@@ -444,10 +440,18 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
if (widthUps.length) api.setColumnWidths(widthUps);
}, [awardCols, awardShown]);
// No column filters here, deliberately. This grid holds only the most recent
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
// an empty result then reads as "the log does not contain it" rather than "it
// is not on this page". An operator hunting an imported CONTEST_ID across
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
//
// The advanced filter queries the whole logbook and is the honest tool for it.
// Removing the filter also gives each header back the width of its menu icon.
const defaultColDef = useMemo<ColDef>(() => ({
sortable: !passOrder,
resizable: true,
filter: true,
filter: false,
suppressMovable: false,
}), [passOrder]);
+144 -7
View File
@@ -36,6 +36,9 @@ import {
GetTelemetryEnabled, SetTelemetryEnabled,
GetQSLDefaults, SaveQSLDefaults,
SetWinkeyerTrace,
SetCIVTrace,
CIVTraceEnabled,
WinkeyerTraceEnabled,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations,
@@ -66,6 +69,7 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
@@ -198,7 +202,35 @@ type SectionId =
type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean; vendor?: 'o3a' };
// A compact 4O3A wordmark for the sidebar, drawn here rather than shipped as an
// image: an inline SVG scales with the row, follows the theme (currentColor), and
// adds no binary asset. It marks which panels drive 4O3A hardware — the same
// reason the language picker draws its flags inline.
//
// This is a plain wordmark, NOT the manufacturer's logo artwork. If you would
// rather show the official mark, drop the SVG in and swap this component out.
function VendorMark({ vendor }: { vendor: 'o3a' }) {
if (vendor !== 'o3a') return null;
// The 4O3A mark: "4", the green waveform, "3A". Drawn inline rather than
// shipped as an image — it scales with the row, keeps the sidebar's own type
// for the digits, and adds no binary asset. The waveform is the part that
// carries the brand, so only it is SVG; the characters stay HTML text so they
// match the surrounding menu at any theme or zoom.
const lens = [3.2, 5.2, 7.4, 9.5, 7.4, 5.2, 3.2]; // half-heights, tallest in the middle
return (
<span className="ml-1.5 flex shrink-0 items-center gap-[1px] text-[10px] font-bold leading-none opacity-80" title="4O3A Signature">
<span>4</span>
<svg viewBox="0 0 26 22" className="h-[13px] w-[15px]" aria-hidden="true">
{lens.map((ry, i) => (
<ellipse key={i} cx={3 + i * 3.3} cy={11} rx={1.5} ry={ry} fill="#1f9d3a" />
))}
</svg>
<span>3A</span>
</span>
);
}
// buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
@@ -208,8 +240,8 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius' },
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
@@ -325,6 +357,7 @@ function TreeNodeView({
style={{ paddingLeft: 8 + depth * 14 }}
>
<span className="truncate">{node.label}</span>
{node.vendor && !node.disabled && <VendorMark vendor={node.vendor} />}
{node.disabled && (
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
)}
@@ -1063,7 +1096,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState('');
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,
yaesu_port: '', yaesu_baud: 38400, 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,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -1106,7 +1139,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
// 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 [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 }));
// ── Audio (DVK + QSO recorder) ──
@@ -1245,7 +1289,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
upload_flags: string[]; write_log: boolean;
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 => ({
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
@@ -1253,7 +1297,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
auto_upload: false, upload_mode: 'immediate',
});
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 [qrzTesting, setQrzTesting] = useState(false);
@@ -2350,6 +2394,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
@@ -2484,6 +2529,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</>
)}
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
<div className="border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
{t('cat.civTrace')}
</label>
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
</div>
)}
{catCfg.backend === 'kenwood' && (
<>
<div className="space-y-1">
<Label>{t('cat.kenwoodPort')}</Label>
<div className="flex gap-2">
<Select value={catCfg.kenwood_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_port: v }))}>
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}></Button>
</div>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
</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' && (
<>
<div className="space-y-1">
@@ -3169,7 +3261,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -3216,6 +3308,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
<Select value={String((rotator as any).baud || 9600)}
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600].map((b) => (
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
@@ -4124,6 +4225,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<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 */}
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
{TABS.map((tab) => (
@@ -5108,6 +5224,27 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</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 />
<label className="flex items-center gap-2 text-sm cursor-pointer">
+4 -1
View File
@@ -14,6 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
@@ -63,7 +64,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
const [menu, setMenu] = useState<QSOMenuState>(null);
// 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>) {
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;
}
+14 -14
View File
@@ -63,7 +63,7 @@ const en: Dict = {
// Language chooser
'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.',
'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.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
'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.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.cancel': 'Cancel', 'imp.import': 'Import',
'imp.progressTitle': 'Importing ADIF…',
'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.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…', 'bulk.progressTitle': 'Updating the selected QSOs\u2026',
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
'imp.complete': 'Import complete.',
'imp.imported': '{n} imported', 'imp.updated': '{n} updated', 'imp.duplicates': '{n} duplicates', 'imp.skipped': '{n} skipped', 'imp.total': '{n} total',
@@ -273,11 +273,11 @@ const en: Dict = {
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'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',
// 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.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',
'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.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.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.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.',
'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°)',
@@ -398,7 +398,7 @@ const en: Dict = {
'awed.importReplace': 'Replace mine',
'awed.importCopy': 'Import as {code}-2',
// 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.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.',
@@ -475,7 +475,7 @@ const fr: Dict = {
'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.',
'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.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',
@@ -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.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.cancel': 'Annuler', 'imp.import': 'Importer',
'imp.progressTitle': 'Import ADIF en cours…',
'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.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…', 'bulk.progressTitle': 'Mise \u00e0 jour des QSO s\u00e9lectionn\u00e9s\u2026',
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
'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',
@@ -675,10 +675,10 @@ const fr: Dict = {
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'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 à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", '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).",
'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.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',
'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.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.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.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.',
'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°)',
@@ -790,7 +790,7 @@ const fr: Dict = {
'awed.importKeepMine': 'Garder le mien',
'awed.importReplace': 'Remplacer le mien',
'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.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.',
+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';
}
+3
View File
@@ -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.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'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.
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
+17 -5
View File
@@ -5,6 +5,8 @@ import App from './App'
import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n'
import { ThemeProvider, initTheme } from './lib/theme'
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
import { reloadDateFormat } from './lib/dateFormat'
const container = document.getElementById('root')
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
// Stamp the persisted theme onto <html> before render so the correct
// palette is applied immediately (portable pref hydrated just above).
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
// rejected promises. Installed before the first render so a fault during
// startup is reported too.
installGlobalErrorLogging()
root.render(
<React.StrictMode>
<I18nProvider>
<ThemeProvider>
<App/>
</ThemeProvider>
</I18nProvider>
<ErrorBoundary>
<I18nProvider>
<ThemeProvider>
<App/>
</ThemeProvider>
</I18nProvider>
</ErrorBoundary>
</React.StrictMode>
)
})
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.1';
export const APP_VERSION = '0.22.3';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+19 -1
View File
@@ -91,6 +91,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 CIVTraceEnabled():Promise<boolean>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>;
@@ -589,7 +591,7 @@ export function IcomStopCW():Promise<void>;
export function IcomTune():Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
@@ -637,6 +639,8 @@ export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
@@ -745,10 +749,20 @@ export function QSOAudioBegin():Promise<boolean>;
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 QSOAudioRestart():Promise<boolean>;
export function QSOAudioResume():Promise<boolean>;
export function QSOAudioStop():Promise<boolean>;
export function QuitApp():Promise<void>;
export function RecomputeAllAwardRefs():Promise<number>;
@@ -891,6 +905,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCIVTrace(arg1:boolean):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
@@ -1033,6 +1049,8 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
export function WinkeyerStop():Promise<void>;
export function WinkeyerTraceEnabled():Promise<boolean>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
export function YaesuSendCW(arg1:string):Promise<void>;
+38 -2
View File
@@ -130,6 +130,10 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function CIVTraceEnabled() {
return window['go']['main']['App']['CIVTraceEnabled']();
}
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
@@ -1126,8 +1130,8 @@ export function IcomTune() {
return window['go']['main']['App']['IcomTune']();
}
export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
}
export function ImportAwardReferencesText(arg1, arg2) {
@@ -1222,6 +1226,10 @@ export function LogUDPLoggedADIF(arg1) {
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
}
export function LogUIError(arg1, arg2, arg3) {
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
}
export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1);
}
@@ -1438,6 +1446,18 @@ export function 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() {
return window['go']['main']['App']['QSOAudioResetClock']();
}
@@ -1446,6 +1466,14 @@ export function 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() {
return window['go']['main']['App']['QuitApp']();
}
@@ -1730,6 +1758,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCIVTrace(arg1) {
return window['go']['main']['App']['SetCIVTrace'](arg1);
}
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
@@ -2014,6 +2046,10 @@ export function WinkeyerStop() {
return window['go']['main']['App']['WinkeyerStop']();
}
export function WinkeyerTraceEnabled() {
return window['go']['main']['App']['WinkeyerTraceEnabled']();
}
export function WorkedBefore(arg1, arg2) {
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
}
+10
View File
@@ -1297,6 +1297,7 @@ export namespace extsvc {
hrdlog: ServiceConfig;
eqsl: ServiceConfig;
cloudlog: ServiceConfig;
delete_remote: boolean;
static createFrom(source: any = {}) {
return new ExternalServices(source);
@@ -1310,6 +1311,7 @@ export namespace extsvc {
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.delete_remote = source["delete_remote"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1906,6 +1908,9 @@ export namespace main {
xiegu_addr: number;
yaesu_port: string;
yaesu_baud: number;
kenwood_host: string;
kenwood_port: string;
kenwood_baud: number;
icom_port: string;
icom_baud: number;
icom_addr: number;
@@ -1942,6 +1947,9 @@ export namespace main {
this.xiegu_addr = source["xiegu_addr"];
this.yaesu_port = source["yaesu_port"];
this.yaesu_baud = source["yaesu_baud"];
this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"];
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"];
@@ -2799,6 +2807,7 @@ export namespace main {
rotator_num: number;
transport: string;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new RotatorSettings(source);
@@ -2814,6 +2823,7 @@ export namespace main {
this.rotator_num = source["rotator_num"];
this.transport = source["transport"];
this.com_port = source["com_port"];
this.baud = source["baud"];
}
}
export class ScpStatus {
+59
View File
@@ -46,6 +46,21 @@ type Importer struct {
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
// source file (common with contest loggers) is corrected on the way in.
Enrich func(*qso.QSO)
// FieldMap moves ADIF fields around before a record is converted, keyed
// lowercase source → lowercase destination.
//
// Contest software puts things where the operator, not the standard, expects
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
// friends export the island reference in STATE, because that is the column
// their contest module uses for the received exchange. Imported verbatim,
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
// nothing at all.
//
// Applied to the RAW record, so it works for promoted columns and Extras
// alike, and so the destination is parsed exactly as if the file had carried
// it there in the first place. A destination that already has a value is
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
FieldMap map[string]string
// OnProgress, when set, is called periodically with (processed, total)
// record counts so the UI can show a progress bar. total is an estimate
// from counting <EOR> tags up front.
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
res.Total++
reportProgress(false)
applyFieldMap(rec, im.FieldMap)
q, ok := recordToQSO(rec)
if !ok {
res.Skipped++
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
// RecordToQSO is the exported alias used by the UDP auto-log path so it
// can convert a freshly received ADIF record into a QSO and then enrich
// it with lookup + operating data before inserting.
// applyFieldMap moves values between tags of a record, in place.
//
// The source is CLEARED after the move: a reference that belongs in IOTA should
// not also be left behind in STATE, where it would show up as the contacted
// station's US state in the grid and in every export.
func applyFieldMap(rec Record, m map[string]string) {
if len(m) == 0 || rec == nil {
return
}
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
// read back what the previous move had just written.
orig := make(map[string]string, len(rec))
for k, v := range rec {
orig[k] = v
}
vals := make(map[string]string, len(m))
for from := range m {
if v := strings.TrimSpace(rec[from]); v != "" {
vals[from] = v
}
}
for from := range m {
delete(rec, from)
}
for from, to := range m {
v, ok := vals[from]
if !ok || from == to || to == "" {
continue
}
// Whether the destination was already filled is judged against the record
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
// depend on Go's map iteration order, which is deliberately random.
//
// A destination that is itself a mapping source is an explicit swap, and
// overwriting it is the whole point; anything else keeps what the file
// carried, because the file's own tag outranks a guess about where a
// reference might have been put.
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
rec[to] = v
}
}
}
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
+74
View File
@@ -0,0 +1,74 @@
package adif_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"hamlog/internal/adif"
"hamlog/internal/db"
"hamlog/internal/qso"
)
// End to end: an operator's own record, through a real SQLite logbook and back.
//
// Written because CONTEST_ID was reported as "not imported". It is imported, and
// this pins the whole path — parse, remap, insert, read back — rather than the
// conversion step alone, which was already green while the report stood. A field
// can be lost at five places between the file and the grid; only the round trip
// says which.
func TestContestIDSurvivesImport(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
path := filepath.Join(dir, "in.adi")
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
t.Fatal(err)
}
repo := qso.NewRepo(conn)
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
res, err := im.ImportFile(context.Background(), path)
if err != nil {
t.Fatalf("import: %v", err)
}
t.Logf("result: %+v", res)
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
n := 0
for rows.Next() {
var call, contest, iota, state any
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
t.Fatal(err)
}
n++
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
}
}
if n == 0 {
t.Fatal("nothing was stored")
}
}
func toS(v any) string {
switch x := v.(type) {
case string:
return x
case []byte:
return string(x)
}
return ""
}
+55
View File
@@ -0,0 +1,55 @@
package adif
import "testing"
// Field mapping, from the RSGB IOTA contest case.
//
// N1MM and similar export the island reference in STATE, because that is the
// column their contest module uses for the received exchange. Imported
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
// sees nothing — the reference is in the log but not where anything looks.
func TestApplyFieldMap(t *testing.T) {
// The real record, from an operator's TM2Q log.
rec := Record{
"call": "2E0CVN", "band": "20m", "mode": "CW",
"state": "EU005", "srx_string": "001EU005",
}
applyFieldMap(rec, map[string]string{"state": "iota"})
if rec["iota"] != "EU005" {
t.Errorf("iota = %q, want EU005", rec["iota"])
}
// Cleared, not copied: a reference left behind in STATE would show up as the
// contacted station's US state in the grid and in every export.
if _, ok := rec["state"]; ok {
t.Errorf("state survived the move as %q", rec["state"])
}
// A destination the file already filled is never overwritten — the file's own
// tag outranks a guess about where the reference might be.
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
applyFieldMap(rec2, map[string]string{"state": "iota"})
if rec2["iota"] != "EU030" {
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
}
// An empty source moves nothing (the second QSO of that log has no STATE).
rec3 := Record{"call": "UT0RM", "state": " "}
applyFieldMap(rec3, map[string]string{"state": "iota"})
if v, ok := rec3["iota"]; ok {
t.Errorf("an empty source created iota=%q", v)
}
// A swap reads both sources before writing either.
rec4 := Record{"state": "EU005", "iota": "NA001"}
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
}
// No mapping, no change.
rec5 := Record{"call": "F4BPO", "state": "EU005"}
applyFieldMap(rec5, nil)
if rec5["state"] != "EU005" {
t.Error("a nil mapping must leave the record alone")
}
}
+64 -7
View File
@@ -52,6 +52,11 @@ type Recorder struct {
// Mixed output state (guarded by mu).
ring []int16 // last prerollSamples of mixed audio
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)
}
@@ -114,7 +119,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.twoSrc = micDev != "" && micDev != fromDev
r.stopCh = make(chan struct{})
r.running = true
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
stop := r.stopCh
twoSrc := r.twoSrc
r.mu.Unlock()
@@ -206,7 +211,7 @@ func (r *Recorder) mixTick() {
if 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.mu.Unlock()
@@ -221,7 +226,7 @@ func (r *Recorder) BeginQSO() {
return
}
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 —
@@ -236,7 +241,7 @@ func (r *Recorder) RestartQSO() {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
r.active, r.paused = true, false
}
// ResetQSOClock restarts the active accumulation from ZERO — discarding
@@ -266,7 +271,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
return nil, fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock()
if len(samples) == 0 {
return nil, fmt.Errorf("recording was empty")
@@ -295,7 +300,7 @@ func (r *Recorder) SaveQSO(path string) error {
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
r.acc, r.active = nil, false
r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock()
}
@@ -313,7 +318,7 @@ func (r *Recorder) Stop() {
close(stop)
r.wg.Wait()
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.srcMu.Lock()
r.bufA, r.bufB = nil, nil
@@ -346,3 +351,55 @@ func int16sToBytes(s []int16) []byte {
}
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
}
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -160,16 +160,29 @@ func FreqToBCD(hz int64) []byte {
}
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
func BCDToFreq(b []byte) int64 {
//
// It returns ok=false when a nibble is not a decimal digit. That check is the
// point of the function: a CI-V byte stream that has lost frame sync — a reply
// read at the wrong offset, a collision with the scope stream — still decodes
// into a number if 0x0A..0x0F are quietly treated as 10..15. An operator on a
// 2 m FM channel then saw entries like 16445550000 Hz appear in the status bar
// between good readings (F4JND, 2026-07-30). Garbage that LOOKS like a
// frequency is worse than a refused frame: it reaches the display, the band
// slots, and eventually the log.
func BCDToFreq(b []byte) (int64, bool) {
var hz int64
mult := int64(1)
for i := 0; i < len(b) && i < 5; i++ {
hz += int64(b[i]&0x0F) * mult
lo, hi := b[i]&0x0F, b[i]>>4
if lo > 9 || hi > 9 {
return 0, false
}
hz += int64(lo) * mult
mult *= 10
hz += int64(b[i]>>4) * mult
hz += int64(hi) * mult
mult *= 10
}
return hz
return hz, true
}
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
+45 -4
View File
@@ -12,8 +12,9 @@ func TestFreqBCDRoundTrip(t *testing.T) {
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
}
if got := BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
got, ok := BCDToFreq(b)
if !ok || got != hz {
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
}
}
}
@@ -49,8 +50,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
t.Errorf("addrs/cmd wrong: %+v", f)
}
if hz := BCDToFreq(f.Data); hz != 14250000 {
t.Errorf("decoded freq %d, want 14250000", hz)
if hz, ok := BCDToFreq(f.Data); !ok || hz != 14250000 {
t.Errorf("decoded freq %d (ok=%v), want 14250000", hz, ok)
}
}
@@ -163,3 +164,43 @@ func TestModelNameAddresses(t *testing.T) {
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
}
}
// A frame that is not decimal BCD is refused rather than decoded.
//
// From an operator's log (F4JND, 2026-07-30): a rig sitting on 145.550 MHz FM
// produced status lines reading 16445550000, 8364550000, 5817408364 and 12640 Hz
// between good readings. CI-V had lost frame sync — a reply read at the wrong
// offset, alongside PTT timeouts and a scope stream the rig was rejecting — and
// the decoder treated the nibbles 0x0A..0x0F as the digits 10..15, turning noise
// into a plausible-looking number.
//
// Garbage that looks like a frequency is worse than a refused frame: it reaches
// the status bar, the band slots and the log, and it is what an operator then
// has to disprove.
func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
// Valid: 145.550 MHz, encoded by the same package rather than by hand — my
// hand-written fixture was byte-reversed and this caught it.
good := FreqToBCD(145550000)
if hz, ok := BCDToFreq(good); !ok || hz != 145550000 {
t.Errorf("valid BCD % X decoded as %d (ok=%v), want 145550000", good, hz, ok)
}
// Every nibble value above 9 must be refused, in either half of the byte.
bad := [][]byte{
{0x0A, 0x00, 0x55, 0x45, 0x01}, // low nibble
{0x00, 0x00, 0xF5, 0x45, 0x01}, // high nibble
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, // an idle line read as data
{0x00, 0x00, 0x00, 0x0B, 0x00},
}
for _, b := range bad {
if hz, ok := BCDToFreq(b); ok {
t.Errorf("% X was accepted as %d Hz — it is not decimal BCD", b, hz)
}
}
// A short frame still decodes what it holds: the caller decides whether a
// partial read is usable, and refusing it here would break the 4-byte forms.
if _, ok := BCDToFreq([]byte{0x00, 0x50}); !ok {
t.Error("a short but valid BCD frame must still decode")
}
}
+33 -7
View File
@@ -557,7 +557,9 @@ func (b *IcomSerial) write(payload ...byte) error {
// recv() only sees the reply to THIS request (avoids a previous command's ack
// or an unsolicited dial-turn update being mistaken for our response).
b.drainResp()
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
traceCIV("TX", frame)
_, err := b.port.Write(frame)
return err
}
@@ -603,6 +605,9 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
continue // read timeout with no data
}
rx = append(rx, tmp[:n]...)
// Traced BEFORE framing: the bytes as they arrived are what shows a lost
// frame boundary, which a decoded view would hide.
traceCIV("RX", tmp[:n])
frames, consumed := civ.Scan(rx)
if consumed > 0 {
rx = append(rx[:0], rx[consumed:]...)
@@ -750,8 +755,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
// 21.000-42.070 span (low + a 21 MHz "span").
v1 := civ.BCDToFreq(region[1:6])
v2 := civ.BCDToFreq(region[6:11])
v1, ok1 := civ.BCDToFreq(region[1:6])
v2, ok2 := civ.BCDToFreq(region[6:11])
if !ok1 || !ok2 {
// Not decimal BCD — the frame was read at the wrong offset. Keep the
// edges we had; a wrong span is drawn, noticed, and mistrusted.
break
}
var low, high int64
if v2 > v1 {
low, high = v1, v2 // absolute low/high edges (fixed edge set)
@@ -788,8 +798,13 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
// high = span (e.g. 100 kHz), so high < low and the panadapter had
// no valid range to map the trace onto → blank scope. FIXED mode
// parsed fine, which is why only center mode was broken.
v1 := civ.BCDToFreq(region[1:6])
v2 := civ.BCDToFreq(region[6:11])
v1, ok1 := civ.BCDToFreq(region[1:6])
v2, ok2 := civ.BCDToFreq(region[6:11])
if !ok1 || !ok2 {
// Not decimal BCD — the frame was read at the wrong offset. Keep the
// edges we had; a wrong span is drawn, noticed, and mistrusted.
break
}
var low, high int64
if v2 > v1 {
low, high = v1, v2 // absolute low/high edges (fixed)
@@ -1142,7 +1157,14 @@ func (b *IcomSerial) readFreq() (int64, error) {
if err != nil {
return 0, err
}
return civ.BCDToFreq(f.Data), nil
hz, ok := civ.BCDToFreq(f.Data)
if !ok {
// A frame that is not decimal BCD is a desynchronised read, not a
// frequency. Reporting the error keeps the caller on its last good value
// instead of publishing a number like 16445550000 Hz to the display.
return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
}
return hz, nil
}
// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
@@ -1172,7 +1194,11 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
if err != nil {
return 0, false
}
return civ.BCDToFreq(f.Data[1:]), true
hz, ok := civ.BCDToFreq(f.Data[1:])
if !ok {
return 0, false
}
return hz, true
}
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
+562
View File
@@ -0,0 +1,562 @@
package cat
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
// a different vocabulary.
//
// The dialect is already proven in this repository from the other side:
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
//
// Commands used:
//
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
// FB; → FB00014030000; VFO B frequency
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
// the whole operating state in ONE round trip, which is why it
// is the poll rather than asking four separate questions.
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
// 7=CW-R 9=FSK-R)
// FR0;/FR1; receive VFO — 0 = A, 1 = B
// FT0;/FT1; transmit VFO (split = the two differ)
// TX;/RX; key / unkey
// ID; → ID020; model number
// AI0; silence unsolicited status reports
//
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
// fault reported through OmniRig came from its interpretation layer rather than
// from the radio, and the rig file decides what a "Freq" property means.
import (
"errors"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
// Kenwood is the native backend. One serial port, one mutex: a command and its
// reply are never interleaved with another exchange.
type Kenwood struct {
portName string
baud int
// 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
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
curFreq int64
curRXFreq int64
curVFO string // "A" or "B"
// Commands this rig answered "?;" to — asked once, then never again.
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 {
if baud <= 0 {
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
}
if strings.TrimSpace(digital) == "" {
digital = "FT8"
}
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
func (k *Kenwood) Name() string { return "kenwood" }
func (k *Kenwood) Connect() error {
k.mu.Lock()
defer k.mu.Unlock()
if k.portName == "" && k.host == "" {
return fmt.Errorf("kenwood: no serial port or network address configured")
}
// Close any handle still held before opening another: Connect runs again on
// every reconnect, and Windows opens a serial port exclusively, so a leaked
// handle makes the next open fail with "port busy" (the fault found in the
// Yaesu backend — same shape here).
if k.port != nil {
_ = k.port.Close()
k.port = nil
}
p, err := k.openPort()
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)
}
p.SetReadTimeout(300 * time.Millisecond)
k.port = p
k.unsupported = map[string]bool{}
// Silence unsolicited status reports: they interleave with our
// request/response pairs and make a reply impossible to attribute. We poll.
_ = k.write("AI0;")
answered := false
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
answered = true
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
if name, ok := kenwoodModels[code]; ok {
k.model = name
} else {
k.model = "Kenwood (" + code + ")"
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
}
}
// IF is the command everything else depends on, so it is also the honest
// test of whether a radio is really there.
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
answered = true
}
if !answered {
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)
}
// 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
}
func (k *Kenwood) Disconnect() {
k.mu.Lock()
defer k.mu.Unlock()
if k.port != nil {
_ = k.port.Close()
k.port = nil
}
}
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
// in one frame; the other VFO is only asked for when split is actually on, so
// the common simplex case costs a single round trip.
func (k *Kenwood) ReadState() (RigState, error) {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return RigState{}, fmt.Errorf("kenwood: not connected")
}
raw, err := k.ask("IF;")
if err != nil {
return RigState{}, err
}
f, ok := parseKenwoodIF(raw)
if !ok {
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
}
s := RigState{Connected: true, Backend: "kenwood"}
k.curVFO = f.VFO
s.Vfo = f.VFO
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
s.Rig = k.model
// IF reports the frequency of the VFO in USE (what the operator hears).
rx := f.FreqHz
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 {
// 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
// into the log, which is worse than showing no split at all.
other := "FB;"
if f.VFO == "B" {
other = "FA;"
}
if r, err := k.ask(other); err == nil {
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
tx = hz
}
}
}
if tx != rx {
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
s.RxFreqHz = rx
s.Split = true
} else {
s.FreqHz = rx
}
k.curFreq = s.FreqHz
if s.Split {
k.curRXFreq = s.RxFreqHz
}
return s, nil
}
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
// what makes a display disagree with the radio when they are on B.
func (k *Kenwood) SetFrequency(hz int64) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
if hz <= 0 || hz > 99_999_999_999 {
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
}
cmd := "FA"
if k.curVFO == "B" {
cmd = "FB"
}
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
}
func (k *Kenwood) SetMode(mode string) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
d := kenwoodModeDigit(mode, k.curFreq)
if d == 0 {
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
}
return k.write(fmt.Sprintf("MD%c;", d))
}
func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
if on {
return k.write("TX;")
}
return k.write("RX;")
}
func (k *Kenwood) write(cmd string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
traceText("kenwood", "TX", cmd)
_, err := k.port.Write([]byte(cmd))
return err
}
// ask sends a query and returns the reply belonging to THAT command. Anything
// else on the wire is discarded: a stray frame parsed as a frequency reads as
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
// for no reason (found the hard way on the Yaesu backend).
func (k *Kenwood) ask(cmd string) (string, error) {
want := cmdPrefix(cmd)
if k.unsupported[want] {
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
}
if err := k.write(cmd); err != nil {
return "", err
}
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n == 0 {
continue // read timeout — the rig may still be composing its answer
}
buf = append(buf, tmp[:n]...)
for {
i := strings.IndexByte(string(buf), ';')
if i < 0 {
break
}
frame := string(buf[:i+1])
buf = buf[i+1:]
traceText("kenwood", "RX", frame)
if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops
// paying a 600 ms timeout for it on every cycle.
k.unsupported[want] = true
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
return "", fmt.Errorf("kenwood: %s rejected", want)
}
if strings.HasPrefix(frame, want) {
return frame, nil
}
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
}
}
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
// ── Frame parsing ──────────────────────────────────────────────────────────
// kenwoodIF is what the 38-character IF status frame carries.
type kenwoodIF struct {
FreqHz int64
Mode byte
VFO string // "A" or "B"
Split bool
// TX is parsed even though RigState has no PTT field: it is one character of
// the same frame, and having it here means a future "transmitting" indicator
// costs no extra round trip.
TX bool
}
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
// one internal/catemu emits — is:
//
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
//
// Fields are read by POSITION, so the length is checked first: a short frame
// means a truncated read, and indexing into it would panic or, worse, silently
// yield a wrong frequency.
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
return kenwoodIF{}, false
}
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
if err != nil || hz <= 0 {
return kenwoodIF{}, false
}
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
if r[30] == '1' {
out.VFO = "B"
}
return out, true
}
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
func parseKenwoodFreq(reply, prefix string) (int64, bool) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, prefix) {
return 0, false
}
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
if digits == "" {
return 0, false
}
hz, err := strconv.ParseInt(digits, 10, 64)
if err != nil || hz <= 0 {
return 0, false
}
return hz, true
}
// ── Modes ──────────────────────────────────────────────────────────────────
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
// every SSB QSO in the log wrong.
func kenwoodModeDigit(mode string, hz int64) byte {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "":
return 0
case "LSB":
return '1'
case "USB":
return '2'
case "CW":
return '3'
case "CW-R", "CWR":
return '7'
case "FM":
return '4'
case "AM":
return '5'
case "RTTY", "FSK":
return '6'
case "RTTY-R", "FSK-R":
return '9'
case "SSB":
if hz > 0 && hz < 10_000_000 {
return '1'
}
return '2'
}
// Any other digital mode rides on the data sideband, which on Kenwood is
// plain USB/LSB with the rig's DATA input selected.
if hz > 0 && hz < 10_000_000 {
return '1'
}
return '2'
}
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
// it is on USB — so the operator's configured digital mode is used, exactly as
// the other backends do.
func kenwoodModeToADIF(d byte, digital string) string {
switch d {
case '1':
return "LSB"
case '2':
return "USB"
case '3', '7':
return "CW"
case '4':
return "FM"
case '5':
return "AM"
case '6', '9':
return "RTTY"
}
return ""
}
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
// answers everything else must not be rejected over it.
var kenwoodModels = map[string]string{
"017": "TS-570",
"019": "TS-2000",
"020": "TS-480",
"021": "TS-590S",
"023": "TS-590SG",
"024": "TS-990S",
"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)
}
}
+131
View File
@@ -0,0 +1,131 @@
package cat
import "testing"
// The IF status frame, read by POSITION.
//
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
// the poll. Every field is a fixed offset, so a length check comes first: a
// truncated read would otherwise index into the wrong characters and yield a
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
//
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
// amplifier, so these two halves of the repository agree by construction.
func TestParseKenwoodIF(t *testing.T) {
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
// split | tone | tone#(2) | shift | ; → 38 characters
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
if len(rx20mUSB) != 38 {
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
}
f, ok := parseKenwoodIF(rx20mUSB)
if !ok {
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
}
if f.FreqHz != 14250000 {
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
}
if f.Mode != '2' {
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
}
if f.VFO != "A" || f.Split || f.TX {
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
}
// Same frame transmitting, on VFO B, split, in CW.
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
f2, ok := parseKenwoodIF(txSplitB)
if !ok {
t.Fatalf("valid frame rejected: %q", txSplitB)
}
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
f2.TX, f2.Mode, f2.VFO, f2.Split)
}
// Rejections: anything that would make position-reading unsafe.
for _, bad := range []string{
"",
"IF;", // query echoed with no payload
"IF0001425000;", // short — the trap this guards against
"FA00014250000;", // another command's reply
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
} {
if _, ok := parseKenwoodIF(bad); ok {
t.Errorf("accepted a frame it should have refused: %q", bad)
}
}
}
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
func TestParseKenwoodFreq(t *testing.T) {
cases := []struct {
reply, prefix string
want int64
ok bool
}{
{"FA00014250000;", "FA", 14250000, true},
{"FB00007030000;", "FB", 7030000, true},
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
{"FA;", "FA", 0, false},
{"FAxxxxxxxxxxx;", "FA", 0, false},
{"", "FA", 0, false},
}
for _, c := range cases {
got, ok := parseKenwoodFreq(c.reply, c.prefix)
if got != c.want || ok != c.ok {
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
}
}
}
// The sideband follows the frequency by worldwide convention. A backend that
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
// pinned rather than left to the rig.
func TestKenwoodModeDigit(t *testing.T) {
cases := []struct {
mode string
hz int64
want byte
}{
{"SSB", 7150000, '1'}, // LSB below 10 MHz
{"SSB", 14250000, '2'}, // USB above
{"LSB", 14250000, '1'}, // an explicit choice wins over the convention
{"USB", 7150000, '2'},
{"CW", 7030000, '3'},
{"RTTY", 14080000, '6'},
{"AM", 7150000, '5'},
{"FM", 145000000, '4'},
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
{"FT8", 14074000, '2'},
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
}
}
}
// What the log records for each mode digit the rig reports.
func TestKenwoodModeToADIF(t *testing.T) {
cases := []struct {
d byte
want string
}{
{'1', "LSB"}, {'2', "USB"},
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
{'4', "FM"}, {'5', "AM"},
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
{'0', ""}, // unknown → say nothing rather than guess
}
for _, c := range cases {
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
}
}
}
+52
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sync"
"sync/atomic"
)
// LogSink, when set by the host app at startup, receives every CAT debug
@@ -73,3 +74,54 @@ func DebugLogPath() string {
}
return filepath.Join(base, "OpsLog", "cat.log")
}
// ── CAT wire trace ─────────────────────────────────────────
//
// Opt-in, off by default. Turning it on logs every frame sent and received —
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
//
// It exists because a fault nobody could reason about — "the keyer sends one
// element then stalls" — was settled in one line the moment the actual bytes
// were visible, and the CAT links have since produced the same class of report:
// a MOX button that sets split instead of transmitting, and a split the Kenwood
// backend does not recognise on a rig whose frequency it reads perfectly.
//
// Both are questions about what the RIG actually said. Guessing at that from a
// protocol document has already cost this project a wrong fix that broke the
// case which already worked.
var catTrace atomic.Bool
// 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) {
catTrace.Store(on)
if on {
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
} else {
debugLog.Printf("wire trace OFF")
}
}
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
func CIVTraceEnabled() bool { return catTrace.Load() }
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
func traceCIV(dir string, b []byte) {
if !catTrace.Load() || len(b) == 0 {
return
}
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)
}
+2 -2
View File
@@ -247,8 +247,8 @@ func (x *Xiegu) readFreq() (int64, error) {
if err != nil {
return 0, err
}
hz := civ.BCDToFreq(d.Data)
if hz <= 0 {
hz, ok := civ.BCDToFreq(d.Data)
if !ok || hz <= 0 {
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
}
return hz, nil
+3 -2
View File
@@ -76,8 +76,9 @@ func TestXieguFrequencyEncoding(t *testing.T) {
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
}
if got := civ.BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
got, ok := civ.BCDToFreq(b)
if !ok || got != hz {
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
}
}
}
+21 -3
View File
@@ -17,7 +17,7 @@ package cat
//
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
// FB; → FB014100000; VFO B frequency
// MD0; → MD02; operating mode of the main receiver
// MD0;/MD1; → MD02; operating mode — 0 = main receiver, 1 = sub
// FR; → FR1; RECEIVE VFO (0=main/A, 1=sub/B)
// VS; → VS0; selected VFO, on models without FR
// ST; → ST1; split (FTDX10/FTDX101)
@@ -304,7 +304,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
y.curRXFreq = s.RxFreqHz
}
if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 {
if r, err := y.ask("MD" + y.modeVFOSuffix() + ";"); err == nil && len(r) >= 4 {
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
// but the panel has to show which sideband the rig is actually on.
y.panel.RawMode = yaesuRawModeName(r[3])
@@ -351,7 +351,25 @@ func (y *Yaesu) SetMode(mode string) error {
if d == 0 {
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
}
return y.write(fmt.Sprintf("MD0%c;", d))
return y.write(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
}
// modeVFOSuffix picks which receiver the mode commands address: "0" is main,
// "1" is sub.
//
// Both the read and the write used 0 unconditionally. A spot click already tuned
// the right VFO — that part was fixed — but then set the MODE on main, so an
// operator working from the sub receiver had a spot change the mode of the VFO
// they were not using and leave the one they were on untouched (F4NBZ,
// 2026-07-29). The rig reads the same way, so the panel showed main's mode too.
//
// Rigs without a sub receiver simply never see "1": curVFO only becomes B on a
// rig that reports its receive VFO.
func (y *Yaesu) modeVFOSuffix() string {
if y.curVFO == "B" {
return "1"
}
return "0"
}
func (y *Yaesu) SetPTT(on bool) error {
+3 -1
View File
@@ -430,7 +430,9 @@ func (y *Yaesu) SetYaesuModeRaw(name string) error {
if !ok {
return fmt.Errorf("yaesu: unknown rig mode %q", name)
}
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
// Same receiver as everywhere else — the console must not set main's mode
// while the operator is working the sub VFO.
return y.setAndRefresh(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
}
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
+33
View File
@@ -347,3 +347,36 @@ func TestYaesuSplitProbeOrder(t *testing.T) {
t.Error("ST1 is a flag and reads as split whatever the VFO — that is the trap")
}
}
// The mode commands address the receiver the operator is ON.
//
// Reported on an FTDX101 (F4NBZ, 2026-07-29): once a spot click tuned the right
// VFO, it still changed the mode of MAIN while the operator worked from SUB — so
// the VFO in use kept its old mode and the idle one was altered. The read had the
// same fault, so the panel showed main's mode as well.
func TestYaesuModeVFOSuffix(t *testing.T) {
cases := []struct{ vfo, want string }{
{"A", "0"}, // main
{"", "0"}, // not yet read — main is the safe assumption
{"B", "1"}, // sub
{"AB", "0"}, // pair enums start with the receive VFO
}
for _, c := range cases {
y := &Yaesu{}
y.curVFO = c.vfo
if got := y.modeVFOSuffix(); got != c.want {
t.Errorf("curVFO=%q → MD%s, want MD%s", c.vfo, got, c.want)
}
}
// Spelled out as the commands actually sent, which is what the rig sees.
y := &Yaesu{}
y.curVFO = "B"
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD13;" {
t.Errorf("setting CW on the sub receiver sends %q, want MD13;", cmd)
}
y.curVFO = "A"
if cmd := "MD" + y.modeVFOSuffix() + "3;"; cmd != "MD03;" {
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
}
}
+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"`
EQSL ServiceConfig `json:"eqsl"`
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.
+22 -7
View File
@@ -1,6 +1,12 @@
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
// protocol, over a raw TCP socket or a serial COM port. The target device is
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
// protocol, over a raw TCP socket or a serial COM port.
//
// Any controller set to GS-232A works, which is most of them: the microHAM ARCO
// natively, and the ERC family (Easy Rotor Control — ERC Mini, ERC interface),
// which EMULATES GS-232A/B over its USB port. An ERC can also be configured for
// Hy-Gain DCU-1, a different command set entirely, so it must be set to GS-232.
//
// On the ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
@@ -37,7 +43,12 @@ const (
type Client struct {
Host string
Port int
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
ComPort string // serial transport: "COM5" etc.
// Baud matters on some controllers. An ARCO's USB virtual COM ignores it, but
// an ERC (Easy Rotor Control) runs at whatever rate its own configuration
// sets — commonly 9600 or 19200 — and a mismatch reads as a dead rotator.
// Zero keeps the historical 9600.
Baud int
}
// New returns a TCP Client with sane defaults applied for empty fields. There
@@ -56,8 +67,8 @@ func New(host string, port int) *Client {
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
// open at 9600 which also suits a real RS-232 hookup left at its default.
func NewSerial(comPort string) *Client {
return &Client{ComPort: comPort}
func NewSerial(comPort string, baud int) *Client {
return &Client{ComPort: comPort, Baud: baud}
}
// roundTrip opens a connection (TCP or serial per the client's config), sends
@@ -66,9 +77,13 @@ func NewSerial(comPort string) *Client {
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
baud := c.Baud
if baud <= 0 {
baud = 9600
}
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
if err != nil {
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
}
_ = sp.SetReadTimeout(200 * time.Millisecond)
conn = sp
+8
View File
@@ -629,3 +629,11 @@ func isTimeout(err error) bool {
}
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() }
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.1"
appVersion = "0.22.3"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.