Compare commits

...
104 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
rouggy d98bb2e929 chore: release v0.22.1 2026-07-29 23:13:27 +02:00
rouggy 3ab6b95ff8 fix: auto-call CW gap started before the message had finished
Reported: a 6 s auto-call gap gives clearly less than 6 s between the end of one
CQ and the start of the next.

The wait after sending watched only the keyer's busy signal. That signal travels
from the keyer to the window as an event, and the code gave it one second to
appear before giving up — if it had not arrived and cleared by then, the wait
ended immediately and the gap ran from the START of the call. A 6 s gap after a
4 s CQ becomes about 2 s of silence, which is exactly what was heard.

The message's own duration is now a FLOOR: the wait runs at least the estimated
sending time, and the busy signal only extends it (slow link, long buffer),
capped as before. The estimate can only be approximate, but it cannot be skipped
by a late event, and erring long costs a slightly wider gap rather than a call
that steps on the previous one.
2026-07-29 22:28:18 +02:00
rouggy b0da6a3d14 fix: RX and TX both on SUB is simplex, not split
Reported on an FTDX101: with RX and TX moved together to the sub VFO, OpsLog
showed split — and took the MAIN frequency as the transmit one, which would log
the wrong frequency.

ST is a bare flag. It says "split" without saying which VFO transmits, and this
rig raises it whenever the transmit VFO is the sub one, whether or not the
operator is also listening there. FT names the transmit VFO, so where the receive
VFO is known as well (FR), split is derived from the pair: they differ or they do
not. That is a fact about the rig's state rather than a flag whose meaning varies
by model.

The fix is therefore in the probe ORDER, not in the reading: FT is asked first
when FR answered, and ST stays the fallback for rigs that have neither. A test
pins the order and both halves of the trap — that FT gets this case right, and
that ST alone gets it wrong.
2026-07-29 22:10:21 +02:00
rouggy 2d7469a7f7 fix: do not report a Yaesu as connected when it answers nothing
The seven minutes of reconnects in the operator's log turned out to be the radio
switched off — no defect. But the log said otherwise:

  20:14:42 yaesu: ID query failed (timeout) — continuing…
  20:14:44 yaesu: connected on COM7 @ 38400 baud, model="FTDX101D"

Opening the port was treated as connecting, and the model name was left over from
the previous session, so a powered-down rig produced a line claiming it was
connected as a named model. That is what sent me looking for a software fault.

Connect now tracks whether ANY probe answered. If none did, it clears the stale
model, says so in terms an operator can act on — is it powered on, is the CAT
rate right — and returns an error so the reconnect loop keeps trying rather than
believing it succeeded.
2026-07-29 21:53:26 +02:00
rouggy 775f411606 fix: Yaesu Connect leaked the serial handle on every reconnect
Connect assigned a fresh handle to y.port without closing the one it already
held. It is called again on every reconnect, so a rig that fails to answer leaks
one handle per attempt — every few seconds, indefinitely.

Windows opens a serial port EXCLUSIVELY, which makes this visible as "yaesu: open
COM7 @ 9600 baud: Serial port busy" in an operator's log: OpsLog was competing
with itself for the port.

This is a real defect found while reading the reconnect loop, but I want to be
straight about its limits: it does NOT explain the seven minutes of "ID query
failed — timeout" in the same log, where the port opened and the rig stayed
silent. That one is still open, and the operator alternates between two separate
radios (a TCI SDR and the Yaesu) rather than sharing one port, so a conflict
between backends is not the explanation either.
2026-07-29 21:43:05 +02:00
rouggy 6501e97895 fix: read the FIRST digit of the Yaesu VFO reply — I inverted it yesterday
Reported both ways round, which is what settles it. With RX and TX on SUB
everything worked; with them on MAIN the frequency froze and a spot click tuned
the sub VFO. The log shows the rig answering FR01 in the MAIN case.

So on an FTDX101 the state is the FIRST digit — 0 = main — and the second is a
separate parameter. Yesterday I changed this to the LAST digit, which read FR01
as SUB and inverted the whole thing. The fault that change was meant to fix ("SUB
shows MAIN") came from reading VS instead of FR, and probing FR had already fixed
it on its own; the digit change was an unnecessary guess layered on a real fix.

Nothing else needed changing for the operator's two questions: the display and
SetFrequency already follow curVFO, so a spot now tunes whichever VFO holds RX
and TX, and the main frequency is read when on main. A test states that in those
terms — active VFO and which command a spot writes — rather than only as a byte,
because the byte is what I got wrong while believing the logic was right.
2026-07-29 21:39:22 +02:00
rouggy f3807c21ba fix: FTDX101 answers the VFO query with two digits, and only the first was read
The log named the cause in one line: yaesu: receive VFO is read through FR
(answered "FR01;"). An FTDX10 answers "FR0;" — one digit — so reading the digit
straight after the prefix worked there and took the 0 of "01" on an FTDX101.

The rig was on SUB and OpsLog said MAIN, which is the reported "Sub shows the main
frequency". It also explains the second half: split is "TX VFO differs from RX
VFO", so a receive VFO that alternated between right and wrong made the split flag
flip between consecutive polls and swap freq and rx in the log.

The state is now taken from the LAST digit before the terminator, which is correct
on both forms, and the same reading applies to ST/FT/VS rather than leaving the
next two-digit rig to be found in the field.

On the operator's question of whether freq should simply be the active VFO: no —
freq is the TRANSMIT frequency by ADIF convention (FREQ is the QSO frequency,
FREQ_RX the receive one, set only when split). In simplex they are the same, so it
does read as the active VFO; in split it must stay the transmit VFO or the log
records the wrong frequency. What the operator saw was this bug, not that rule.
2026-07-29 18:35:47 +02:00
rouggy d536c39ef5 docs: label the three numbers in the QSO counter
"Showing 10000 of 23683 matches · 28648 total" was read as a filter matching more
than the log holds. The numbers were right — 23 683 matches out of 28 648 QSOs,
capped at 10 000 displayed — but the middle one belonged to "matches" while
sitting where "of N" normally means the total.

Each number is now named where it appears: shown · match the filter · in the log.
Thousands separators too, since 23683 and 28648 are hard to compare at a glance.

No counting change: both counts run over the same table, so matches can never
exceed the total.
2026-07-29 18:10:39 +02:00
rouggy 284a7a18c7 fix: QRZ download marked every paper-QSL QSO as QRZ-confirmed
Reported: after a confirmation download they all turn to Y.

The test accepted qsl_rcvd = Y as a confirmation. That field is the operator's
OWN paper-QSL flag, which they uploaded to QRZ in the first place — so QRZ handed
it back and OpsLog read it as QRZ's answer. On a log full of paper cards that is
most of the log, which is exactly what was seen.

Only QRZ's own statement counts now: app_qrzlog_status = C, or the ADIF field
qrzcom_qso_download_status = Y that QRZ sets for it. LoTW and eQSL confirmations
are explicitly not accepted either — they are other services' answers, and the
column says QRZ.

This status feeds award slots, so a false Y is a QSO counted as confirmed when it
is not. That asymmetry is why the rule is narrow rather than generous, and the
test lists the negatives as deliberately as the positives.

The changelog warns that earlier downloads may already have set the column
wrongly — the fix stops it happening again but does not undo it.
2026-07-29 17:59:04 +02:00
rouggy 482f81fe45 docs: say that the import option also fills the confirmation statuses
Asked for as a missing feature. It is not missing — the "fill my station fields"
option has stamped the default QSL / LoTW / eQSL / Club Log / HRDLog / QRZ.com
statuses on empty fields since v0.14. What was missing is any mention of it: the
option's description talked only about MY_* fields, so the behaviour was
undiscoverable except by comparing a log before and after.

The description now says it, in both languages, and names why it matters — a
WSJT-X log carries almost no QSL fields.

The rule is also pinned by a test now, which meant splitting the fill from the
settings lookup: blanks are filled, existing values are never touched.
Overwriting them would erase confirmations the operator has actually received,
and that is the half a future edit is most likely to get wrong.
2026-07-29 17:56:10 +02:00
rouggy 5d7a9a9562 docs: changelog for the DXHunter tune-on-spot-click (a036120)
Describes what the operator gets: a spot click in DXHunter now moves the radio,
not just the callsign field. Notes explicitly that a packet without <FREQ>/<MODE>
behaves exactly as before — that is the reassurance anyone already using
remote_call needs, and it is the part a feature description usually omits.

Appended at the bottom of the block, per the rule adopted with 0.22.0: a version
is read top to bottom.
2026-07-29 17:51:23 +02:00
rouggy a036120871 feat: remote_call UDP accepts <FREQ>/<MODE> from DXHunter and tunes the active CAT backend 2026-07-29 17:49:29 +02:00
rouggy ccceab9d02 fix: Yaesu showed MAIN when the operator had moved RX and TX to SUB
The operator's description separates the two cases precisely, and that is what
identifies the fault. RX alone on SUB displayed VFO B correctly; RX AND TX on
SUB — entirely on the sub receiver — displayed VFO A.

VS was being read as "which VFO is in use". On an FTDX101 it does not answer that
question: with both RX and TX on sub it still reported the main VFO. FR is the
command that selects the RECEIVE VFO, and it is now asked first, VS remaining the
fallback for models that lack it.

Split falls out of the same correction: split means the TRANSMIT VFO differs from
the RECEIVE one, so a wrong receive VFO made the comparison wrong too — which is
the second half of what was reported. A test covers all four RX/TX combinations,
including the one that is NOT split (both on sub) and reads as split if you take
the transmit VFO alone.
2026-07-29 17:26:00 +02:00
rouggy 35db1440e4 fix: repair a MySQL logbook already left with TEXT columns
Translating the schema correctly does nothing for a database that already
exists. This operator's had been created by an earlier attempt, so qso.state was
already TEXT and every launch died on the same statement — CREATE INDEX … ON
qso(state), error 1170 — with the connection failing at startup and no way
forward from the UI.

Columns that MUST be VARCHAR (the indexed and keyed ones) are now converted
before the migrations run. It reads information_schema, touches only the columns
in varcharColumns, and only when they are actually TEXT — a no-op on a healthy
database, and it preserves each column's NULL/NOT NULL.

A failure to repair IS reported: it is the difference between a logbook that
opens and one that does not. A failure to READ information_schema is not — a
server that will not answer it can still be running a perfectly good schema, and
refusing to open would turn a non-problem into an outage.
2026-07-29 16:35:57 +02:00
rouggy e3aabc06a4 fix: convert EVERY column in a MySQL baseline, not just the first per line
The quoting fix was necessary but not sufficient: "column 'op_name' can't have a
default value" persisted.

The translation worked line by line and rewrote only the FIRST column on each
line. Our own migrations put one column per line, so nothing showed there — but
sqlite_master stores a table's CREATE statement with every ALTER-added column
appended to the SAME line, and the baseline is built from exactly that. On a
logbook that had been through upgrades, most columns therefore kept their TEXT
type and MySQL rejected the schema.

Two consequences fixed together: every match is now rewritten, and each one is
decided from ITS OWN declaration — the text between the surrounding commas —
rather than from the whole line. Deciding per line would have made every column
sharing a line with a default-bearing one into VARCHAR(255), which breaches the
InnoDB row-size limit the TEXT rule exists to respect.

Tests cover the many-columns-on-one-line shape and pin that a neighbour's DEFAULT
does not leak.
2026-07-29 16:30:44 +02:00
rouggy ab9d0bfe0f fix: MySQL schema build failed on quoted column declarations
Two errors from a shared MySQL logbook, both fatal to the schema and neither
naming its cause:

  CREATE INDEX … ON qso(state) → 1170 BLOB/TEXT column used in key
                                 specification without a key length
  op_name TEXT … DEFAULT ''    → 1101 TEXT column can't have a default value

One root cause. TEXT columns are rewritten to VARCHAR(255) when they are indexed
or carry a default — that is exactly what those two MySQL rules require — but the
pattern only matched a BARE column name. The SQLite baseline dump quotes its
identifiers, and mysqlDDL turns those quotes into backticks before the TEXT rules
run, so every column coming from the baseline kept its TEXT type and MySQL
rejected the schema.

The operator saw none of this: just "MySQL is enabled but the connection failed
at startup" and a silent fallback to local SQLite — which is the worst shape for
a shared logbook, because each operator then logs into their own copy.

The pattern now accepts a quoted or bare name and puts the quoting back. A test
covers both forms, plus the case that must NOT change: an unindexed column stays
TEXT, since VARCHAR everywhere would break MySQL's row-size limit. Reverting the
one-line change makes it fail.
2026-07-29 16:26:04 +02:00
rouggy a3fd32ec91 fix: split read (and set) backwards on a Yaesu reporting the TX VFO
Reported on an FTDX101: the panel showed split ON with the radio OFF, and the
reverse — while the same model behaved correctly for another operator, and an
FTDX10 was fine throughout. That pattern is the clue: one operator was on MAIN,
the other on SUB.

The two commands say different things and were read alike:

  ST is a split FLAG   — ST1 means split, whatever VFO is in use.
  FT names the TX VFO  — FT0 = transmit on A, FT1 = transmit on B.

Split is on when the rig transmits on a DIFFERENT VFO from the one it listens to.
Reading FT1 as "split" is therefore correct only on VFO A, and exactly inverted
on SUB. It is now compared with the current VFO.

Writing had the identical fault, and it was worse: sending FT1 for "split on"
while the operator listened on SUB CLEARED the split it was asked to set. Both
directions are pinned by tests over each VFO state.
2026-07-29 16:20:25 +02:00
rouggy c297f91ca8 fix: a cluster spot dragged the rig from SUB back to MAIN
Reported on an FTDX101 (F4NBZ): listening on the SUB VFO, clicking a spot moved
the radio to MAIN.

SetSimplexMode is the first thing SetFrequency tries, and it means "receive and
transmit here, simplex" — OmniRig applies that to the MAIN VFO by definition. It
is there because several Icoms ignore direct FreqA/FreqB writes, so it stays for
the main VFO; on SUB it is now skipped entirely and FreqB carries the change.

The generic "Freq" property is skipped on SUB too: on several rig files it IS the
main VFO, so writing it would move the VFO the operator is not using — or pull
them back to it, which is the bug being fixed.

An operator on SUB put themselves there deliberately. A spot click asks for a
frequency, not for a change of VFO — that distinction is the whole fix, and a
test pins which property each VFO state writes.
2026-07-29 16:07:20 +02:00
rouggy ee1f9ccf35 fix: ADIF import silently dropped a file with no header
Reported with screenshots: a dozen records lifted out of wsjtx_log.adi into a new
.adi imported as "0 imported, 0 ignored, 0 total". The parser only emitted
records after <EOH>, and a hand-assembled file has no header — so the whole file
was read as header text and thrown away. The count says the file was empty; there
is nothing on screen to suggest a missing tag, which is why this was reported as
"import does nothing".

An <eor> is proof that a record ended, header or no header. Fields are now
collected always and DISCARDED at <eoh> — that tag is exactly the statement
"everything before this was the header", so real headers stay out of the data
just as reliably, and a headerless file keeps its first record instead of losing
it to the same rule.

An existing test asserted the old behaviour. It encoded a defensible reading of
the spec, but the file it rejects is one operators really make and the rejection
is silent, so the test is rewritten with the evidence that changed it rather than
deleted.
2026-07-29 15:06:41 +02:00
rouggy 6fb7f06375 docs: read a changelog block top to bottom — order 0.22.0 chronologically
New entries were being prepended, so a version block read backwards: an operator
met "the Yaesu meters are corrected" and "the console follows the mode" several
entries before learning that a Yaesu console existed. The fixes made sense only
to someone who had followed the development.

0.22.0 is reordered so each feature appears before what was fixed on it, and
CLAUDE.md now says to append at the bottom.

Restored the entry that INTRODUCES the Yaesu console: I had overwritten it when
editing the meters entry in place, so the release described corrections to a
panel it never announced.
2026-07-29 15:01:35 +02:00
rouggy 54caee37c4 chore: release v0.22.0 2026-07-29 14:55:17 +02:00
rouggy 15fdbce22a fix: WinKeyer 2 sent the dit/dah ratio to the key-compensation command
The byte trace from a real WK2 settled in one line what no amount of reading
could: "TX 11 32". Command 0x11 is SET KEY COMPENSATION in milliseconds, not the
dit/dah ratio — that is 0x17. So a neutral ratio of 50 asked for 50 ms of extra
key-down on EVERY element. At 25 wpm a dit is 48 ms, so elements more than
doubled and ran into each other: the reported "it sends one element, then a long
pause".

The ratio now goes out as 0x17, and the compensation is explicitly set to 0 —
merely stopping the wrong command would leave an affected keyer misbehaving,
since it keeps the value in EEPROM until something writes over it.

The init sequence is now built by a separate function so the BYTES are testable,
and a test pins each command number. These numbers are the contract with the
hardware, and a wrong one produces a fault that cannot be diagnosed from the UI
at all — this one cost the operator weeks and needed a trace to find.

This is why the fix waited for the trace rather than being guessed: the plausible
guesses (mode register, sidetone, WK1-vs-WK2 differences) were all wrong, and any
of them shipped blind would have broken the keyers that work today.
2026-07-29 14:53:18 +02:00
rouggy c7737aabd6 docs: changelog block becomes 0.22.0, and the duplicated CW entry is merged
Renamed from 0.21.9 at the author's request: this release adds native Yaesu and
Xiegu CAT, CAT sharing over Hamlib NET rigctl, a Yaesu console and a fifth CW
engine, which is a minor version rather than a patch.

The Yaesu keyer had two entries — one written when it was added, one when the
FTDX10 turned out to refuse it. Merged into the single statement an operator
needs: what it does, where to select it, and which rigs accept it.
2026-07-29 14:49:15 +02:00
rouggy 6f5acc2eef docs: name the rig setting that makes the voice keyer inaudible
The weak audio was not a level at all: the radio was still modulating from its
front microphone, so almost nothing of the USB feed reached the air. On an FTDX10
that is MENU → SSB MOD SOURCE = REAR.

Worth putting in the settings hint rather than leaving in a conversation — every
operator wiring a voice keyer for the first time meets it, and no amount of gain
in OpsLog can compensate for a rig listening to the wrong input.
2026-07-29 14:46:50 +02:00
rouggy 67a03c3ddc feat: a level control for the voice keyer, and name every CAT backend in its PTT list
The messages went to the radio exactly as recorded. Nothing in OpsLog could
raise them, so a microphone captured quietly drove the rig quietly and the only
remedies were the radio's own USB input menu or the Windows mixer — which is
where the operator was heading. There is now a level from 10 to 400 %, applied
with clamping (a wrap would turn loud speech into noise on the air), and Play
previews at that same level so the adjustment is made against what will actually
be transmitted.

The PTT method list also named only OmniRig, Flex, Icom and TCI, so choosing a
native Yaesu left "CAT" with no backend beside it — which reads as "there is no
CAT PTT for my radio" and sends the operator to RTS on a COM port that has
nothing to do with the rig. Same list-needing-every-member shape as three
earlier bugs in this feature. The TestPTT log line had the same rot: it said
"CAT via OmniRig" whatever backend was running.
2026-07-29 14:29:47 +02:00
rouggy 8da3a27803 fix: the PWR bar stayed at full scale after transmitting
Receiving cleared the two bar percentages but not the values the bars now
actually draw from: the watts and the SWR ratio, added when the meters were
corrected. So the power bar sat pinned across the panel with the rig plainly
receiving — the dash in the label said one thing and the bar another.

Every transmit value is cleared now, and the peak-hold state with them: a peak
left in the holder would have carried the last transmission's reading into the
start of the next one, which is worse, being wrong while it looks live.

The panel also draws both bars from zero unless the rig reports transmitting, so
a value that has not been refreshed yet cannot show as output.
2026-07-29 14:20:58 +02:00
rouggy e0cefb5c41 fix: the Yaesu power meter is not linear — calibrate it on three points
One point could not reveal the curve. Scaling 207 = 100 W straight down read
30 W where the radio showed 10, and 75 where it showed 50 — wrong everywhere
except at the single point it was fitted to.

Three readings taken against the rig's own display give the shape:

  raw  62 → 10 W
  raw 155 → 50 W
  raw 207 → 100 W

Interpolating between them reproduces the radio exactly at those points and stays
close in between. I did not fit a formula: three samples can be made to support
several curves, and the operator can check a table against their own meter.

Above the top the last segment's slope continues rather than clamping, so a rig
driving an amplifier does not sit pinned at 100 W. A test pins the measured pairs
and the monotonicity, so a later change that breaks them fails against the radio
rather than against taste.
2026-07-29 14:11:25 +02:00
rouggy 113faede14 fix: hold the TX meters through the gaps between words
A quarter-per-poll decay covers the milliseconds between CW elements but not the
gaps that matter on the air: between the words of a CQ, in CW as in SSB, the
meters genuinely read 0 for most of a second and the bars fell with them.

A peak now stands for 1.5 s before it starts to fall, and still rises instantly —
a needle goes up fast and comes down slow. The SWR RATIO is not updated at all
from a zero reading: showing 1.0 during a word gap is worse than showing a stale
figure, because it looks like good news.

A test caught a real defect on the way: the proportional decay stalls on
integers. With the needle at 13 and the truth at 10, a quarter of the gap rounds
to zero and the meter sat three units high for ever. It now always steps down by
at least one, so it converges.
2026-07-29 13:59:47 +02:00
rouggy 8e491544dd fix: power is RM5, not RM4 — the CW keying was the experiment
An FM carrier could not answer it: constant by definition, so nothing to
correlate. CW at 100 W did, because the keying itself varies the output:

  key down: RM4=25 RM5=207 RM6=13
  key up:   RM4=25 RM5=0   RM6=0

RM5 follows the RF envelope exactly — it IS the power meter. RM4 sits near 25
whether the key is down or up, so it is not measuring output at all, and reading
it as power is what showed 8 W on a 100 W transmission. The operator's hunch was
right and my first reading of the ramp was wrong: what I took for a needle
rising was RM4 drifting, not tracking.

Watts are now derived from that meter (207 = 100 W, measured) instead of the
power SETTING scaled by a percentage — the setting says what was asked for, the
meter says what left the radio.

The bars also hold their peak with a gentle decay. In CW the meters genuinely
read zero between elements, so following the raw value made them flash to nothing
several times a second; a needle has inertia, and this only ever holds a value
the radio really reported.
2026-07-29 13:56:59 +02:00
rouggy cbe571a742 chore: log the power setting with the Yaesu meters, to identify the wattmeter
One transmission cannot name it. RM4 rose while RM5 stayed flat — which points at
RM4 — but RM5 differed BETWEEN transmissions, 208 then 105, which points at RM5.
Both readings are consistent with either answer, so picking one now would just be
the FT-991A table mistake again in a new place.

The survey line now carries the rig's power setting, and the sample cap is raised
from 12 to 40 so two transmissions fit. Keying at, say, 10 W and then 100 W makes
the answer a one-line comparison: the wattmeter is whichever index moves with the
setting.

The displayed mapping is left alone until that comparison exists.
2026-07-29 13:52:45 +02:00
rouggy bdda7ad07a fix: read the real SWR on the Yaesu, and show the ratio
A second measurement at a known mismatch settled both the index and the scale.
At SWR 1.1: RM6=0. At SWR 1.5: RM6=52, while RM4 kept tracking the power.

52/255 = 0.204, which is the reflection coefficient of a 1.5 SWR to three
decimals. So the raw value is rho scaled to 255, and the ratio is
(1+rho)/(1-rho) — physics, not a curve fitted through two points, which is why
2.0 and 3.0 fall out of it correctly without ever having been measured.

The panel now shows that ratio, the number the operator reads on the rig, rather
than a percentage of meter travel — and a dash while receiving, since a stale SWR
from the last transmission reads as a live one.

Both measurements are recorded in the code and in a test, so the mapping is
evidence rather than a table borrowed from another model — which is exactly how
it came to read 81 in the first place.
2026-07-29 13:49:09 +02:00
rouggy 3fd6763ff0 fix: the Yaesu SWR bar showed a meter unrelated to the antenna
Measured on the radio, steady carrier for three seconds:

  RM1=0  RM2=unsupported  RM3=0  RM4=9→18→21→22  RM5=208 flat  RM6=0

RM4 is the index that RAMPS with the output, so RM4 is the power meter. RM5 sat
at 208 from the first sample to the last, unmoved by the power — that is not SWR
on an operator reading 1.1, and 208/255 is exactly the 81 that appeared on the
bar. Borrowing the FT-991A's table, which puts SWR on RM5, is what put it there.

SWR now reads RM6. It stayed at 0 throughout, which is CONSISTENT with a 1.1
match but does not prove the index — only a deliberate mismatch would, and I am
not asking for that. A bar at zero on a good antenna is honest; 81 was actively
misleading.

The measurement is written into the code next to the mapping, so the next person
sees the evidence rather than a table copied from another model.
2026-07-29 13:42:25 +02:00
rouggy a89c6e0df8 chore: sample the Yaesu meters throughout a transmission, not at its start
The one-shot survey fired as the transmission began and caught the meters still
at rest — RM4=13 and everything else zero, which identifies nothing. And the
operator's reading points the other way: the bar showing 81 tracks his 100 W,
while the one labelled power sat at 8.

What names a meter is which index FOLLOWS the power over a few seconds of steady
carrier, so the survey now samples on every poll while transmitting, capped at a
dozen lines. Two seconds of tune will settle it.

Still not guessing at the mapping: the numbers will say which index is power and
which is SWR on this radio, and it gets corrected then.
2026-07-29 13:38:09 +02:00
rouggy 48d92ff547 fix: CW speed drove the wrong keyer; survey the meters to find the SWR one
Speed: with DTR/RTS line keying the PC does the timing, so the Yaesu console's
slider — which sets the rig's internal keyer — changed nothing audible and looked
broken. Both entry points now go through one handler that drives the engine
actually sending, and additionally sets the rig's own keyer whenever a Yaesu is
on CAT, so the radio's front panel shows the same figure.

SWR: an operator reads 80 on the bar with a real SWR of 1.1. That is the shape of
reading the WRONG METER — ALC, say — not of a scaling error, and which RM index
carries which meter is not consistent across the family. Rather than guess again
and move the wrong number somewhere else, the first transmission now logs RM1
through RM6 raw, once. The log will say which index is which on this radio, and
the mapping can then be corrected as a fact.
2026-07-29 13:32:29 +02:00
rouggy adadb632fa docs: FTDX10 confirmed — no CAT keying, the serial DTR keyer is the way
Tested on the radio: DAKY does not help, KY is refused whatever PC KEYING is set
to, and the "Serial port (DTR=CW / RTS=PTT)" engine on the second COM port keys
correctly. So my earlier "try DAKY first" was wrong, and the order is now the
other way round: name what works, mention the model that refuses.

The KY engine stays. It is documented for the FTDX101 / FT-991A / FT-710 family
and costs nothing to keep — a rig that refuses it now says so in one clear
sentence instead of transmitting nothing for no stated reason.
2026-07-29 13:27:04 +02:00
rouggy b0b25c7f1f docs: name PC KEYING = DAKY, the setting that should enable CAT keying
The operator found the menu I said to look for: it offers DAKY, RTS and DTR. KY
is the CAT route, so DAKY is the setting that would enable it — which makes my
previous "this rig has no CAT keying command" too strong a claim to leave
standing, since it was drawn from a refusal at the DEFAULT setting.

The error and the settings hint now name DAKY first, and keep the serial line
keyer as the fallback. That order matters: the CAT route costs nothing, the
fallback costs a second COM port.

Marked as untested rather than verified — I have no confirmation that DAKY makes
KY work on this model, only that it is the option that should.
2026-07-29 13:23:22 +02:00
rouggy e120bd4f04 docs: an FTDX10 has no CAT keying command — say what works instead
Confirmed on the radio: it answers "?;" to KY, so this is not a setting to find.
My previous message sent the operator looking for a MENU → CW → PC KEYING entry I
named without checking it exists on that model, and they could not find it.

The message now names the path that does work: the "Serial port (DTR=CW /
RTS=PTT)" keyer on the rig's OTHER COM port — the standard one — while CAT keeps
the enhanced one. Same for the settings hint and the changelog.

The KY engine stays: it is documented for the FTDX101 / FT-991A / FT-710 family.
It now fails loudly and usefully on the models that lack it, which is the
difference between a dead feature and a wrong one.
2026-07-29 13:21:49 +02:00
rouggy 40df4fe22f fix: read the rig's answer to KY — it was killing the CAT link, silently
The log timed it exactly: the CW send at 13:16:17.591, the CAT dropping at
13:16:17.633, forty milliseconds later.

An accepted KY says nothing, but a REJECTED one answers "?;" — and nobody was
reading it. The frame sat in the buffer until the poll loop's next query picked
it up, failed, and the Manager tore the link down. That is the disconnect on
every macro click, and it also explains the silence: the backend was being
rebuilt underneath the send.

Two changes. The KY write is now followed by a short read: silence means
accepted, "?;" means refused — and the operator is told so, naming MENU → CW →
PC KEYING, instead of getting nothing with no reason. And FA; retries once when
it meets a stray rejection, because a "?;" arriving there is almost never about
FA: the rig answers frequency queries perfectly well, it is the previous
command's refusal being attributed to this one.

This does not yet prove the FTDX10 accepts KY at all. It makes the next run say
so plainly either way, which is the point.
2026-07-29 13:18:52 +02:00
rouggy d114a73729 fix: the FTDX10 refuses KY; — stop waiting on a status it will never send
The log settled it: the rig answers "?;" — its "unknown command" — to KY; and to
MG;. So the keyer status query cannot work on this model, and every send spent
four seconds waiting for an answer that was never coming.

"?;" is now recognised as what it is. A command refused once is never asked
again: models implement different subsets, and re-asking costs a 600 ms timeout
on every slow beat for a control that will never answer — which is also why the
panel felt sluggish.

Without a buffer status there is still a real constraint: one KY command carries
24 characters and the rig DROPS the rest silently. So the send is now paced by
how long the text takes to key, from PARIS timing at the rig's own speed. A long
macro goes out complete instead of losing its tail.

That leaves the question the log cannot answer: whether KY <text>; itself is
accepted. If the rig also replies "?;" to it, the CAT menu's PC KEYING setting is
the next suspect — but nothing in the code will be guessing at it.
2026-07-29 13:15:36 +02:00
rouggy d0666581c3 fix: a CW macro knocked the Yaesu CAT link over
Clicking a macro dropped the CAT for a few seconds, keyed nothing, then came
back.

ask() returned the first ';'-terminated frame it saw, whatever command it
belonged to. KY produces NO reply, so the next query — FA; from the poll loop —
collected a leftover frame, failed to parse it as a frequency, and ReadState
reported an error. The Manager reads that as "lost the rig": disconnect, wait,
reconnect. Hence the drop and the automatic recovery a few seconds later.

Replies are now matched to the command that asked for them: anything else is
discarded and logged, so a stray frame costs one log line instead of the link.

This also explains the silence — the send never got a clean run at the port
while the backend was being torn down under it.
2026-07-29 13:03:09 +02:00
rouggy 37dc2a07e5 fix: the Yaesu keyer refused to send — a status reply we read too strictly
"the keyer buffer stayed full for 4s" and nothing keyed, on a rig whose CAT is
working. The buffer was not full: the check demanded the reply be exactly "KY0;"
with the digit at byte 2, so anything the FTDX10 phrases differently — a space
before the digit, another command's reply arriving first — read as "still full"
on every poll until the deadline.

The test is now asymmetric on purpose: only a clear "1" holds the send back.
Anything unrecognised goes ahead. Refusing to transmit because a status line was
phrased unexpectedly is the worse failure — the operator gets silence with no
explanation, where at worst sending early truncates a long message, which the
chunk loop then recovers from.

The reply is also logged once per run, since we have no verified sample of it —
that line is what turns the next surprise into a fact instead of a theory.
2026-07-29 13:00:30 +02:00
rouggy 7424bc6e81 fix: the Yaesu keyer reported "Rig CAT offline" while CAT was working
Three places decide what a rig keyer is, and the Yaesu was only in two of them.

The CW panel's status came from a list naming icom and flex; anything else fell
back to the WinKeyer status, which reports disconnected because no WinKeyer is
attached. So the panel said the rig CAT was offline while the console beside it
was reading the FTDX10 perfectly.

The send loop had the same gap: a rig keyer BUFFERS the whole message, so the
wait before <LOGQSO> is a length estimate, while WinKeyer watches a busy echo
that will never arrive here. Auto-call would have raced the transmission.

This is the third time the same shape has bitten in this feature — a list of
engines that needs every member named, with a silent fallback for the rest.
Grepping for the pair "icom || flex" is what finds them.
2026-07-29 12:58:08 +02:00
rouggy 01c93d979f fix: the Yaesu keyer selection was thrown away when read back
Choosing "Yaesu (rig keyer)" saved correctly and still produced a WinKeyer panel
asking for a COM port. The engine is normalised on load through a chain that
names icom, flex and serial and maps EVERYTHING ELSE to "winkeyer" — so the
value came back as WinKeyer on every read, whatever the settings said.

That is why the panel offered COM3 then COM10: it genuinely believed the engine
was a WinKeyer. The list now names yaesu too.

The same shape of bug is worth watching for: a normaliser with a silent default
turns an unknown value into a plausible one instead of an error, and the symptom
appears far from the cause — here, in a panel three components away from the
setting.
2026-07-29 12:55:26 +02:00
rouggy 0190255762 fix: the Yaesu keyer asked for a COM port it does not use
Selecting the Yaesu keyer fell through to the WinKeyer branch of the settings,
which offers a serial port picker and a Connect button. So the operator went
hunting for the right port — trying the standard COM and the enhanced one in turn
— and neither connected, because this keyer uses NEITHER: it keys over the CAT
link already configured in Settings → CAT.

It now has its own branch, like the Flex one: a speed field, the warning when the
CAT backend is not a Yaesu, and a line saying explicitly that keying rides the
CAT link. Speed changes go to the rig's keyer instead of a WinKeyer that is not
there.
2026-07-29 12:49:19 +02:00
rouggy 0cc806722e fix: Antenna Genius drew both ports on the same antenna
Reported on an 8x2: port A correctly on the 80 m vertical, then a few seconds
later both A and B shown on the same beam.

The display preferred the TX antenna, falling back to RX. On an 8x2 only ONE port
can hold the transmit antenna, so the switch reports the same txant on both — and
every keepalive poll redrew port A as whatever port B transmits through. The RX
antenna is the per-port selection, and the only thing Activate sets, so it is
what a port shows; TX stays the fallback for a port reporting no RX antenna.

A port change is now logged with the raw line — but only on CHANGE, since the
device pushes state every few seconds and logging each one would bury the rest.
Without any trace, "port A jumped to the wrong antenna" cannot be checked.

Two tests pin it: rx wins over a disagreeing tx, and a message about one port
never moves the other — which is the shape that was actually on screen.
2026-07-29 12:46:48 +02:00
rouggy 08f3e54afb feat: CW through the Yaesu keyer — fifth CW engine
The radio has a keyer and a command to feed it (KY), so an FTDX10 needs no
WinKeyer and no second cable, exactly as the Icom CI-V and Flex CWX engines
already do. The rig keys with its own timing, which is why the spacing is right
where a PC keying a line through USB latency drifts.

Text is filtered to what the keyer can actually send: an unsupported byte does
not produce an error on a Yaesu, it can abort the whole buffer, so the rest of a
macro would vanish silently. It is then fed in 24-character pieces, waiting for
room between them — the rig DROPS what does not fit, again with no error, so a
contest CQ would lose its tail.

STOP is the honest gap. Yaesu documents no buffer-clear, so it drops the
transmitter (TX0) instead: nothing queued reaches the air, which is what Escape
means to an operator. It deliberately does NOT send "KY0;" — a plausible-looking
clear that the rig would read as the CHARACTER zero and transmit.

A test caught a real one on the way: tabs and newlines were dropped as
"unsupported", gluing the words either side together, so a macro written on two
lines went out as CQCQ. Whitespace now becomes a word gap before filtering.

Settings warn when the Yaesu keyer is selected without the Yaesu CAT backend —
otherwise it simply never keys, with nothing on screen saying why.

Send path follows the CAT reference and how Hamlib drives these rigs; NOT yet
verified on the air.
2026-07-29 12:42:40 +02:00
rouggy 520d17810c fix: the Yaesu panel showed the TRANSMIT frequency as the main VFO under split
Confirmed on the FTDX10: receiving on 14.018.98 and pressing SPLIT put 14.019.98
in the big display with "TX 14.019.98 (0 kHz)" under it. The radio was right —
RX 14.018.98, TX 14.019.98, up 1 kHz on CW — the panel was not.

RigState follows the ADIF convention where freq_hz is the TRANSMIT frequency, so
under split it is the OTHER VFO. Taking it as the main display showed the
operator the frequency they transmit on, and then an offset of that frequency
against itself: zero.

The header now reads the listening frequency (freq_rx_hz when split, freq_hz
otherwise) and the TX line shows the real transmit frequency and offset.
2026-07-29 12:19:49 +02:00
rouggy c9218310ae feat: the Yaesu console follows the mode — CW gets its own controls
Microphone gain and VOX are meaningless in CW: the rig ignores both, so showing
them is showing dead controls. They are hidden, and a CW card takes their place
with keyer speed (KS), break-in (BI) and ZIN (ZI), the zero-in that retunes so
the station being received lands on the operator's own pitch.

ZIN is a one-shot with no state, so it is a plain button rather than a chip that
would look latched, and no settings read-back follows — the frequency change
arrives through the normal poll like any other.

The keyer values are read on the slow beat whatever the mode, so the card is
already populated the instant the operator switches to CW instead of filling in a
poll cycle later.

Which controls show is decided by the RIG's mode, not the logged one: the logged
mode can be a digital sub-mode the radio knows nothing about.
2026-07-29 12:07:28 +02:00
rouggy 8cc6997eec feat: colour the Yaesu S-meter like a real one — green, amber past S9, red at +20
The bar was one colour for its whole travel, so nothing distinguished a signal
that is merely readable from one that belongs in the log as 59+20.

Green to S9, amber through the S9+ range, red from +20 dB. The thresholds are
computed from the SAME S9 point the label uses rather than hard-coded
percentages: the S9 position is still a hypothesis on this rig, and when it is
corrected the colours have to move with it or the meter would say 59+20 in
amber.
2026-07-29 11:59:58 +02:00
rouggy 6d309cada1 fix: SPLIT placed the transmitter on a stale VFO; share the meter widget
Reported on the FTDX10: listening on 14.244 with VFO B still holding 18.115 from
an earlier session, pressing SPLIT threw the transmitter onto another band. The
button only flipped the rig's split flag, and the other VFO is stale by nature —
the only transmit frequency that makes sense is one derived from where the
operator is listening NOW. SPLIT therefore places the TX VFO too: up 1 kHz on CW
and the data modes, up 5 kHz on phone, the offsets operators actually call. The
+1k / +5k buttons remain for anything else, and a test pins the mapping.

The panel also drew its own flat meters while the Flex and Icom consoles use the
shared LED-segment MeterBar. Two instrument styles in one application is just
inconsistency — it now uses the shared component, and the local one is gone.

And the three consoles are named alike: "Flex Console", "Icom Console", "Yaesu
Console", in the tabs and in the Main-view pane list, in both languages.
2026-07-29 11:55:28 +02:00
rouggy 7153768579 feat: Yaesu panel — sideband on re-click, split readout, one-touch up 1/up 5
Three corrections from the operator's second pass.

The sideband gesture was wrong: I used double-click, which hides the action.
Clicking a button that is ALREADY active now flips its sideband — CW-U → CW-L →
CW-U. One button, one finger, nothing to discover.

A lit SPLIT chip does not tell an operator anything useful: it says split is on,
not where they transmit. The header now shows the TX frequency and the offset in
kHz whenever split is active.

And the offset that matters is set in one action: up 1 kHz on CW, up 5 kHz on
phone. Doing it by hand means swapping VFOs, retuning and swapping back — exactly
the fumbling a panel exists to remove. Both are offered rather than picked from
the mode, because which one is idiomatic is the operator's call, and the button
turns split on at the same time.

The offset is measured from the RECEIVE frequency and written to the VFO we are
not listening on, so it stays correct when the operator works on VFO B, where the
roles are mirrored.
2026-07-29 11:46:54 +02:00
rouggy df4155108f fix: centre the Yaesu panel like the Icom and Flex ones
Capping the width without mx-auto pinned the console to the left edge with an
empty window beside it. It now uses the exact wrapper the other two panels use —
h-full min-h-0 overflow-auto bg-background, then max-w-5xl mx-auto p-3 — rather
than a second layout of my own invention.
2026-07-29 11:42:42 +02:00
rouggy 9bc5a14c69 fix: Yaesu panel — S units, sidebands, three-step ATT, readable sliders, own tab
First look on the FTDX10 turned up six things:

The S meter printed a raw percentage — "57" tells an operator nothing, and it is
the S number that goes into a report. It now reads S1-S9/S9+dB, the same value
the click-to-fill RST already used.

CW, RTTY and the data modes exist on BOTH sidebands and the operator is the one
who knows which they want. The buttons now carry the rig's actual sideband and a
double-click flips it; PSK is added, riding the rig's DATA mode as it does on the
radio itself. This also means the mode row drives the rig directly (MD0 with the
exact mode) instead of going through the ADIF path, which could only pick a
sideband by convention.

The attenuator is a 6/12/18 dB pad on these rigs, not the single step I assumed —
two thirds of the control was unreachable.

Sliders had no visible filled side: --muted is barely lighter than the card it
sits on, so the whole track read as one bar. They also came in three kinds (two
bare range inputs among them). One component now, explicit track colour, and it
takes a min/max so power in watts and DNR 1-15 look like the rest.

The panel stretched across the whole window; it is a column of controls, so it is
now capped and every row stays readable.

And it gets its own Yaesu tab, like FlexRadio and Icom, rather than only being
available as a Main-view pane.
2026-07-29 11:37:18 +02:00
rouggy 842d4708a7 feat: Yaesu control panel (meters, bands, DSP, TX), and drop a stale Flex hint
A console pane for the native Yaesu backend, in the same shape as the Icom and
Flex ones: S/PO/SWR meters, band and mode rows, AF/RF/squelch, AGC, the IPO/AMP1/
AMP2 front-end selector, ATT, NB, DNR + level, narrow filter, power in watts, mic
gain, VOX, split and ATU tune.

Three decisions worth keeping:

Panel reads are STAGGERED and live in their own file, away from ReadState. Meters
poll every cycle; settings only change when someone turns a knob, so they refresh
every 8th cycle and right after any set. Polling all of it every cycle would put
twenty queries a second on the serial link the frequency display shares.

Band buttons use the rig's own band memory (BS) rather than a frequency we pick,
so 20 m lands where the operator last was on 20 m — what the radio's own band
keys do.

A set is followed by a read-back, so the panel shows what the RIG ended up with,
not what we asked for; the two differ whenever a value is out of range or the
mode forbids the control. And a control the model lacks keeps its previous value
instead of dropping to zero, which reads as a setting that reset itself.

The S9 point of the S-meter scale is a hypothesis (the manual does not state it)
and is commented as such — one number to correct if reports come out an S unit
off, rather than a fudge spread through the RST helper.

Also removes the FlexRadio settings blurb, which explained the backend to
someone who had already chosen it.
2026-07-29 11:17:08 +02:00
rouggy e2aba828a9 feat: native Xiegu CAT backend (G90 / X6100 / X6200 / X5105)
Xiegu speaks CI-V with a reduced command set: frames, BCD encoding, addressing
and the opcodes for frequency, mode, PTT and split are Icom's, so this reuses
internal/cat/civ wholesale instead of re-deriving a codec.

It is a SEPARATE backend rather than the Icom one at address 0x70, because what
the two rigs do NOT share is the deciding part. The Icom backend reads the
spectrum scope, the DSP block, data mode via 1A 06 and the model id via 19 —
none of which a Xiegu implements. Pointed at a G90 it would poll every cycle for
answers that never come, and spend its silence tolerance on commands the radio
was never going to support.

Two consequences of the rig's smaller mode table are handled explicitly rather
than left to fail: there is no data mode, so a digital QSO is set to plain
sideband (what the operator does on the radio anyway) instead of being refused;
and the split TX frequency is NOT reported, because reading the unselected VFO
needs 0x25, which the Xiegu table does not list — a split flag carrying a wrong
TX frequency is worse than the flag alone, since the frequency is what gets
logged.

The published command table has rows that slipped during typesetting (0x07 and
0x0F share a block). Where it contradicts itself the Icom meaning is used, the
rest of the table matching Icom exactly, and every unexpected reply is logged
raw so a first on-air run settles it.

NOT yet verified on a radio.
2026-07-29 11:06:27 +02:00
rouggy 753d8d2ffa fix: accept Hamlib's VFO-prefixed commands — JTDX could not set the frequency
MSHV worked immediately, JTDX answered "Hamlib error: Invalid parameter while
setting frequency". The two use different Hamlib dialects: MSHV sends
"F 14074000", JTDX names the target first — "F VFOA 14074000". The VFO name
landed in the slot the frequency was read from, the parse failed, and we returned
RPRT -1, which is exactly the error JTDX reported.

A leading VFO name is now stripped from every command's arguments. It costs
nothing: OpsLog follows the rig's own VFO selection, so the name carries no
information we act on — but refusing it locked out a whole family of clients.
Both dialects are covered by a test, the plain one included, since this is an
addition and must not become a swap.

A rejected frequency is also logged with the raw line now. The client only shows
"Invalid parameter", which says nothing about what it actually sent — that is
why this took a screenshot to diagnose rather than a log.
2026-07-29 10:58:16 +02:00
rouggy 38b480a985 feat: share the CAT link with other programs (Hamlib NET rigctl server)
A native CAT backend owns the rig's serial port, and Windows gives a COM port to
one process — so choosing native CAT locked WSJT-X, MSHV and JTDX out of the
radio entirely. That is the cost of dropping OmniRig, which was itself a sharing
layer, and it has to be paid back.

OpsLog now becomes the server, as wfview does. It speaks the Hamlib net rigctl
protocol, which every one of those programs supports natively (rig model "Hamlib
NET rigctl", 127.0.0.1:4532) with no driver to install. It sits in front of the
MANAGER, not a backend, so an operator on OmniRig, Flex, Icom or TCI gets the
same server.

Two details that decide whether a client works at all rather than degrading:
dump_state is parsed positionally and WSJT-X refuses to proceed without a
well-formed block, so it is written out in full and its shape is pinned by a
test; and set_vfo / set_split_vfo answer RPRT 0 rather than an error, because
OpsLog follows the rig's own VFO and a refusal makes WSJT-X abandon the
connection. Unknown commands answer RPRT -11 — never silence, which hangs a
client instead.

The whole protocol is tested against a fake rig, plus one end-to-end exchange
over a real socket, since the framing is as much the contract as the text.

Also: the Yaesu backend is confirmed working on a real FTDX10 (frequency, mode,
VFO, split), so its "not yet verified" note is now wrong and is corrected.
2026-07-29 10:49:14 +02:00
rouggy 67005a8d50 feat: native Yaesu CAT backend (FTDX10 / FTDX101), no OmniRig
Every Yaesu fault reported so far came from OmniRig's interpretation layer, not
from the radio: a rig file that never exposes the VFO, a Freq property meaning A
on one model and B on another, a split flag that alternates between polls. This
talks to the rig directly, so what the radio answers is what is shown.

Modern Yaesu CAT is plain ASCII with a ';' terminator — FA/FB for the VFOs, MD0
for the mode, VS for the selected VFO, TX to key. Frequency is written to the VFO
the operator is actually on, not always to A, which is the failure that made a
display disagree with the radio.

Two things are genuinely uncertain across the family and are treated as such
rather than guessed. SPLIT is read through ST, then FT if the rig ignores ST —
whichever answers wins and the choice is remembered, because the two commands say
DIFFERENT things (a split flag vs which VFO transmits). If neither answers, split
is reported OFF and the fact is logged, rather than invented. Unknown model ids
and mode bytes are logged raw for the same reason.

Split resolution, frequency parsing and the mode mapping are pure functions with
a table test — the OmniRig equivalent is where every Yaesu bug lived, and it had
no test until late.

Written from the CAT reference; NOT yet verified on a radio.
2026-07-29 10:33:26 +02:00
rouggy 9cfa7a4dd1 fix: band change from OpsLog left the frequency on the old band
The log settled it: the backend was right all along. Every set produced
"readback +1.5s FreqA=21140000 Freq=21140000 -> shown 21140000" and published
cat:state with the new frequency and band. The display did not follow.

The fault is in the frontend. Changing band does two things at once: it opens the
1.5 s freeze that protects what the operator is typing, and it commands the rig.
The rig's answer comes back in ~170 ms — inside that freeze — and the handler
DROPPED any snapshot arriving during it. The backend only emits on change, so
nothing came afterwards, and the strip kept the old frequency until the VFO was
nudged. Changing band from the radio always worked because no freeze was open.

A dropped snapshot is now kept and replayed when the freeze closes, rather than
discarded. Further typing simply defers the replay again.
2026-07-29 10:23:52 +02:00
rouggy a1c4305f20 feat: bulk-edit the contacted station's gridsquare
An import that drops the locator leaves it missing on a whole batch, and fixing
that one QSO at a time is exactly what bulk edit exists to avoid.

Added on all three sides in lockstep — the dialog field, the field-to-column map,
and the repository whitelist — because they are separate lists and offering a
field the repository refuses fails only at Apply, after the operator has selected
the QSOs. The existing contract test covers it.

The callsign and RST stay excluded, as before: bulk-setting those corrupts a log.
The locator does not carry that risk — it identifies a place, not a station, and
a wrong value is simply overwritten again.
2026-07-29 09:00:35 +02:00
rouggy 3b296b19ab test: prove the status filter filters; fix a regex escape in the filter builder
An operator reports that "QRZ.com received status = N" returns rows showing both
N and Y (issue #5 follow-up). The SQL is a plain col = ?, but reading it cannot
distinguish a wrong query from a UI that kept the previous rows after an error —
so this runs it: five QSOs inserted, filtered, and both the list and the count
asserted. The backend filters correctly, list and count agree. The fault is not
in the query.

Found while looking: the filter builder tested an ADIF date with /^d{8}$/ instead
of /^\d{8}$/. The escape was missing, so the branch never matched and the
calendar input was handed "20260728", which type=date rejects — an empty box
over a value that was really stored.
2026-07-29 08:19:18 +02:00
rouggy 8a0d76fa0c feat: IC-7300MKII, and two Icom addresses that were plain wrong
Adds the IC-7300MKII at CI-V 0xB6.

Doing so exposed a drift between the two hand-kept copies of the model table: the
settings offered the IC-7700 at 0x88 and the IC-7800 at 0x80, which are the
IC-7100's and the IC-7410's factory addresses. Picking either set an address the
rig never answers on — the symptom is a radio that simply stays silent — and the
backend then named it as the other model. Corrected to 0x74 and 0x6A, and the
four models the backend already knew (IC-7100, IC-7410, IC-7600, IC-7851) are now
offered too instead of forcing a manual address.

A test reads the model list out of the .tsx and asserts civ.ModelName agrees, so
the next model added on one side alone fails the build rather than someone's
radio.
2026-07-28 22:38:02 +02:00
rouggy bd5f9c0746 fix: Icom over LAN froze on the last frequency after WSJT-X released the rig
The network backend treated "control link alive but no CI-V reply" as the rig
being in standby, and tolerated it WITHOUT BOUND. When another program takes the
CI-V session — WSJT-X through OmniRig, or the Remote Utility — the rig goes on
answering pings on the control stream while sending us nothing at all. Alive()
stayed true, so ReadState returned the cached frequency with err == nil, the
Manager never saw a failure, never reconnected, and re-published a frozen number
with a fresh timestamp on every poll. Only restarting OpsLog cleared it.

Bounded now, on the last SUCCESSFUL read. Past the grace the error is reported so
the Manager tears the session down and reconnects, which re-takes the CI-V
stream. Also fatal immediately: the CI-V reader goroutine having exited — no read
can ever succeed after that, however healthy the control link looks.

The grace backs off to minutes when the silence persists, because the two cases
pull opposite ways: a stolen session recovers on the first attempt, while a rig
switched OFF is silent for hours and re-tearing its session every 30 s would
blink the panel — and its ON button — away continuously. A good read resets it.

The decision table is pinned by a test; the standby case (never answered since
connect) keeps the old tolerate-for-ever behaviour.
2026-07-28 22:24:32 +02:00
rouggy 29591c5f0c feat: the offline FCC database answers during entry, not only at save
ULS was wired into AddQSO alone, so an operator who downloaded it and has no
QRZ.com/HamQTH account saw nothing while typing a US call: cty.dat gives the
country, the zones, and a 4-character grid that is the ENTITY centroid — a
thousand kilometres from the station. The county and the real square were stamped
after logging, too late to point an antenna with.

The enrichment runs last in the lookup wrapper and only fills blanks, so a
provider always wins. ULS carries no name and no address, so it completes a QRZ
record and can never replace one.

The grid needed a rule of its own. refineGrid keeps what is there unless the new
value extends it — correct for a QRZ square, wrong against an entity centroid,
which is simply a different square. A per-callsign FCC square therefore beats any
4-character grid, while a 6-character one already present is left untouched.
Lat/lon are recomputed only when we actually moved the grid.

Portable calls are skipped: W1AW/4 is not what the FCC licensed.
2026-07-28 22:10:22 +02:00
rouggy 01bcf256e2 fix: hand-corrected CQ/ITU zones came back wrong at every restart
The profile's MY_* metadata is derived from the callsign through cty.dat, and the
effect that derives it ran on every load — so opening the settings counted as
"the source changed" and overwrote whatever the operator had typed. Reported by
an operator in CQ 4 / ITU 4 handed 5 and 9: he corrected them, and each restart
put 5 and 9 back.

cty.dat gives the zones of the ENTITY, and a large country spans several, so the
automatic value cannot be treated as authoritative — it is a starting point. A
load now fills only fields that are EMPTY; a recompute that overwrites happens
only when the callsign or grid itself changes, which is the case the derivation
exists for.

The load-vs-edit distinction is a ref holding the profile id the values were last
derived from — first sight of a profile is a load.
2026-07-28 20:53:10 +02:00
71 changed files with 17536 additions and 331 deletions
+2
View File
@@ -93,6 +93,8 @@ Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
+836 -77
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -1,4 +1,152 @@
[
{
"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",
"en": [
"Clicking a cluster spot while listening on the SUB VFO no longer pulls the radio back to MAIN. The command used to tune sets the main VFO by definition; on SUB the sub VFO is now written directly and the VFO selection is left alone.",
"Split showed the opposite of the radio on some Yaesus. Where the rig reports which VFO transmits rather than a split flag, that has to be compared with the VFO you are listening to — so it read backwards for an operator working on SUB and correctly for one on MAIN. Setting split had the same fault.",
"A shared MySQL logbook could fail to build its schema, leaving OpsLog on the local SQLite database with a message that only said the connection failed. Column declarations that are quoted, or share a line with others, escaped the SQLite-to-MySQL type conversion — so MySQL refused an index and a default value. A database already left in that state is now repaired at startup instead of failing at every launch.",
"Yaesu: OpsLog now follows the VFO you are actually on — the displayed frequency, and the VFO a cluster spot tunes. An FTDX101 answers the receive-VFO query with two digits where an FTDX10 uses one, and the wrong one was read.",
"Clicking a spot in DXHunter can now tune the radio: a remote-call UDP packet carrying <FREQ> and <MODE> sets the frequency and mode on whichever CAT backend is active, as well as filling the callsign. A packet without them still just fills the callsign.",
"The ADIF import option that fills your station fields from your profile also stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com) on the ones the file leaves empty — it always did, but nothing said so, and a WSJT-X log carries almost none. Existing confirmations are never overwritten.",
"QRZ.com confirmation download marked far too many QSOs as confirmed: it accepted the paper-QSL received flag, which you uploaded to QRZ yourself, as if it were a QRZ confirmation. Only QRZ own confirmation now counts — check the QRZ received column after your next download, as earlier ones may have set it wrongly.",
"The Recent QSOs counter labels each of its three numbers — shown, matching the filter, and in the log — with thousands separators. \"Showing 10000 of 23683 matches · 28648 total\" read as if the filter had found more QSOs than the log holds.",
"Switching the CAT backend back to a Yaesu no longer risks leaving the serial port held. Each reconnect opened a new handle without closing the previous one, and Windows opens a serial port exclusively, so the next attempt could fail with \"port busy\".",
"A Yaesu that is switched off is reported as such. Opening the serial port used to be logged as \"connected\", with the model name left over from the previous session, so a radio that was simply powered down looked like a software fault.",
"Yaesu: moving RX and TX together to the SUB receiver is no longer shown as split. That is simplex on the sub VFO, but the rig reports its split flag anyway, so OpsLog now works split out from which VFO transmits and which receives.",
"Auto-call CW now waits the gap you set AFTER the message, not from the moment it starts. With a WinKeyer or serial keyer the wait could end before the call had finished, so a 6 s gap gave about 2 s of silence after a 4 s CQ."
],
"fr": [
"Cliquer sur un spot du cluster en écoutant sur le VFO SUB ne ramène plus la radio sur le VFO principal. La commande utilisée pour s'accorder agit par définition sur le VFO principal ; sur SUB, c'est désormais le VFO secondaire qui est écrit, sans toucher à la sélection.",
"Le split affichait l'inverse de la radio sur certains Yaesu. Quand la radio indique quel VFO émet plutôt qu'un indicateur de split, il faut le comparer au VFO écouté — l'affichage était donc inversé pour un opérateur travaillant sur SUB et correct pour un autre sur MAIN. Le réglage du split souffrait du même défaut.",
"Un carnet MySQL partagé pouvait échouer à créer son schéma, laissant OpsLog sur la base SQLite locale avec un message indiquant seulement l'échec de la connexion. Les déclarations de colonnes entre guillemets, ou partageant une ligne avec d'autres, échappaient à la conversion de types SQLite vers MySQL — d'où le refus d'un index et d'une valeur par défaut. Une base déjà dans cet état est désormais réparée au démarrage au lieu d'échouer à chaque lancement.",
"Yaesu : OpsLog suit désormais le VFO réellement utilisé — la fréquence affichée, et le VFO qu'un spot du cluster accorde. Un FTDX101 répond à la question du VFO de réception avec deux chiffres là où un FTDX10 n'en donne qu'un, et c'est le mauvais qui était lu.",
"Cliquer un spot dans DXHunter peut désormais accorder la radio : un paquet UDP remote_call contenant <FREQ> et <MODE> règle la fréquence et le mode sur le backend CAT actif, en plus de renseigner l'indicatif. Un paquet sans ces balises se contente, comme avant, de renseigner l'indicatif.",
"L'option d'import ADIF qui remplit vos champs station depuis votre profil applique aussi vos statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com) sur ceux que le fichier laisse vides — c'était déjà le cas, mais rien ne le disait, et un log WSJT-X n'en contient pratiquement aucun. Les confirmations existantes ne sont jamais écrasées.",
"Le téléchargement des confirmations QRZ.com marquait beaucoup trop de QSO comme confirmés : il acceptait l'indicateur de QSL papier reçue — que vous avez vous-même envoyé à QRZ — comme une confirmation QRZ. Seule la confirmation propre à QRZ compte désormais ; vérifiez la colonne QRZ.com reçu après votre prochain téléchargement, les précédents ayant pu la remplir à tort.",
"Le compteur des QSO récents nomme chacun de ses trois nombres — affichés, correspondant au filtre, et présents dans le log — avec des séparateurs de milliers. « Showing 10000 of 23683 matches · 28648 total » se lisait comme si le filtre trouvait plus de QSO que le log n'en contient.",
"Repasser le backend CAT sur un Yaesu ne risque plus de laisser le port série occupé. Chaque reconnexion ouvrait une nouvelle poignée sans fermer la précédente, et Windows ouvre un port série en exclusivité : la tentative suivante pouvait échouer avec « port occupé ».",
"Un Yaesu éteint est signalé comme tel. L'ouverture du port série était journalisée comme « connecté », avec le nom de modèle hérité de la session précédente : une radio simplement hors tension ressemblait à un défaut du logiciel.",
"Yaesu : passer RX et TX ensemble sur le récepteur SUB n'est plus affiché comme un split. C'est du simplex sur le VFO secondaire, mais la radio lève quand même son indicateur de split ; OpsLog le déduit désormais du VFO qui émet et de celui qui reçoit.",
"L'appel automatique en CW attend désormais l'intervalle réglé APRÈS le message, et non depuis son début. Avec un WinKeyer ou un keyer série, l'attente pouvait se terminer avant la fin de l'appel : un intervalle de 6 s ne laissait qu'environ 2 s de silence après un CQ de 4 s."
]
},
{
"version": "0.22.0",
"date": "2026-07-28",
"en": [
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes.",
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
"Yaesu console: with the native Yaesu backend, a console tab and a Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — click an active button again to switch it. Pressing SPLIT places the transmit VFO up 1 kHz on CW and data, up 5 kHz on phone, and shows the transmit frequency.",
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
"The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).",
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer. For the FTDX101 / FT-991A / FT-710 family: an FTDX10 does not accept it (DAKY included), and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
"Voice keyer: a level control for the messages sent to the radio (Settings → Audio). They went out exactly as recorded, so a quietly recorded microphone drove the rig quietly with nothing in OpsLog to fix it. Play previews at the same level, and the hint names the rig setting to check when it transmits almost nothing (on an FTDX10, SSB MOD SOURCE = REAR).",
"The voice keyer PTT list now names the Yaesu and Xiegu backends too — with a native backend selected it showed no CAT name, which read as \"CAT PTT is not available for my radio\".",
"WinKeyer 2: keying is fixed. The dit/dah ratio was sent on the command that sets KEY COMPENSATION, so every element got 50 ms of extra key-down — at 25 wpm a dit is 48 ms — and the keyer sent one element then stalled. The compensation is also cleared, since a keyer already set that way keeps it.",
"ADIF import accepts a file with no header. Records pasted straight out of another log — a few lines lifted from wsjtx_log.adi into a new .adi — imported as \"0 imported, 0 ignored, 0 total\", which reads as an empty file rather than a missing tag."
],
"fr": [
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change.",
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
"Console Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — recliquez sur le bouton actif pour en changer. Appuyer sur SPLIT place le VFO d'émission à +1 kHz en CW et numérique, +5 kHz en phonie, et affiche la fréquence d'émission.",
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
"La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).",
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) ». Pour la famille FTDX101 / FT-991A / FT-710 : un FTDX10 ne l'accepte pas (DAKY compris), et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots dun CQ, en CW comme en phonie.",
"Voice keyer : un réglage de niveau pour les messages envoyés à la radio (Réglages → Audio). Ils partaient exactement tels qu'enregistrés : un micro capté faiblement attaquait donc la radio faiblement, sans rien dans OpsLog pour y remédier. Le bouton Lire fait entendre ce même niveau, et l'aide indique le réglage radio à vérifier quand elle n'émet presque rien (sur un FTDX10, SSB MOD SOURCE = REAR).",
"La liste PTT du voice keyer nomme aussi les backends Yaesu et Xiegu — avec un backend natif sélectionné, aucun nom de CAT n'apparaissait, ce qui se lisait « il n'y a pas de PTT CAT pour ma radio ».",
"WinKeyer 2 : la manipulation est réparée. Le rapport point/trait était envoyé sur la commande qui règle la COMPENSATION DE MANIPULATION : chaque élément recevait donc 50 ms de fermeture supplémentaire — à 25 mots/minute un point dure 48 ms — et le keyer envoyait un élément puis se bloquait. La compensation est aussi remise à zéro, car un keyer déjà réglé ainsi la conserve.",
"L'import ADIF accepte un fichier sans en-tête. Des enregistrements collés depuis un autre log — quelques lignes reprises de wsjtx_log.adi dans un nouveau .adi — donnaient « 0 importé, 0 ignoré, 0 au total », ce qui se lit comme un fichier vide plutôt que comme une balise manquante."
]
},
{
"version": "0.21.8",
"date": "2026-07-28",
+358 -50
View File
@@ -38,12 +38,13 @@ import {
ListCountries,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
QSOAudioManualReady, QSOAudioManualStart, QSOAudioStop, QSOAudioResume, QSOAudioPlayOnAir,
GetAwardDefs,
GetUIPref, GetActiveProfile, ListProfiles, ActivateProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
@@ -73,6 +74,7 @@ import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { YaesuPanel } from '@/components/YaesuPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
@@ -98,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';
@@ -167,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 '';
@@ -391,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 ===
@@ -502,6 +503,9 @@ export default function App() {
// CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
// Live copy for callbacks that must not close over a stale snapshot.
const catStateRef = useRef(catState);
useEffect(() => { catStateRef.current = catState; }, [catState]);
// Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
// is "icom" for BOTH, so we track the settings value to tell them apart (used to
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
@@ -594,6 +598,10 @@ export default function App() {
// window after manual edits and skip CAT updates during it.
const catFreezeUntilRef = useRef<number>(0);
function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; }
// The last CAT snapshot that arrived while the freeze was open, replayed when
// it closes — see applyCatState.
const pendingCatRef = useRef<CATState | null>(null);
const pendingCatTimerRef = useRef<number | undefined>(undefined);
// Suggested QSY frequency (Hz) for a given band + mode. Common phone /
// CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq.
@@ -758,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.
@@ -960,7 +1003,26 @@ export default function App() {
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : 'winkeyer';
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
// keying the PC does the timing, so the rig's internal keyer speed changes
// nothing audible — the Yaesu console's slider looked broken because it drove
// that one alone. It now drives both: the rig's keyer for its own front panel,
// and the engine doing the work.
const setCWSpeedEverywhere = useCallback((w: number) => {
setWkWpm(w); saveWk({ wpm: w });
const src = cwSourceRef.current;
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
// not the sending engine: its front panel and OpsLog then agree.
if (src !== 'yaesu' && catStateRef.current?.backend === 'yaesu') {
SetYaesuKeySpeed(w).catch(() => {});
}
}, []);
const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
@@ -996,6 +1058,7 @@ export default function App() {
useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
: wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
@@ -1315,12 +1378,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol';
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'yaesu' | 'netcontrol';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol';
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'yaesu' || v === 'netcontrol';
const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -1490,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
@@ -2009,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;
@@ -2122,7 +2212,26 @@ export default function App() {
function applyCatState(s: CATState) {
setCatState(s);
if (!s?.connected) return;
if (Date.now() < catFreezeUntilRef.current) return;
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
// only one that mattered. Changing band from OpsLog does two things at once:
// it opens the 1.5 s freeze (noteManualEdit) and it commands the rig. The
// rig's answer comes back in ~170 ms — inside the freeze — so it was thrown
// away, and since the backend only emits on CHANGE, nothing followed: the
// frequency stayed on the old band until the operator nudged the VFO. From
// the radio it always worked, because no freeze was open. Confirmed on an
// FTDX10 (2026-07-29): the log showed the backend publishing the right
// frequency every time while the display did not move.
const now = Date.now();
if (now < catFreezeUntilRef.current) {
pendingCatRef.current = s;
if (pendingCatTimerRef.current) window.clearTimeout(pendingCatTimerRef.current);
pendingCatTimerRef.current = window.setTimeout(() => {
const p = pendingCatRef.current;
pendingCatRef.current = null;
if (p) applyCatState(p); // re-checks the freeze, so more typing just defers it again
}, catFreezeUntilRef.current - now + 50);
return;
}
const lk = locksRef.current;
if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
@@ -2159,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)
}
}
}
@@ -2350,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) });
});
@@ -2376,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
}, []);
@@ -2396,7 +2514,16 @@ export default function App() {
setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type);
setWkEsm(!!s.esm);
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
// Every engine has to be named here. Anything unlisted silently became
// "winkeyer", so choosing the Yaesu keyer in the settings still gave a
// WinKeyer panel asking for a COM port — the setting was saved correctly,
// it just never survived being read back.
setWkEngine(
s.engine === 'icom' ? 'icom'
: s.engine === 'flex' ? 'flex'
: s.engine === 'yaesu' ? 'yaesu'
: s.engine === 'serial' ? 'serial'
: 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
@@ -2522,7 +2649,7 @@ export default function App() {
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
// variables correctly.
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu';
for (let p = 0; p < parts.length; p++) {
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
const resolved = parts[p];
@@ -2534,7 +2661,7 @@ export default function App() {
// current WPM, so it scales automatically.
const keyed = resolved + ' ';
setWkSent(resolved);
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null;
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
@@ -2546,11 +2673,21 @@ export default function App() {
if (isRig) {
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
} else {
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
// over a remote/serial-over-IP link that echo can lag tens of seconds.
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
// send, so the estimate is a floor and the busy signal only extends it.
//
// Waiting on busy alone made the auto-call gap start early: busy travels
// from the keyer to this window as an event, and if it had not arrived
// and cleared within the one-second start window the wait simply ended.
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
// after a 4 s call, which is what an operator hears as "clearly not 6".
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
const started = Date.now();
const capMs = Math.round(estMs * 1.4) + 2500;
const deadline = started + capMs;
// The message's own duration, first — this is the part that must not be skipped.
while (Date.now() - started < estMs && !aborted()) await sleep(50);
// Then let a still-sending keyer finish (slow link, long buffer), capped.
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
}
}
@@ -2567,6 +2704,7 @@ export default function App() {
wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
@@ -2863,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;
@@ -3298,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) {
@@ -3471,6 +3615,7 @@ export default function App() {
stopAutoCall();
wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
@@ -3568,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
@@ -3587,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}
@@ -4256,6 +4461,12 @@ export default function App() {
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
case 'yaesu':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
</div>
);
case 'icom':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
@@ -5180,8 +5391,16 @@ export default function App() {
{wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel
status={cwSource === 'icom' || cwSource === 'flex'
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
// A rig keyer has no serial status of its own: it is connected
// exactly when its CAT backend is. Yaesu was missing from this
// list, so the panel fell back to the WinKeyer status — which
// reported disconnected, no WinKeyer being attached.
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
? {
connected: catState.backend === cwSource && catState.connected,
busy: false, wpm: wkWpm, version: 0,
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
}
: wkStatus}
ports={wkPorts}
port={wkPort}
@@ -5195,12 +5414,7 @@ export default function App() {
onRefreshPorts={reloadWkPorts}
onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
}}
onSetSpeed={setCWSpeedEverywhere}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); stopKeyerTx(); }}
@@ -5341,8 +5555,9 @@ export default function App() {
</span>
</TabsTrigger>
)}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
{statsTabOpen && (
<TabsTrigger value="stats" className="gap-1.5">
{t('stats.tab')}
@@ -5492,16 +5707,31 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button>
) : null}
{/* Three different numbers, so each one is LABELLED. "Showing
10000 of 23683 matches · 28648 total" was read as a filter
matching more than the log holds: the middle number belonged
to "matches" but sat where "of N" normally means the total.
Thousands separators for the same reason 23683 and 28648
are hard to compare at a glance. */}
{gridFilteredCount != null ? (
<span>
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
Showing <span className="font-semibold text-foreground">{gridFilteredCount.toLocaleString()}</span> of{' '}
<span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> (column filter)
</span>
) : (
<span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
{(activeFilter.conditions?.length || filterCallsign) ? (
<>
Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span>
{' · '}<span className="font-semibold text-foreground">{(matchCount ?? 0).toLocaleString()}</span> match the filter
{' · '}<span className="font-semibold text-foreground">{total.toLocaleString()}</span> in the log
</>
) : (
<>
Showing <span className="font-semibold text-foreground">{qsos.length.toLocaleString()}</span> of{' '}
<span className="font-semibold text-foreground">{total.toLocaleString()}</span>
</>
)}
</span>
)}
</div>
@@ -5758,6 +5988,13 @@ export default function App() {
{/* Icom CI-V receive-DSP control panel only when the CAT backend
is an Icom. */}
{/* Yaesu CAT control panel — only when the CAT backend is a Yaesu. */}
{catState.backend === 'yaesu' && (
<TabsContent value="yaesu" className="flex-1 min-h-0 p-0">
<YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} onKeySpeed={setCWSpeedEverywhere} />
</TabsContent>
)}
{catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
@@ -6047,10 +6284,11 @@ 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'}
yaesuAvailable={catState.backend === 'yaesu'}
/>
)}
@@ -6222,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>
@@ -6231,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>
@@ -82,6 +82,7 @@ const FIELDS: FieldDef[] = [
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (location / activation refs / SIG)
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
+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);
});
}
+5 -1
View File
@@ -278,7 +278,11 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
className="h-8 flex-1 text-xs"
disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={fieldType === 'adifdate' && /^d{8}$/.test(c.value)
// \d, not d: the escape was missing, so the test never matched
// an 8-digit ADIF date and the calendar input was handed
// "20260728" — which type=date rejects, showing an empty box
// over a value that was really there.
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
: c.value}
onChange={(e) => setCond(i, {
+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]);
+317 -30
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';
@@ -156,6 +160,7 @@ interface Props {
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
}
// Pretty little card showing what OpsLog will stamp on each QSO based on
@@ -197,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).
@@ -207,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' },
@@ -324,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" />
)}
@@ -817,7 +851,7 @@ function RelayAutoPanel() {
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
const { t } = useI18n();
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
@@ -826,10 +860,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
...MAIN_PANE_VALUES,
...(flexAvailable ? ['flex'] : []),
...(icomAvailable ? ['icom'] : []),
...(yaesuAvailable ? ['yaesu'] : []),
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => {
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
}, []);
@@ -1006,17 +1041,25 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
// The IC-7700 and IC-7800 were listed at 0x88 and 0x80 — which are the IC-7100's
// and the IC-7410's. Picking one of them set an address the rig never answers on,
// and the backend then named it as the other model.
const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-705', addr: 0xA4 },
{ name: 'IC-7100', addr: 0x88 },
{ name: 'IC-7300', addr: 0x94 },
{ name: 'IC-7300MKII', addr: 0xB6 },
{ name: 'IC-7410', addr: 0x80 },
{ name: 'IC-7600', addr: 0x7A },
{ name: 'IC-7610', addr: 0x98 },
{ name: 'IC-7700', addr: 0x88 },
{ name: 'IC-7800', addr: 0x80 },
{ name: 'IC-7700', addr: 0x74 },
{ name: 'IC-7800', addr: 0x6A },
{ name: 'IC-7851', addr: 0x8E },
{ name: 'IC-9100', addr: 0x7C },
{ name: 'IC-9700', addr: 0xA2 },
];
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable }: Props) {
const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
@@ -1053,9 +1096,10 @@ 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, 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',
digital_default: 'FT8', share_enabled: false, share_port: 4532,
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
@@ -1095,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) ──
@@ -1103,13 +1158,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number;
from_gain: number; mic_gain: number; tx_gain: number;
};
type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100,
from_gain: 100, mic_gain: 100, tx_gain: 100,
});
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -1234,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: '',
@@ -1242,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);
@@ -1483,27 +1538,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
return () => { off(); };
}, []);
// Which (profile, callsign, grid) the auto-fill below last derived from.
// Without it, simply OPENING the settings counted as "the source changed" and
// overwrote the operator's own values.
const derivedFrom = useRef<string>('');
// Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
// are derived values, so they always recompute when the callsign or grid
// changes — the user can still edit a field, it just re-populates when the
// source changes. Debounced so we don't hammer cty.dat while typing.
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon).
//
// These are derived values, so they recompute when the callsign or grid
// changes. What they must NOT do is recompute on a plain load: cty.dat gives
// the zones of the ENTITY, and a large country spans several — an operator in
// CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
// restart put the wrong pair back. cty.dat is a starting point, not an
// authority, so on a load we only fill fields that are EMPTY; a value the
// operator typed stays until the callsign or grid itself changes.
// Debounced so we don't hammer cty.dat while typing.
useEffect(() => {
const call = (activeProfile?.callsign ?? '').trim();
if (!call) return;
const grid = (activeProfile?.my_grid ?? '').trim();
const source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
// First sight of this profile — a load, not an edit.
const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
const t = window.setTimeout(async () => {
try {
const i: any = await ComputeStationInfo(call, grid);
derivedFrom.current = source;
setActiveProfile((p) => {
if (!p) return p;
const patch: any = {};
if (i.country) patch.my_country = i.country;
if (i.dxcc) patch.my_dxcc = i.dxcc;
if (i.cqz) patch.my_cqz = i.cqz;
if (i.ituz) patch.my_ituz = i.ituz;
if (i.lat) patch.my_lat = i.lat;
if (i.lon) patch.my_lon = i.lon;
// On a load, keep in patch only what the profile does not already have.
const put = (k: string, v: any) => {
if (!v) return;
const cur = (p as any)[k];
if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
patch[k] = v;
};
put('my_country', i.country);
put('my_dxcc', i.dxcc);
put('my_cqz', i.cqz);
put('my_ituz', i.ituz);
put('my_lat', i.lat);
put('my_lon', i.lon);
// Only re-render when a value actually changed (prevents loops).
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
return changed ? { ...p, ...patch } : p;
@@ -2316,6 +2393,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
<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>
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
@@ -2383,6 +2463,119 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</>
)}
{catCfg.backend === 'xiegu' && (
<>
<div className="space-y-1">
<Label>{t('cat.xieguPort')}</Label>
<div className="flex gap-2">
<Select value={catCfg.xiegu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_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>
</div>
<div className="space-y-1">
<Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.xiegu_baud || 19200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_baud: parseInt(v) || 19200 }))}>
<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.xieguBaudHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.civAddr')}</Label>
<Input value={'0x' + (catCfg.xiegu_addr || 0x70).toString(16).toUpperCase().padStart(2, '0')}
onChange={(e) => {
const n = parseInt(e.target.value.replace(/^0x/i, ''), 16);
if (!isNaN(n) && n > 0 && n <= 0xFF) setCatCfg((s) => ({ ...s, xiegu_addr: n }));
}} />
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
</div>
</>
)}
{catCfg.backend === 'yaesu' && (
<>
<div className="space-y-1">
<Label>{t('cat.yaesuPort')}</Label>
<div className="flex gap-2">
<Select value={catCfg.yaesu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_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.yaesuPortHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.yaesu_baud || 38400)} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_baud: parseInt(v) || 38400 }))}>
<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.yaesuBaudHint')}</span>
</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">
@@ -2529,6 +2722,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
</div>
</div>
{/* CAT sharing. A native backend owns the rig's serial port, so without
this WSJT-X and friends are locked out of the radio entirely. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!catCfg.share_enabled}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, share_enabled: !!c }))}
/>
{t('cat.share')}
</label>
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
{catCfg.share_enabled && (
<div className="space-y-1 max-w-[200px]">
<Label>{t('cat.sharePort')}</Label>
<PortInput
value={catCfg.share_port || 4532}
fallback={4532}
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
/>
</div>
)}
</div>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2543,11 +2758,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</p>
</>
)}
{catCfg.backend === 'flex' && (
<p className="text-xs text-muted-foreground">
{t('cat.flexHint')}
</p>
)}
</div>
</>
);
@@ -3051,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>
@@ -3098,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>
) : (
@@ -3191,6 +3410,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
</SelectContent>
@@ -3221,6 +3441,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</>
) : wk.engine === 'yaesu' ? (
<>
{(!catCfg.enabled || catCfg.backend !== 'yaesu') && (
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden></span>
<span>{t('wk.catWarnYaesu', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
</p>
)}
<p className="text-xs text-muted-foreground -mt-1">{t('wk.yaesuHint')}</p>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>{t('wk.speed')}</Label>
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
</>
) : wk.engine === 'flex' ? (
<>
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
@@ -3989,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) => (
@@ -4714,6 +4965,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
icom: t('cat.optIcom'),
'icom-net': t('cat.optIcomNet'),
tci: t('cat.optTci'),
yaesu: t('cat.optYaesu'),
xiegu: t('cat.optXiegu'),
} as Record<string, string>)[catCfg.backend] ?? '';
const deviceSelect = (
@@ -4887,6 +5140,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
</div>
{/* Playback level. Nothing else could raise it: the message went to the
rig exactly as recorded, so a quietly recorded mic drove the radio
quietly and the only remedies were the rig's own USB input menu or
the Windows mixer. */}
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
<Label className="text-sm">{t('aud.txLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={400} step={5} value={audioCfg.tx_gain}
onChange={(e) => setAudioField({ tx_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.tx_gain}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('aud.txLevelHint')}</p>
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
<div className="space-y-1.5">
{dvkMsgs.map((m) => {
@@ -4958,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">
@@ -5012,7 +5299,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
<TelemetryToggle />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} />
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
+6 -4
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void;
@@ -101,16 +101,18 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' || source === 'flex' ? (
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
: source === 'yaesu'
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
) : !connected ? (
<>
+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);
+501
View File
@@ -0,0 +1,501 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
import {
GetYaesuState, RefreshYaesuPanel,
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { MeterBar } from '@/components/MeterBar';
type YaesuState = {
available: boolean; model?: string; mode?: string; raw_mode?: string;
transmitting: boolean; split: boolean;
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
agc?: string; preamp: number; att: number;
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
};
const ZERO: YaesuState = {
available: false, transmitting: false, split: false,
s_meter: 0, power_meter: 0, swr_meter: 0,
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
};
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
// the radio's own band keys do. That is why these are band names, not Hz.
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
// the operator is the one who knows which they want, so each shows its sideband
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
// the band plan dictates, and AM/FM have none.
//
// PSK rides on the rig's DATA mode, like the other digital modes — the button
// exists because the operator thinks in modes, not in what the radio calls them.
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
const MODES: ModeBtn[] = [
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
];
// Which button the rig's current raw mode belongs to, and on which sideband.
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
switch ((raw || '').toUpperCase()) {
case 'USB': return { id: 'SSB', side: 'U' };
case 'LSB': return { id: 'SSB', side: 'L' };
case 'CW-U': return { id: 'CW', side: 'U' };
case 'CW-L': return { id: 'CW', side: 'L' };
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
case 'DATA-U': return { id: 'DIGI', side: 'U' };
case 'DATA-L': return { id: 'DIGI', side: 'L' };
case 'AM': return { id: 'AM', side: 'U' };
case 'FM': return { id: 'FM', side: 'U' };
}
return null;
}
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
function fmtVFO(hz?: number): string {
if (!hz || hz <= 0) return '––.–––.––';
const mhz = Math.floor(hz / 1_000_000);
const khz = Math.floor((hz % 1_000_000) / 1000);
const h2 = Math.floor((hz % 1000) / 10);
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
}
function bandOfHz(hz?: number): string {
if (!hz || hz <= 0) return '';
const mhz = hz / 1_000_000;
const bands: [string, number, number][] = [
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
];
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
return '';
}
// Split the 0-100 S-meter reading into S units + dB over S9.
//
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
// falls; the front panel puts it at roughly half travel, which is the 50 used
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
// unit off on a radio, this is the number to correct, not the RST helper.
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
function sParts(v: number): { s: number; over: number; label: string } {
if (v >= S9_PCT) {
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
}
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
return { s, over: 0, label: `S${s}` };
}
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
// through the S9+ range, red once the signal is strong enough to be reported as
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
// change always lands exactly where the numbers say it should — if the S9 point
// is ever corrected, the colours follow on their own.
const RED_OVER_DB = 20;
function sSegColor(frac: number): string {
const pct = frac * 100;
if (pct < S9_PCT) return '#16a34a';
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
return '#dc2626';
}
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
}) {
const v = Math.max(min, Math.min(max, value));
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
const ref = useRef<HTMLInputElement>(null);
// React's onWheel is passive, so preventDefault is ignored there — attach a
// native non-passive listener, and read live values through refs so the
// handler never closes over a stale value.
const valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled;
const minRef = useRef(min); minRef.current = min;
const maxRef = useRef(max); maxRef.current = max;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (disRef.current) return;
e.preventDefault();
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
if (nv !== valRef.current) cbRef.current(nv);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<input
ref={ref}
type="range" min={min} max={max} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
// The filled side was invisible against the dark theme: --muted is barely
// lighter than the card it sits on, so the whole track read as one bar.
// An explicit translucent track keeps both halves distinct in either theme.
style={{
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
borderColor: accent,
}}
/>
);
}
function Segmented({ value, options, onChange }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
return (
<button type="button" onClick={onClick} title={title}
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
{children}
</div>
);
}
export function YaesuPanel({ onReportRST, onKeySpeed }: {
onReportRST?: (rst: string) => void;
// Told whenever the operator moves the CW speed here, so the app can keep the
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
// the timing and the rig's internal keyer speed changes nothing audible — the
// slider looked broken because it was driving the wrong keyer.
onKeySpeed?: (wpm: number) => void;
}) {
const { t } = useI18n();
const [st, setSt] = useState<YaesuState>(ZERO);
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
// main display showed the operator the frequency they transmit on and an
// offset of 0 kHz against itself.
const [freqHz, setFreqHz] = useState(0);
const [txHz, setTxHz] = useState(0);
const [err, setErr] = useState('');
// Optimistic local values for the sliders. Without them a drag fights the
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
const [local, setLocal] = useState<Partial<YaesuState>>({});
const localAtRef = useRef(0);
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const s = (await GetYaesuState()) as YaesuState;
const c = await GetCATState();
if (!alive) return;
setSt(s);
const cs = c as any;
const tx = cs?.freq_hz ?? 0;
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
setFreqHz(rx);
setTxHz(tx);
// Drop the optimistic overlay once the rig has had time to answer with
// the new value — 1.2 s covers the slow-beat settings read.
if (Date.now() - localAtRef.current > 1200) setLocal({});
setErr('');
} catch (e: any) {
if (alive) setErr(String(e?.message ?? e));
}
};
tick();
const id = window.setInterval(tick, 400);
return () => { alive = false; window.clearInterval(id); };
}, []);
const view = { ...st, ...local };
// Every setter follows the same shape: show the value at once, remember when,
// and let the poll take over. A rejected command surfaces as an error rather
// than as a control that silently springs back.
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
setLocal((l) => ({ ...l, [key]: value }));
localAtRef.current = Date.now();
fn().catch((e) => setErr(String(e?.message ?? e)));
}
const band = bandOfHz(freqHz);
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
if (!st.available) {
return (
<div className="h-full w-full flex items-center justify-center p-6 text-center">
<div className="space-y-1">
<Radio className="size-8 mx-auto text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
{err && <p className="text-xs text-destructive">{err}</p>}
</div>
</div>
);
}
return (
<div className="h-full min-h-0 overflow-auto bg-background">
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
it without mx-auto left the console pinned to the left edge with a
window of empty space beside it. */}
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* VFO + status */}
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
{view.split && txHz > 0 && (
<div className="text-[11px] font-mono tabular-nums text-warning">
{t('yaesu.txOn')} {fmtVFO(txHz)}
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
</div>
)}
</div>
<div className="flex items-center gap-2">
{st.transmitting && (
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
)}
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
{/* The usual pile-up offsets. Which one is idiomatic depends on the
mode — up 5 on phone, up 1 on CW — so both are offered rather than
guessed, and each turns split on in the same action. */}
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.splitUpHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.splitUpHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.tuneHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
TUNE
</button>
</div>
</div>
{err && <p className="text-xs text-destructive px-1">{err}</p>}
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
consoles read alike instead of each having its own instrument style. */}
<Card icon={Activity} title={t('yaesu.meters')}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
display={sParts(view.s_meter).label}
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
title={onReportRST ? t('yaesu.sToRst') : undefined} />
{/* Watts as MEASURED, not the power setting scaled by a percentage:
the setting says what was asked for, the meter says what left. */}
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
{/* The RATIO, as the rig shows it — a percentage of meter travel is
not something an operator can act on. The bar keeps the travel. */}
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
</div>
</Card>
{/* Bands + modes */}
<Card icon={Antenna} title={t('yaesu.bandMode')}>
<div className="flex flex-wrap gap-1">
{BANDS.map((b) => (
<button key={b} type="button"
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{b.replace('m', '')}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{MODES.map((m) => {
const act = activeMode(view.raw_mode);
const on = act?.id === m.id;
// The sideband shown is the rig's when this mode is active, else the
// one the band plan implies — so a button says what pressing it will
// actually do rather than a stale letter.
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
return (
<button key={m.id} type="button"
onClick={() => {
// Already on this mode → the click means "the other sideband".
const target = m.id === 'SSB'
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
: m.rig(on && m.sideband ? flip : side);
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
}}
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{m.sideband ? m.label + '-' + side : m.label}
</button>
);
})}
</div>
</Card>
{/* Receive */}
<Card icon={AudioLines} title={t('yaesu.receive')}>
<Row label="AF">
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
</Row>
<Row label="RF">
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
</Row>
<Row label="SQL">
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
</Row>
<Row label="AGC">
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
</Row>
<Row label="FRONT">
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
</Row>
<Row label="ATT">
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
</Row>
</Card>
{/* Noise + filter */}
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
<div className="flex items-center gap-2 flex-wrap">
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
</div>
<Row label="DNR">
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
the radio's own display counts 1-15, and matching it is what makes
the panel readable next to the front panel. */}
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
</Row>
</Card>
{/* Transmit */}
<Card icon={Mic} title={t('yaesu.transmit')}>
<Row label="PWR">
{/* Watts, not a percentage: the rig reports and takes watts, and a
percentage would be a second unit to reconcile every time. */}
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
</Row>
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
— so they are hidden rather than shown as dead controls. */}
{!isCW && (
<>
<Row label="MIC">
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
</Row>
<Row label="VOX">
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
</Row>
</>
)}
<Row label="">
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
{t('yaesu.refresh')}
</button>
</Row>
</Card>
{/* CW — only in CW, where these replace the phone controls above. */}
{isCW && (
<Card icon={Radio} title={t('yaesu.cw')}>
<Row label="SPEED">
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
</Row>
<div className="flex items-center gap-2 flex-wrap">
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
label="BK-IN" title={t('yaesu.breakInHint')} />
{/* ZIN is a one-shot: the rig retunes so the station being received
lands on the operator's own CW pitch. Not a toggle, so it is a
plain button rather than a chip that would look latched. */}
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.zinHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
ZIN
</button>
</div>
</Card>
)}
</div>
</div>
);
}
+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;
}
File diff suppressed because one or more lines are too long
+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.21.8';
export const APP_VERSION = '0.22.3';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+69 -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>;
@@ -499,6 +501,8 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomRefresh():Promise<void>;
@@ -587,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>;
@@ -635,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>;
@@ -743,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>;
@@ -755,6 +771,8 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>;
export function RefreshYaesuPanel():Promise<void>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
@@ -887,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>;
@@ -911,6 +931,44 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>;
export function SetYaesuAtt(arg1:number):Promise<void>;
export function SetYaesuBand(arg1:string):Promise<void>;
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
export function SetYaesuMicGain(arg1:number):Promise<void>;
export function SetYaesuModeRaw(arg1:string):Promise<void>;
export function SetYaesuNB(arg1:boolean):Promise<void>;
export function SetYaesuNR(arg1:boolean):Promise<void>;
export function SetYaesuNRLevel(arg1:number):Promise<void>;
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
export function SetYaesuPower(arg1:number):Promise<void>;
export function SetYaesuPreamp(arg1:number):Promise<void>;
export function SetYaesuRFGain(arg1:number):Promise<void>;
export function SetYaesuSplit(arg1:boolean):Promise<void>;
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
export function SetYaesuSquelch(arg1:number):Promise<void>;
export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
@@ -947,6 +1005,8 @@ export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationT
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function TuneYaesuATU():Promise<void>;
export function TunerGeniusActivate(arg1:number):Promise<void>;
export function TunerGeniusAutotune():Promise<void>;
@@ -989,4 +1049,12 @@ 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>;
export function YaesuStopCW():Promise<void>;
export function YaesuZeroIn():Promise<void>;
+138 -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']();
}
@@ -946,6 +950,10 @@ export function GetWinkeyerStatus() {
return window['go']['main']['App']['GetWinkeyerStatus']();
}
export function GetYaesuState() {
return window['go']['main']['App']['GetYaesuState']();
}
export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
@@ -1122,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) {
@@ -1218,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);
}
@@ -1434,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']();
}
@@ -1442,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']();
}
@@ -1458,6 +1490,10 @@ export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar']();
}
export function RefreshYaesuPanel() {
return window['go']['main']['App']['RefreshYaesuPanel']();
}
export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
@@ -1722,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);
}
@@ -1770,6 +1810,82 @@ export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
}
export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
}
export function SetYaesuAGC(arg1) {
return window['go']['main']['App']['SetYaesuAGC'](arg1);
}
export function SetYaesuAtt(arg1) {
return window['go']['main']['App']['SetYaesuAtt'](arg1);
}
export function SetYaesuBand(arg1) {
return window['go']['main']['App']['SetYaesuBand'](arg1);
}
export function SetYaesuBreakIn(arg1) {
return window['go']['main']['App']['SetYaesuBreakIn'](arg1);
}
export function SetYaesuKeySpeed(arg1) {
return window['go']['main']['App']['SetYaesuKeySpeed'](arg1);
}
export function SetYaesuMicGain(arg1) {
return window['go']['main']['App']['SetYaesuMicGain'](arg1);
}
export function SetYaesuModeRaw(arg1) {
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
}
export function SetYaesuNB(arg1) {
return window['go']['main']['App']['SetYaesuNB'](arg1);
}
export function SetYaesuNR(arg1) {
return window['go']['main']['App']['SetYaesuNR'](arg1);
}
export function SetYaesuNRLevel(arg1) {
return window['go']['main']['App']['SetYaesuNRLevel'](arg1);
}
export function SetYaesuNarrow(arg1) {
return window['go']['main']['App']['SetYaesuNarrow'](arg1);
}
export function SetYaesuPower(arg1) {
return window['go']['main']['App']['SetYaesuPower'](arg1);
}
export function SetYaesuPreamp(arg1) {
return window['go']['main']['App']['SetYaesuPreamp'](arg1);
}
export function SetYaesuRFGain(arg1) {
return window['go']['main']['App']['SetYaesuRFGain'](arg1);
}
export function SetYaesuSplit(arg1) {
return window['go']['main']['App']['SetYaesuSplit'](arg1);
}
export function SetYaesuSplitOffset(arg1) {
return window['go']['main']['App']['SetYaesuSplitOffset'](arg1);
}
export function SetYaesuSquelch(arg1) {
return window['go']['main']['App']['SetYaesuSquelch'](arg1);
}
export function SetYaesuVOX(arg1) {
return window['go']['main']['App']['SetYaesuVOX'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
@@ -1842,6 +1958,10 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
export function TuneYaesuATU() {
return window['go']['main']['App']['TuneYaesuATU']();
}
export function TunerGeniusActivate(arg1) {
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
}
@@ -1926,6 +2046,22 @@ 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);
}
export function YaesuSendCW(arg1) {
return window['go']['main']['App']['YaesuSendCW'](arg1);
}
export function YaesuStopCW() {
return window['go']['main']['App']['YaesuStopCW']();
}
export function YaesuZeroIn() {
return window['go']['main']['App']['YaesuZeroIn']();
}
+90
View File
@@ -1074,6 +1074,70 @@ export namespace cat {
this.fixed = source["fixed"];
}
}
export class YaesuTXState {
available: boolean;
model?: string;
mode?: string;
raw_mode?: string;
transmitting: boolean;
split: boolean;
split_tx_hz: number;
s_meter: number;
power_meter: number;
swr_meter: number;
rf_power: number;
mic_gain: number;
af_gain: number;
rf_gain: number;
squelch: number;
agc?: string;
preamp: number;
att: number;
nb: boolean;
nr: boolean;
nr_level: number;
narrow: boolean;
swr: number;
power_w: number;
vox: boolean;
key_speed: number;
break_in: boolean;
static createFrom(source: any = {}) {
return new YaesuTXState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.model = source["model"];
this.mode = source["mode"];
this.raw_mode = source["raw_mode"];
this.transmitting = source["transmitting"];
this.split = source["split"];
this.split_tx_hz = source["split_tx_hz"];
this.s_meter = source["s_meter"];
this.power_meter = source["power_meter"];
this.swr_meter = source["swr_meter"];
this.rf_power = source["rf_power"];
this.mic_gain = source["mic_gain"];
this.af_gain = source["af_gain"];
this.rf_gain = source["rf_gain"];
this.squelch = source["squelch"];
this.agc = source["agc"];
this.preamp = source["preamp"];
this.att = source["att"];
this.nb = source["nb"];
this.nr = source["nr"];
this.nr_level = source["nr_level"];
this.narrow = source["narrow"];
this.swr = source["swr"];
this.power_w = source["power_w"];
this.vox = source["vox"];
this.key_speed = source["key_speed"];
this.break_in = source["break_in"];
}
}
}
@@ -1233,6 +1297,7 @@ export namespace extsvc {
hrdlog: ServiceConfig;
eqsl: ServiceConfig;
cloudlog: ServiceConfig;
delete_remote: boolean;
static createFrom(source: any = {}) {
return new ExternalServices(source);
@@ -1246,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 {
@@ -1537,6 +1603,7 @@ export namespace main {
format: string;
from_gain: number;
mic_gain: number;
tx_gain: number;
static createFrom(source: any = {}) {
return new AudioSettings(source);
@@ -1556,6 +1623,7 @@ export namespace main {
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
this.tx_gain = source["tx_gain"];
}
}
export class AutostartLaunchResult {
@@ -1835,6 +1903,14 @@ export namespace main {
flex_spots: boolean;
flex_decode_spots: boolean;
flex_decode_secs: number;
xiegu_port: string;
xiegu_baud: number;
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;
@@ -1848,6 +1924,8 @@ export namespace main {
poll_ms: number;
delay_ms: number;
digital_default: string;
share_enabled: boolean;
share_port: number;
static createFrom(source: any = {}) {
return new CATSettings(source);
@@ -1864,6 +1942,14 @@ export namespace main {
this.flex_spots = source["flex_spots"];
this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"];
this.xiegu_port = source["xiegu_port"];
this.xiegu_baud = source["xiegu_baud"];
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"];
@@ -1877,6 +1963,8 @@ export namespace main {
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
}
}
export class CabrilloResult {
@@ -2719,6 +2807,7 @@ export namespace main {
rotator_num: number;
transport: string;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new RotatorSettings(source);
@@ -2734,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 {
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"os"
"regexp"
"strconv"
"testing"
"hamlog/internal/cat/civ"
)
// The Icom model picker in the settings and civ.ModelName are two hand-kept
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
// factory addresses. Picking either set an address the rig never answers on —
// the symptom is a rig that simply never replies — and the backend then named it
// as the other model.
//
// The list is read out of the .tsx itself, so a model added on one side alone
// fails here rather than on someone's radio.
func TestIcomModelAddressesMatch(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
ms := re.FindAllStringSubmatch(string(src), -1)
if len(ms) < 5 {
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
}
for _, m := range ms {
name := m[1]
addr, err := strconv.ParseUint(m[2], 16, 8)
if err != nil {
t.Fatalf("bad address %q for %s", m[2], name)
}
if got := civ.ModelName(byte(addr)); got != name {
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
name, addr, addr, got)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// The confirmation statuses an import fills in, and the ones it must not touch.
//
// An operator asked for this feature believing it missing — it has been there
// since v0.14, but the option's description never mentioned it, so nobody could
// know. The behaviour is pinned here so a future edit cannot quietly drop what
// the description now promises.
//
// The rule is fill-if-empty: a WSJT-X log carries almost no QSL fields, while a
// log exported from another program carries real ones that must survive.
func TestQSLDefaultsFillOnlyEmptyFields(t *testing.T) {
defaults := QSLDefaults{
QSLSent: "N", QSLRcvd: "N",
LOTWSent: "N", LOTWRcvd: "N",
EQSLSent: "N", EQSLRcvd: "N",
ClublogStatus: "N", HRDLogStatus: "N",
QRZComStatus: "N", QRZComCfm: "N",
}
// A record as WSJT-X writes it: no confirmation fields at all.
bare := &qso.QSO{Callsign: "F4XYZ"}
applyQSLDefaultsTo(bare, defaults)
for name, got := range map[string]string{
"qsl_sent": bare.QSLSent, "qsl_rcvd": bare.QSLRcvd,
"lotw_sent": bare.LOTWSent, "lotw_rcvd": bare.LOTWRcvd,
"eqsl_sent": bare.EQSLSent, "eqsl_rcvd": bare.EQSLRcvd,
"clublog": bare.ClublogUploadStatus, "hrdlog": bare.HRDLogUploadStatus,
"qrz_sent": bare.QRZComUploadStatus, "qrz_rcvd": bare.QRZComDownloadStatus,
} {
if got != "N" {
t.Errorf("%s = %q on an empty record, want the default \"N\"", name, got)
}
}
// A record that already carries confirmations — from a real logbook export.
// Overwriting these would erase QSLs the operator has actually received.
kept := &qso.QSO{
Callsign: "F4XYZ",
QSLRcvd: "Y", LOTWRcvd: "Y", EQSLSent: "Y",
ClublogUploadStatus: "Y", QRZComDownloadStatus: "Y",
}
applyQSLDefaultsTo(kept, defaults)
for name, got := range map[string]string{
"qsl_rcvd": kept.QSLRcvd, "lotw_rcvd": kept.LOTWRcvd, "eqsl_sent": kept.EQSLSent,
"clublog": kept.ClublogUploadStatus, "qrz_rcvd": kept.QRZComDownloadStatus,
} {
if got != "Y" {
t.Errorf("%s = %q — an existing confirmation was overwritten", name, got)
}
}
// …while its empty ones still get the default.
if kept.QSLSent != "N" || kept.LOTWSent != "N" {
t.Errorf("empty fields on a partly-filled record were not defaulted: qsl_sent=%q lotw_sent=%q",
kept.QSLSent, kept.LOTWSent)
}
}
+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")
}
}
+23 -7
View File
@@ -22,9 +22,11 @@ import (
// Record is a single ADIF record. Keys are lowercased field names.
type Record map[string]string
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
// Parse reads an ADI stream and invokes fn for each record.
// Returning a non-nil error from fn stops parsing and is propagated.
// The header (text before <EOH>) is silently discarded.
//
// The header (text before <EOH>) is silently discarded. A file with NO header is
// read as records from the first tag — hand-assembled files often have none.
func Parse(r io.Reader, fn func(Record) error) error {
return parseWith(r, nil, fn)
}
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
br := bufio.NewReaderSize(r, 64*1024)
rec := Record{}
headerDone := false
for {
// Seek next '<'. Bytes before it are either header text or
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
name, length := parseSpec(spec)
switch name {
case "eoh":
headerDone = true
rec = Record{}
rec = Record{} // everything collected so far was the header
continue
case "eor":
if headerDone && len(rec) > 0 {
// An <eor> proves we are in the record section, whether or not an <eoh>
// came first. Files pasted together by hand — a few lines lifted out of
// wsjtx_log.adi into a new file — have no header at all, and requiring
// one made every record invisible: the import reported 0 imported,
// 0 ignored, 0 total, which reads as "the file is empty" rather than
// "there was no header".
//
// A real header cannot contain <eor>, so accepting it costs nothing for
// well-formed files: theirs ends at <eoh> long before any record.
if len(rec) > 0 {
if err := fn(rec); err != nil {
return err
}
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
if decodeValue == nil && !utf8.Valid(val) {
val = extendToRunes(br, val, length)
}
if headerDone && name != "" {
// Fields are collected ALWAYS, and thrown away at <eoh>.
//
// Collecting them only after the header meant a headerless file lost its
// FIRST record whole: its fields were discarded as header text, and the
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
// keeps real headers out of the data just as well — that tag is exactly
// what says "everything before this was the header".
if name != "" {
if decodeValue != nil {
rec[name] = decodeValue(val)
} else {
+69 -6
View File
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
}
func TestParseNoHeader(t *testing.T) {
// Some loggers omit the header entirely — records before <EOH> are
// discarded by design. Verify nothing is emitted in that case.
// A file without a header is IMPORTED, not discarded.
//
// This test asserted the opposite until 2026-07-29, when an operator pasted a
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
// reading of the spec, but the file it rejects is one people really make, and
// the rejection is silent: the count says the file was empty, not that a tag
// was missing. An <eor> is proof enough that a record ended.
src := `<CALL:5>F4XYZ<EOR>`
var got int
var got []Record
err := Parse(strings.NewReader(src), func(r Record) error {
got++
got = append(got, r)
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != 0 {
t.Errorf("expected 0 records without <EOH>, got %d", got)
if len(got) != 1 {
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
}
if got[0]["call"] != "F4XYZ" {
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
}
}
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
t.Errorf("freq mismatch: %q", got["freq"])
}
}
// A file with NO header must still import.
//
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
// empty file rather than as a missing header. Records were only emitted after
// <EOH>, so a hand-assembled file was invisible in its entirety.
func TestParseHeaderlessFile(t *testing.T) {
// The operator's exact shape: records straight from a WSJT-X log, no header.
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
var got []Record
if err := Parse(strings.NewReader(in), func(r Record) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("Parse: %v", err)
}
if len(got) != 2 {
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
}
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
}
if got[0]["gridsquare"] != "GG67" {
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
}
}
// And a normal file must be unaffected: its header is still discarded, so header
// fields never leak into the first record.
func TestParseHeaderStillDiscarded(t *testing.T) {
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
"<call:4>F4AA <mode:2>CW <eor>\n"
var got []Record
if err := Parse(strings.NewReader(in), func(r Record) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("Parse: %v", err)
}
if len(got) != 1 {
t.Fatalf("parsed %d records, want 1", len(got))
}
if _, leaked := got[0]["programid"]; leaked {
t.Error("a header field leaked into the record")
}
if got[0]["call"] != "F4AA" {
t.Errorf("call = %q, want F4AA", got[0]["call"])
}
}
+34 -14
View File
@@ -66,11 +66,12 @@ type Client struct {
authTries int
ready atomic.Bool // init commands sent → keepalive may run
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
lastShown map[int]int // port id → antenna last reported, so only CHANGES are logged
stop chan struct{}
running bool
@@ -81,13 +82,14 @@ func New(host string, port int, password string) *Client {
port = defaultPort
}
return &Client{
host: host,
port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}),
antennas: map[int]string{},
antBands: map[int]int{},
status: Status{Host: host},
host: host,
port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}),
antennas: map[int]string{},
antBands: map[int]int{},
lastShown: map[int]int{},
status: Status{Host: host},
}
}
@@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) {
}
tx := kvInt(msg, "txant")
rx := kvInt(msg, "rxant")
active := tx
// The RX antenna is what identifies a port's selection, and it is the only
// thing Activate sets. Preferring the TX antenna made both ports converge on
// the same row a few seconds after a change: on an 8x2 only ONE port can hold
// the transmit antenna, so the device reports the same txant on both, and a
// port A genuinely on the 80 m vertical was redrawn as the beam that port B
// transmits through.
//
// TX remains the fallback for a port that reports no RX antenna at all.
active := rx
if active == 0 {
active = rx
active = tx
}
// Logged only when the shown antenna CHANGES: the device pushes port state
// every few seconds, and logging each one would bury everything else — but
// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
c.statusMu.Lock()
prev, seen := c.lastShown[id]
c.lastShown[id] = active
c.statusMu.Unlock()
if !seen || prev != active {
applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
}
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
c.setStatus(func(s *Status) {
+73
View File
@@ -0,0 +1,73 @@
package antgenius
import "testing"
// Which antenna a port SHOWS.
//
// Reported on an 8x2: port A was correctly on the 80 m vertical, then a few
// seconds later both A and B were drawn on the same beam. The cause is that only
// ONE port can hold the transmit antenna, so the device reports the same txant
// on both — and the display preferred txant. The RX antenna is the per-port
// selection, and the only thing Activate sets, so it has to win.
func TestPortShowsRXAntenna(t *testing.T) {
cases := []struct {
name string
msg string
wantPort int
wantAnt int
}{
{
// The reported case: port A listening on antenna 2 while the shared TX
// antenna is 1. Before the fix this drew port A on antenna 1.
name: "rx and tx disagree — rx wins",
msg: "port 1 rxant=2 txant=1 tx=0",
wantPort: 1, wantAnt: 2,
},
{
name: "they agree",
msg: "port 2 rxant=3 txant=3 tx=0",
wantPort: 2, wantAnt: 3,
},
{
// A port with no RX antenna still shows something meaningful.
name: "no rx antenna — fall back to tx",
msg: "port 1 rxant=0 txant=4 tx=1",
wantPort: 1, wantAnt: 4,
},
{
name: "nothing selected",
msg: "port 2 rxant=0 txant=0 tx=0",
wantPort: 2, wantAnt: 0,
},
}
for _, c := range cases {
cl := New("host", 0, "")
cl.parsePort(c.msg)
st := cl.GetStatus()
got := st.PortA
if c.wantPort == 2 {
got = st.PortB
}
if got != c.wantAnt {
t.Errorf("%s: port %d shows antenna %d, want %d (%q)", c.name, c.wantPort, got, c.wantAnt, c.msg)
}
}
}
// The two ports must stay independent: a message about one must never move the
// other, which is the shape the operator actually saw on screen.
func TestPortsAreIndependent(t *testing.T) {
c := New("host", 0, "")
c.parsePort("port 1 rxant=2 txant=0 tx=0")
c.parsePort("port 2 rxant=1 txant=1 tx=1")
st := c.GetStatus()
if st.PortA != 2 {
t.Errorf("port A = %d, want 2 — port B's message moved it", st.PortA)
}
if st.PortB != 1 {
t.Errorf("port B = %d, want 1", st.PortB)
}
if !st.TxB || st.TxA {
t.Errorf("transmit flags crossed: TxA=%v TxB=%v", st.TxA, st.TxB)
}
}
+30 -1
View File
@@ -104,11 +104,27 @@ func (m *Manager) IsPlaying() bool {
// Play renders a WAV file to deviceID. Any current playback is stopped first.
// Returns immediately; playback runs in the background.
func (m *Manager) Play(deviceID, path string) error {
// Play sends a recorded message to a device, amplified by gainPct (100 = as
// recorded).
//
// The gain exists because nothing else could raise the level: the message went
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
// the operator had no control anywhere in OpsLog — only the radio's own USB
// input level, buried in its menus, and the Windows mixer.
func (m *Manager) Play(deviceID, path string, gainPct int) error {
pcm, rate, ch, bits, err := readWAV(path)
if err != nil {
return err
}
if gainPct > 0 && gainPct != 100 && bits == 16 {
g := float64(gainPct) / 100
// In place: the buffer is this call's own copy of the file.
for i := 0; i+1 < len(pcm); i += 2 {
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
v = scalePCM(v, g)
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
}
m.StopPlayback()
stop := make(chan struct{})
m.mu.Lock()
@@ -265,3 +281,16 @@ func (m *Manager) TXAudioActive() bool {
defer m.mu.Unlock()
return m.txStop != nil
}
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
// overflow that wraps turns loud speech into a burst of noise on the air.
func scalePCM(s int16, g float64) int16 {
v := float64(s) * g
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
+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
+24
View File
@@ -845,3 +845,27 @@ func BandFromHz(hz int64) string {
}
return ""
}
// YaesuState returns the panel snapshot, or (zero, false) when the active
// backend isn't a Yaesu — same shape as IcomState.
func (m *Manager) YaesuState() (YaesuTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if yc, ok := b.(YaesuController); ok {
return yc.YaesuState(), true
}
return YaesuTXState{}, false
}
// YaesuDo dispatches a Yaesu control onto the CAT goroutine, so a panel click
// and the poll loop never share the serial port at the same instant.
func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
return m.exec(func(b Backend) error {
yc, ok := b.(YaesuController)
if !ok {
return fmt.Errorf("active CAT backend is not a Yaesu")
}
return fn(yc)
})
}
+19 -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
@@ -344,6 +357,8 @@ func ModelName(addr byte) string {
return "IC-9700"
case 0xA4:
return "IC-705"
case 0xB6:
return "IC-7300MKII"
}
return fmt.Sprintf("Icom (0x%02X)", addr)
}
+46 -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)
}
}
@@ -152,6 +153,7 @@ func TestModelNameAddresses(t *testing.T) {
0x98: "IC-7610",
0xA2: "IC-9700",
0xA4: "IC-705",
0xB6: "IC-7300MKII",
} {
if got := ModelName(addr); got != want {
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
@@ -162,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")
}
}
+62
View File
@@ -0,0 +1,62 @@
package cat
import (
"testing"
"time"
)
// The rule that decides whether a silent-but-alive Icom network session is
// tolerated or torn down. It was "always tolerate", and that is what froze the
// frequency display for ever when another program took the CI-V session.
//
// The decision is pinned here rather than left implicit, because the two cases
// it separates pull in opposite directions: a rig in standby must NOT be
// reconnected every few seconds (the panel and its ON button would blink away),
// while a hijacked session must recover without restarting OpsLog.
func TestIcomSilenceTolerance(t *testing.T) {
// tolerate mirrors the condition in ReadState.
tolerate := func(alive, readerGone bool, lastGood time.Time, silentFor, grace time.Duration) bool {
return alive && !readerGone && (lastGood.IsZero() || silentFor < grace)
}
never := time.Time{}
some := time.Now()
cases := []struct {
name string
alive bool
readerGone bool
lastGood time.Time
silentFor time.Duration
want bool
}{
{"never answered since connect — rig is off, keep the panel", true, false, never, time.Hour, true},
{"brief silence during a band change", true, false, some, 2 * time.Second, true},
{"silence past the grace — reconnect", true, false, some, icomSilentGrace + time.Second, false},
{"reader goroutine gone — dead however alive the link looks", true, true, some, time.Second, false},
{"control link itself dead", false, false, some, time.Second, false},
}
for _, c := range cases {
got := tolerate(c.alive, c.readerGone, c.lastGood, c.silentFor, icomSilentGrace)
if got != c.want {
t.Errorf("%s: tolerate = %v, want %v", c.name, got, c.want)
}
}
}
// The backoff must actually converge on the ceiling: a rig left switched off is
// silent for hours, and a window that stayed at 30 s would re-tear its session
// about a hundred times an hour.
func TestIcomSilenceBackoff(t *testing.T) {
g := icomSilentGrace
for i := 0; i < 20; i++ {
if g < icomSilentGraceMax {
g *= 2
}
}
if g < icomSilentGraceMax {
t.Fatalf("backoff never reached the ceiling: %s < %s", g, icomSilentGraceMax)
}
if g > 2*icomSilentGraceMax {
t.Errorf("backoff overshot the ceiling: %s", g)
}
}
+106 -17
View File
@@ -37,6 +37,19 @@ type aliveTransport interface {
Alive() bool
}
const (
// How long the network backend accepts "the control link answers but the rig
// sends no CI-V" before declaring the session lost. Long enough to cover a
// band change or a rig booting from standby; short enough that an operator
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
// a live frequency back on his own rather than restarting OpsLog.
icomSilentGrace = 30 * time.Second
// Ceiling for the backoff applied when the silence persists — a rig switched
// OFF is silent for hours, and re-tearing its session every 30 s would blink
// the panel (and its ON button) away continuously.
icomSilentGraceMax = 4 * time.Minute
)
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
// (0x27) frames on a SEPARATE channel from control replies. The network transport
// implements it so the continuous panadapter stream can't crowd control replies
@@ -89,13 +102,17 @@ type IcomSerial struct {
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
// "alive but silent" tolerance below, which used to be
// unbounded and left the display frozen for ever
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
@@ -267,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// rig is switched on) rather than tearing the whole UDP session down and
// flapping every few seconds. The panel stays up so the ON button works.
if at, ok := b.port.(aliveTransport); ok {
if at.Alive() {
// The reader goroutine is what feeds every CI-V reply. If it has
// exited, no read can ever succeed again, however healthy the control
// link looks — that is not a silent rig, it is a dead connection.
readerGone := false
if b.readerDone != nil {
select {
case <-b.readerDone:
readerGone = true
default:
}
}
// "Alive but silent" is the rig in standby, or mid band-change. It was
// tolerated for ever, and that is the freeze: when ANOTHER program takes
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
// pings on the control stream while sending us no CI-V at all. Alive()
// stayed true, the cached frequency was re-published with a fresh
// timestamp on every poll, and the operator saw a confident, frozen
// number until OpsLog was restarted. Bounded now: past the grace period
// the error is reported so the Manager tears the session down and
// reconnects, which re-takes the CI-V stream.
silentFor := time.Duration(0)
if !b.lastGoodAt.IsZero() {
silentFor = time.Since(b.lastGoodAt)
}
if b.silentGrace <= 0 {
b.silentGrace = icomSilentGrace
}
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
b.readFails = 0
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
if b.curModeByte != 0 {
@@ -286,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock()
return s, nil
}
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
return RigState{}, err // control link dead → let the Manager reconnect
switch {
case readerGone:
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
case at.Alive():
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
default:
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
}
// Restart the clock, and widen the window each time it fires without a
// good read in between. A hijacked session recovers on the first shot;
// a rig simply switched OFF never will, and re-tearing its session every
// 30 s would make the panel — and its ON button — blink away
// continuously. Backing off to minutes keeps the standby case quiet
// while still recovering on its own.
b.lastGoodAt = time.Now()
if b.silentGrace < icomSilentGraceMax {
b.silentGrace *= 2
}
return RigState{}, err // let the Manager reconnect
}
// USB (no liveness signal): the rig briefly stops answering CI-V while it
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
@@ -308,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
return RigState{}, err
}
b.readFails = 0
b.lastGoodAt = time.Now()
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
s.FreqHz = hz
b.curFreq = hz
@@ -494,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
}
@@ -540,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:]...)
@@ -687,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)
@@ -725,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)
@@ -1079,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
@@ -1109,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)
}
+26 -8
View File
@@ -493,12 +493,24 @@ func (o *OmniRig) SetFrequency(hz int64) error {
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
// where direct FreqA/FreqB writes are accepted but never move the radio.
// Clearing split is the right thing when tuning to a spot anyway.
// …but ONLY when the operator is on the main VFO. SetSimplexMode means
// "receive and transmit here, simplex", and OmniRig implements that on the
// MAIN VFO — so on an FTDX101 listening on SUB, clicking a cluster spot
// dragged the radio back to MAIN (F4NBZ, 2026-07-29). The operator picked SUB
// deliberately; a spot click is a request to change frequency, not to change
// VFO. On SUB the direct FreqB write below does the whole job.
onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
simplexOK := false
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
simplexOK = true
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
} else {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
switch {
case onSubVFO:
debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
default:
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
simplexOK = true
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
} else {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
}
}
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
@@ -511,12 +523,18 @@ func (o *OmniRig) SetFrequency(hz int64) error {
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
if !isIcom || !simplexOK {
prop := "FreqA"
switch vfo {
case "B", "BB", "BA":
if onSubVFO {
prop = "FreqB"
}
okAny := false
for _, p := range []string{prop, "Freq"} {
// The generic "Freq" property is written too — but NOT on SUB, where it is
// the main VFO's frequency on several rig files and would move the VFO the
// operator is not using, or pull them back to it.
props := []string{prop, "Freq"}
if onSubVFO {
props = []string{prop}
}
for _, p := range props {
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
} else {
+42
View File
@@ -83,3 +83,45 @@ func TestResolveOmniRigVFOs(t *testing.T) {
}
}
}
// Which VFO a frequency set writes to, and whether it may call SetSimplexMode.
//
// Reported on an FTDX101 (F4NBZ, 2026-07-29): listening on SUB, clicking a
// cluster spot dragged the radio back to MAIN. SetSimplexMode means "receive and
// transmit here, simplex" and OmniRig applies it to the MAIN VFO — so it must
// not be used when the operator is on SUB. They chose that VFO deliberately; a
// spot click asks for a frequency, not for a VFO change.
func TestOmniRigWriteTarget(t *testing.T) {
// Mirrors the decision made in SetFrequency.
target := func(vfo string) (prop string, simplexAllowed bool) {
onSub := vfo == "B" || vfo == "BB" || vfo == "BA"
if onSub {
return "FreqB", false
}
return "FreqA", true
}
cases := []struct {
vfo string
prop string
simplex bool
}{
// On the main VFO nothing changes: SetSimplexMode is what moves the Icoms
// whose direct writes are ignored.
{"", "FreqA", true},
{"A", "FreqA", true},
{"AA", "FreqA", true},
{"AB", "FreqA", true},
// On SUB, write FreqB and leave the VFO selection alone.
{"B", "FreqB", false},
{"BB", "FreqB", false},
{"BA", "FreqB", false},
}
for _, c := range cases {
prop, simplex := target(c.vfo)
if prop != c.prop || simplex != c.simplex {
t.Errorf("VFO %q → write %s, simplex=%v; want %s, simplex=%v",
c.vfo, prop, simplex, c.prop, c.simplex)
}
}
}
+289
View File
@@ -0,0 +1,289 @@
package cat
// Native Xiegu CAT (G90, X6100, X6200, X5105 and relatives) over the rig's
// serial/USB port.
//
// Xiegu speaks CI-V — Icom's bus protocol — with a REDUCED command set. Frames,
// BCD encoding, addressing and the opcodes for frequency (0x03/0x05), mode
// (0x04/0x06), PTT (0x1C 0x00), split (0x0F) and the meters (0x15) are the same
// as Icom's, which is why this backend reuses internal/cat/civ wholesale rather
// than re-deriving it.
//
// It is a SEPARATE backend rather than the Icom one with another address,
// because what the two rigs DON'T share is the important part. The Icom backend
// reads the spectrum scope, the DSP block, data-mode via 0x1A 0x06, the model id
// via 0x19 — none of which a Xiegu implements. Pointed at a G90 it would poll
// for answers that never come on every cycle, and its silence tolerance would
// spend itself on commands the radio was never going to support.
//
// Mode differences that matter: the Xiegu table lists LSB, USB, AM, CW and CWR
// only — no FM, no RTTY, and no data mode. A digital QSO therefore runs in USB
// (which is what the operator does on the radio anyway), and the mode is
// reported as the operator's configured digital mode when they select one, not
// invented from the rig.
//
// Verified on: nothing yet — written from the Xiegu CI-V command table. The
// command table published by Xiegu has rows that clearly slipped during
// typesetting (0x07 and 0x0F share a block), so where it contradicts itself the
// Icom meaning is used, since the rest of the table matches Icom exactly. Every
// unexpected reply is logged raw so a first on-air run settles it.
import (
"fmt"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/cat/civ"
)
// XieguDefaultAddr is the factory CI-V address of the G90/X6100 family.
const XieguDefaultAddr = 0x70
type Xiegu struct {
portName string
baud int
rigAddr byte
digital string
mu sync.Mutex
port serial.Port
curFreq int64
// splitSupported is cleared when the rig ignores the split query, so we stop
// asking every cycle — a Xiegu that has no split must not cost a timeout per
// poll, which would slow the whole loop to a crawl.
splitSupported bool
}
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
if baud <= 0 {
baud = 19200 // G90 factory default
}
if addr <= 0 || addr > 0xFF {
addr = XieguDefaultAddr
}
if strings.TrimSpace(digital) == "" {
digital = "FT8"
}
return &Xiegu{
portName: strings.TrimSpace(portName), baud: baud,
rigAddr: byte(addr), digital: digital, splitSupported: true,
}
}
func (x *Xiegu) Name() string { return "xiegu" }
func (x *Xiegu) Connect() error {
x.mu.Lock()
defer x.mu.Unlock()
if x.portName == "" {
return fmt.Errorf("xiegu: no serial port configured")
}
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
if err != nil {
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
}
p.SetReadTimeout(200 * time.Millisecond)
x.port = p
x.splitSupported = true
// Prove the link before declaring success: an open COM port says nothing
// about a radio being on the other end, and a backend that reports
// "connected" to a powered-off rig sends the operator hunting for a fault in
// the wrong place.
if _, err := x.readFreq(); err != nil {
_ = p.Close()
x.port = nil
return fmt.Errorf("xiegu: no answer on %s @ %d baud (address 0x%02X): %w", x.portName, x.baud, x.rigAddr, err)
}
debugLog.Printf("xiegu: connected on %s @ %d baud, CI-V address 0x%02X", x.portName, x.baud, x.rigAddr)
return nil
}
func (x *Xiegu) Disconnect() {
x.mu.Lock()
defer x.mu.Unlock()
if x.port != nil {
_ = x.port.Close()
x.port = nil
}
}
func (x *Xiegu) ReadState() (RigState, error) {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return RigState{}, fmt.Errorf("xiegu: not connected")
}
s := RigState{Backend: x.Name(), Connected: true, Rig: "Xiegu"}
hz, err := x.readFreq()
if err != nil {
return RigState{}, err // let the Manager reconnect
}
s.FreqHz = hz
x.curFreq = hz
if d, err := x.ask(civ.CmdReadMode); err == nil && len(d.Data) >= 1 {
s.Mode = civ.ModeToADIF(d.Data[0], false)
// The rig has no data mode, so it reports USB on the digital watering
// holes. Naming the operator's digital mode there is the frontend's job
// (it infers from frequency); reporting USB honestly is ours.
}
if x.splitSupported {
d, err := x.ask(civ.CmdSplit)
switch {
case err != nil:
debugLog.Printf("xiegu: split query got no answer (%v) — not asking again this session", err)
x.splitSupported = false
case len(d.Data) >= 1:
s.Split = d.Data[0] == 0x01
}
}
// Split TX frequency is deliberately NOT reported. Reading the unselected
// VFO needs 0x25, which the Xiegu table does not list — and a split flag with
// a wrong TX frequency is worse than a split flag alone, because it is the
// frequency that gets logged.
return s, nil
}
func (x *Xiegu) SetFrequency(hz int64) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
if hz <= 0 {
return fmt.Errorf("xiegu: invalid frequency %d", hz)
}
payload := append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)
return x.send(payload...)
}
func (x *Xiegu) SetMode(mode string) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
m, ok := xieguModeByte(mode, x.curFreq)
if !ok {
return fmt.Errorf("xiegu: no CAT mode for %q", mode)
}
return x.send(civ.CmdSetMode, m)
}
func (x *Xiegu) SetPTT(on bool) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
v := byte(0x00)
if on {
v = 0x01
}
return x.send(civ.CmdPTT, 0x00, v)
}
// ── helpers ───────────────────────────────────────────────────────────────
func (x *Xiegu) send(payload ...byte) error {
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
_, err := x.port.Write(civ.Frame(x.rigAddr, civ.AddrController, payload...))
return err
}
// ask sends a query and returns the rig's answer frame.
//
// CI-V is a shared bus: the rig echoes back what we sent before answering, so
// our own frame has to be skipped. Matching on the SENDER (From == the rig)
// rather than on position is what makes this robust when an echo is dropped or
// an unsolicited frame arrives from the dial being turned.
func (x *Xiegu) ask(payload ...byte) (civ.Decoded, error) {
if err := x.send(payload...); err != nil {
return civ.Decoded{}, err
}
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := x.port.Read(tmp)
if err != nil {
return civ.Decoded{}, err
}
if n == 0 {
continue
}
buf = append(buf, tmp[:n]...)
frames, consumed := civ.Scan(buf)
buf = buf[consumed:]
for _, f := range frames {
if f.From != x.rigAddr {
continue // our own echo on the bus
}
if f.Cmd == 0xFA {
return civ.Decoded{}, fmt.Errorf("xiegu: rig rejected command 0x%02X", payload[0])
}
if f.Cmd == payload[0] {
return f, nil
}
// An unsolicited update (the operator turning the dial) — useful, but
// not the answer we asked for.
debugLog.Printf("xiegu: unsolicited frame cmd=0x%02X data=% X", f.Cmd, f.Data)
}
}
return civ.Decoded{}, fmt.Errorf("xiegu: timeout answering 0x%02X", payload[0])
}
func (x *Xiegu) readFreq() (int64, error) {
d, err := x.ask(civ.CmdReadFreq)
if err != nil {
return 0, err
}
hz, ok := civ.BCDToFreq(d.Data)
if !ok || hz <= 0 {
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
}
return hz, nil
}
// xieguModeByte maps an ADIF mode to the Xiegu's mode byte.
//
// The rig has LSB, USB, AM, CW and CWR — nothing else. A digital mode therefore
// becomes USB (or LSB below 10 MHz), which is what the operator selects on the
// radio; claiming a DATA mode it does not have would just be refused.
func xieguModeByte(mode string, freqHz int64) (byte, bool) {
lowBand := freqHz > 0 && freqHz < 10_000_000
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "":
return 0, false
case "LSB":
return 0x00, true
case "USB":
return 0x01, true
case "SSB":
if lowBand {
return 0x00, true
}
return 0x01, true
case "AM":
return 0x02, true
case "CW":
return 0x03, true
case "CWR", "CW-R":
return 0x07, true
default:
// Every digital sub-mode rides on plain sideband here.
if lowBand {
return 0x00, true
}
return 0x01, true
}
}
+84
View File
@@ -0,0 +1,84 @@
package cat
import (
"testing"
"hamlog/internal/cat/civ"
)
// The Xiegu mode table is SHORTER than Icom's: LSB, USB, AM, CW, CWR and
// nothing else. The interesting cases are the ones with no equivalent — a
// digital mode must land on plain sideband rather than be refused, because
// refusing it would leave the rig on whatever it was and silently log the wrong
// mode.
func TestXieguModeByte(t *testing.T) {
cases := []struct {
mode string
hz int64
want byte
ok bool
}{
{"SSB", 7150000, 0x00, true}, // LSB below 10 MHz
{"SSB", 14250000, 0x01, true}, // USB above
{"LSB", 14250000, 0x00, true}, // explicit beats the convention
{"USB", 7150000, 0x01, true},
{"AM", 7150000, 0x02, true},
{"CW", 7030000, 0x03, true},
{"CWR", 7030000, 0x07, true},
{"FT8", 7074000, 0x00, true}, // no data mode on this rig → LSB
{"FT8", 14074000, 0x01, true}, // → USB
{"RTTY", 14080000, 0x01, true},
{"FM", 145000000, 0x01, true}, // no FM either; sideband is the honest fallback
{"", 14074000, 0x00, false},
}
for _, c := range cases {
got, ok := xieguModeByte(c.mode, c.hz)
if got != c.want || ok != c.ok {
t.Errorf("xieguModeByte(%q, %d) = 0x%02X,%v — want 0x%02X,%v", c.mode, c.hz, got, ok, c.want, c.ok)
}
}
}
// The Xiegu mode bytes must decode through the shared Icom table, since that is
// the whole reason this backend reuses internal/cat/civ instead of its own
// codec. If the two ever disagree, the radio would be set to one mode and read
// back as another.
func TestXieguModesRoundTripThroughCIV(t *testing.T) {
cases := []struct {
mode string
adif string
}{
// ADIF has no LSB/USB distinction — both sidebands ARE the SSB mode, and
// that is what gets logged. The sideband still matters to the radio, which
// is why xieguModeByte picks it from the frequency.
{"LSB", "SSB"},
{"USB", "SSB"},
{"AM", "AM"},
{"CW", "CW"},
}
for _, c := range cases {
b, ok := xieguModeByte(c.mode, 14200000)
if !ok {
t.Fatalf("xieguModeByte(%q) refused", c.mode)
}
if got := civ.ModeToADIF(b, false); got != c.adif {
t.Errorf("%s → 0x%02X → %q, want %q", c.mode, b, got, c.adif)
}
}
}
// Frequency encoding is shared with Icom, and it is the one field where a byte
// out of place mistunes the radio silently. Pinned here at the boundaries a
// Xiegu actually covers (it is an HF rig, so the 5-byte BCD upper bytes are 0).
func TestXieguFrequencyEncoding(t *testing.T) {
for _, hz := range []int64{1840000, 7074000, 14074000, 28500000, 50313000} {
b := civ.FreqToBCD(hz)
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
}
got, ok := civ.BCDToFreq(b)
if !ok || got != hz {
t.Errorf("round trip %d → % X → %d (ok=%v)", hz, b, got, ok)
}
}
}
+588
View File
@@ -0,0 +1,588 @@
package cat
// Native Yaesu CAT over the rig's serial/USB port — no OmniRig.
//
// Why this exists: OmniRig sits between OpsLog and the radio and adds its own
// rig-description files, its own VFO/split interpretation and its own polling.
// Every Yaesu problem reported so far came from that layer disagreeing with the
// radio — a .ini that never exposes the VFO, a Freq property that means A on one
// model and B on another, a split flag that alternates. Talking to the rig
// directly removes the disagreement: what the radio answers is what we show.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Modern Yaesu CAT is plain ASCII: a command, its arguments, and a ';'
// terminator. A query is the command with no argument; the rig echoes the same
// command with the value. It is the same shape as Kenwood's, which is why an
// FTDX10 answers a Kenwood-speaking logger for the basics.
//
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
// FB; → FB014100000; VFO B frequency
// 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)
// FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family)
// TX1; / TX0; key / unkey
// ID; → ID0761; model identifier
//
// Two of these are genuinely uncertain across the family and are treated as
// such rather than guessed at: SPLIT is read through ST and, if the rig does not
// answer that, through FT — whichever replies wins, and the choice is
// remembered. Every unrecognised reply is logged raw, because that log is the
// only way to learn a model's real behaviour from an operator's shack.
//
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
// against the radio. The other models are still inference from the same CAT
// reference; anything this file asserts about a rig it has not met should be
// read as a hypothesis with a log line attached.
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
// yaesuModels maps the ID reply to a display name. An unknown id is shown as
// itself rather than guessed — a wrong model name would be worse than a number,
// because it silently implies capabilities the rig may not have.
var yaesuModels = map[string]string{
"0761": "FTDX10",
"0681": "FTDX101D",
"0682": "FTDX101MP",
"0800": "FT-710",
"0570": "FT-991A",
"0650": "FT-891",
"0670": "FT-DX3000",
"0460": "FT-450D",
}
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
// differ only by sideband, which ADIF does not record — they collapse to the
// operator's configured digital mode and to RTTY respectively.
var yaesuModeToADIF = map[byte]string{
'1': "LSB",
'2': "USB",
'3': "CW",
'4': "FM",
'5': "AM",
'6': "RTTY",
'7': "CW",
'8': "DATA",
'9': "RTTY",
'A': "FM",
'B': "FM",
'C': "DATA",
'D': "AM",
'E': "FM", // C4FM — digital voice, closest ADIF sense is FM
}
type Yaesu struct {
portName string
baud int
digital string // ADIF mode reported for DATA (FT8, RTTY…)
mu sync.Mutex
port serial.Port
model string
// splitCmd is learned at connect: "ST" or "FT" depending on which the rig
// answers. Empty means the rig answered neither, and split is reported as
// off rather than invented.
splitCmd string
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
// and VS is used — see ReadState.
rxVFOCmd string
curFreq int64
curRXFreq int64
curVFO string // "A" or "B"
// Control-panel state and its slow-beat counter — see yaesu_panel.go.
panel YaesuTXState
panelCycle int
panelLoaded bool
// Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool
// Needle inertia for the TX meters — see meterPeak.
powerPeak meterPeak
powerWPeak meterPeak
swrPeak meterPeak
// metersLogged counts the RM1..RM6 samples taken during transmission, so the
// survey follows a real carrier instead of catching one instant of it.
metersLogged int
}
func NewYaesu(portName string, baud int, digital string) *Yaesu {
if baud <= 0 {
baud = 38400 // FTDX10/FTDX101 factory default
}
if strings.TrimSpace(digital) == "" {
digital = "FT8"
}
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
func (y *Yaesu) Name() string { return "yaesu" }
func (y *Yaesu) Connect() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.portName == "" {
return fmt.Errorf("yaesu: no serial port configured")
}
// Close any handle we still hold before opening another.
//
// Connect is called again on every reconnect, and it used to overwrite y.port
// with a fresh handle and leak the old one. Windows opens a serial port
// EXCLUSIVELY, so a leaked handle makes the next open fail with "Serial port
// busy" — seen in the field — and the retry loop then leaks one handle per
// attempt, once every few seconds, for as long as it keeps failing.
if y.port != nil {
_ = y.port.Close()
y.port = nil
}
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
}
p.SetReadTimeout(300 * time.Millisecond)
y.port = p
// Silence unsolicited status reports. The rig can push them on every knob
// movement (AI1), which interleaves with our request/response pairs and makes
// a reply impossible to attribute — we poll instead, so the traffic is ours.
_ = y.write("AI0;")
answered := false
if id, err := y.ask("ID;"); err == nil {
answered = true
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
if name, ok := yaesuModels[code]; ok {
y.model = name
} else {
y.model = "Yaesu (" + code + ")"
debugLog.Printf("yaesu: unknown model id %q — add it to yaesuModels", code)
}
} else {
debugLog.Printf("yaesu: ID query failed (%v) — continuing, the model name is cosmetic", err)
}
// Which command carries split on THIS rig. Asking once at connect and
// remembering the answer keeps the poll loop from paying for two round trips
// per cycle, and makes "neither answered" an explicit, logged state instead
// of a silent assumption that split is off.
// Which command reports the RECEIVE VFO. FR is the right one where it exists;
// VS is a weaker substitute that an FTDX101 answers with the main VFO even
// when the operator has moved both RX and TX to sub.
if r, err := y.ask("FR;"); err == nil && strings.HasPrefix(r, "FR") {
answered = true
y.rxVFOCmd = "FR"
debugLog.Printf("yaesu: receive VFO is read through FR (answered %q)", r)
} else {
debugLog.Printf("yaesu: no FR; — falling back to VS for the receive VFO")
}
// Which command carries split — and the ORDER matters.
//
// ST is a bare flag: it says "split", not which VFO transmits. On an FTDX101
// with RX and TX both moved to SUB, the rig reports ST1 even though that is
// plain simplex on the sub VFO, and OpsLog showed split with the main
// frequency as TX (F4NBZ, 2026-07-29). FT names the TRANSMIT VFO, so where the
// receive VFO is also known (FR), "split" can be derived from the two: it is
// on when they differ. That is a statement about the rig's actual state rather
// than a flag whose meaning varies by model, so FT is asked FIRST when FR
// answered, and ST remains the fallback for rigs without either.
splitProbes := []string{"ST", "FT"}
if y.rxVFOCmd != "" {
splitProbes = []string{"FT", "ST"}
}
for _, c := range splitProbes {
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
answered = true
y.splitCmd = c
debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
break
}
}
if y.splitCmd == "" {
debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
}
// A port that opens is not a rig that is there.
//
// The log used to announce "connected … model=FTDX101D" after every probe had
// timed out, with the model left over from the previous session — an operator
// whose radio was simply switched OFF got a line saying it was connected, and
// the real cause took a log study to find. Say what actually happened.
if !answered {
y.model = ""
debugLog.Printf("yaesu: %s opens at %d baud but the rig answers nothing — is it powered on, and is its CAT rate %d?",
y.portName, y.baud, y.baud)
return fmt.Errorf("yaesu: %s opened but the rig is not answering — check that it is switched on", y.portName)
}
debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
return nil
}
func (y *Yaesu) Disconnect() {
y.mu.Lock()
defer y.mu.Unlock()
if y.port != nil {
_ = y.port.Close()
y.port = nil
}
}
func (y *Yaesu) ReadState() (RigState, error) {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return RigState{}, fmt.Errorf("yaesu: not connected")
}
s := RigState{Backend: y.Name(), Connected: true, Rig: y.model}
faRaw, err := y.ask("FA;")
if errors.Is(err, errYaesuUnsupported) {
// A "?;" here is almost never about FA — the rig answers frequency queries
// perfectly well. It is a rejection left over from the PREVIOUS command
// that our read then attributed to this one. Retrying once costs a few
// milliseconds; treating it as "lost the rig" tore the CAT link down and
// reconnected it, which is what the operator saw on every CW macro.
debugLog.Printf("yaesu: FA; got a stray rejection — retrying once")
faRaw, err = y.ask("FA;")
}
if err != nil {
return RigState{}, err // the rig stopped answering — let the Manager reconnect
}
freqA, ok := parseYaesuFreq(faRaw, "FA")
if !ok {
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
}
freqB := int64(0)
if r, err := y.ask("FB;"); err == nil {
freqB, _ = parseYaesuFreq(r, "FB")
}
// Which VFO the operator is LISTENING on.
//
// FR is the command that answers that — it selects the receive VFO — and VS
// does not: on an FTDX101 with both RX and TX moved to SUB, VS still reported
// the main VFO, so OpsLog displayed VFO A while the operator was entirely on
// B. Reported 2026-07-29. FR is asked first and VS is the fallback for models
// that do not implement it.
vfo := "A"
switch {
case y.rxVFOCmd != "":
if r, err := y.ask(y.rxVFOCmd + ";"); err == nil && yaesuStateDigit(r, y.rxVFOCmd) == '1' {
vfo = "B"
}
default:
if r, err := y.ask("VS;"); err == nil && yaesuStateDigit(r, "VS") == '1' {
vfo = "B"
}
}
y.curVFO = vfo
split := false
if y.splitCmd != "" {
if r, err := y.ask(y.splitCmd + ";"); err == nil {
split = yaesuSplitFromReply(r, y.splitCmd, vfo)
}
}
s.Vfo = vfo
s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split)
y.curFreq = s.FreqHz
// The frequency being LISTENED to, which is what a split offset is measured
// from — under split that is RxFreqHz, not FreqHz.
y.curRXFreq = s.FreqHz
if s.Split && s.RxFreqHz > 0 {
y.curRXFreq = s.RxFreqHz
}
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])
if m, ok := yaesuModeToADIF[r[3]]; ok {
if m == "DATA" {
m = y.digital
}
s.Mode = m
} else {
debugLog.Printf("yaesu: unknown mode reply %q", r)
}
}
// s.FreqHz is the TX frequency by the ADIF convention, so it IS the split
// transmit frequency when split is on.
y.readPanel(s.Mode, s.Split, s.FreqHz)
return s, nil
}
func (y *Yaesu) SetFrequency(hz int64) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
if hz <= 0 || hz > 999_999_999 {
return fmt.Errorf("yaesu: frequency %d out of the 9-digit CAT range", hz)
}
// Write to the VFO the operator is ACTUALLY on. Always writing FA is what
// makes a display disagree with the radio when the operator is on B.
cmd := "FA"
if y.curVFO == "B" {
cmd = "FB"
}
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
}
func (y *Yaesu) SetMode(mode string) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
d := yaesuModeDigit(mode, y.curFreq)
if d == 0 {
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
}
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 {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
if on {
return y.write("TX1;")
}
return y.write("TX0;")
}
// ── helpers ───────────────────────────────────────────────────────────────
// write sends one command. The caller holds the mutex.
func (y *Yaesu) write(cmd string) error {
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
_, err := y.port.Write([]byte(cmd))
return err
}
// ask sends a query and reads the reply up to its ';'. The caller holds the
// mutex, so a command and its answer are never interleaved with another's.
func (y *Yaesu) ask(cmd string) (string, error) {
if err := y.write(cmd); err != nil {
return "", err
}
// Match the reply to the COMMAND, and drop anything else.
//
// Returning the first ';'-terminated string whatever it was is what made a CW
// macro knock the CAT link over: KY produces no reply, so the next query —
// FA; from the poll loop — collected a leftover frame, failed to parse as a
// frequency, and the Manager treated that as "lost the rig" and reconnected.
// The operator saw the CAT drop for a few seconds on every macro click.
want := cmdPrefix(cmd)
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := y.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:]
// "?;" is the rig saying it does not know this command. Confirmed on an
// FTDX10, which answers it to KY; and MG;. Reporting it as such — rather
// than discarding it and waiting out the timeout — is what lets callers
// stop asking instead of paying 600 ms per poll for ever.
if strings.TrimSpace(frame) == "?;" {
return "", errYaesuUnsupported
}
if want == "" || strings.HasPrefix(strings.ToUpper(frame), want) {
return frame, nil
}
debugLog.Printf("yaesu: discarding %q while waiting for %s (asked %q)", frame, want, cmd)
}
if len(buf) > 512 {
return "", fmt.Errorf("yaesu: no ';' in %d bytes answering %q", len(buf), cmd)
}
}
return "", fmt.Errorf("yaesu: timeout answering %q", cmd)
}
// errYaesuUnsupported is returned when the rig answers "?;" — it does not know
// the command. Different models implement different subsets, and the only
// reliable way to learn which is to ask once and remember the refusal.
var errYaesuUnsupported = errors.New("yaesu: command not supported by this rig")
// cmdPrefix is the leading letters of a command — what its reply starts with.
// "FA;" → "FA", "MD0;" → "MD", "KY;" → "KY".
func cmdPrefix(cmd string) string {
c := strings.ToUpper(strings.TrimSpace(cmd))
for i := 0; i < len(c); i++ {
if c[i] < 'A' || c[i] > 'Z' {
return c[:i]
}
}
return c
}
// parseYaesuFreq reads "FA014074000;" into Hz.
func parseYaesuFreq(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
}
// yaesuSplitFromReply turns the split reply into a yes or no.
//
// The two commands say DIFFERENT things and reading them alike is a real fault,
// reported on an FTDX101 (F4NBZ, 2026-07-29) where the panel showed split ON with
// the radio OFF and the reverse:
//
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
//
// Split is on when the rig TRANSMITS on a different VFO from the one it is
// LISTENING to. Reading FT1 as "split" is therefore only right for an operator
// on VFO A: on SUB it is exactly inverted, which is why the same model behaved
// correctly for one operator and backwards for another — one was on MAIN, the
// other on SUB.
func yaesuSplitFromReply(reply, cmd, vfo string) bool {
d := yaesuStateDigit(reply, cmd)
if d == 0 {
return false
}
if cmd == "ST" {
return d == '1'
}
// FT: compare the transmit VFO with the one being listened to.
txOnB := d == '1'
rxOnB := strings.HasPrefix(strings.ToUpper(vfo), "B")
return txOnB != rxOnB
}
// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
// the ADIF pair: FreqHz is where we TRANSMIT, RxFreqHz only when split.
//
// Kept pure and separate from ReadState so the rules can be tested without a
// radio — the equivalent OmniRig function is where every Yaesu bug lived.
func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64, isSplit bool) {
listening, transmitting := freqA, freqB
if vfo == "B" {
listening, transmitting = freqB, freqA
}
if !split {
return listening, 0, false
}
// Split with a missing or identical other VFO is not split: reporting it
// would put a wrong TX frequency in the log, which is worse than ignoring a
// flag the rig may have left set.
if transmitting <= 0 || transmitting == listening {
return listening, 0, false
}
return transmitting, listening, true
}
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
// which is why the current frequency is part of the decision.
func yaesuModeDigit(mode string, freqHz int64) byte {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB":
if freqHz > 0 && freqHz < 10_000_000 {
return '1' // LSB
}
return '2' // USB
case "LSB":
return '1'
case "USB":
return '2'
case "CW":
return '3'
case "FM":
return '4'
case "AM":
return '5'
case "RTTY":
return '6'
case "":
return 0
default:
// Everything else is a digital sub-mode (FT8, FT4, PSK31, JS8…). They all
// ride on the rig's DATA mode; the sideband follows the same convention.
if freqHz > 0 && freqHz < 10_000_000 {
return '8' // DATA-LSB
}
return 'C' // DATA-USB
}
}
// yaesuStateDigit returns the STATE digit of a reply — the FIRST digit after
// the command — or 0 if the reply does not belong to this command.
//
// The parameter is not always one character: an FTDX101 answers "FR01;" where an
// FTDX10 answers "FR0;". The state is the FIRST digit in both; the second is a
// separate parameter. Reading the LAST digit inverted it — with RX and TX on
// MAIN the rig answered FR01, OpsLog concluded SUB, the main frequency stopped
// updating and a spot click tuned VFO B (F4NBZ, 2026-07-29). That was my own
// correction of the previous evening, made the wrong way round: the earlier
// "SUB shows MAIN" fault came from reading VS, not from this digit.
func yaesuStateDigit(reply, cmd string) byte {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, cmd) || len(r) <= len(cmd) {
return 0
}
d := r[len(cmd)]
if d < '0' || d > '9' {
return 0
}
return d
}
+290
View File
@@ -0,0 +1,290 @@
package cat
// CW keying through the Yaesu's own keyer — the KY command.
//
// This is the fifth CW engine, alongside WinKeyer, the DTR/RTS line keyer, the
// Icom CI-V keyer and FlexRadio's CWX. The point is the same in each: no extra
// hardware, no second cable. The radio holds the text and keys it with its own
// timing, which is why the character spacing is perfect where a PC keying a line
// through USB latency is not.
//
// KY; → KY0; buffer has room / KY1; buffer full
// KY <text>; queue up to 24 characters
//
// The leading SPACE after KY is part of the command, not padding.
//
// CONFIRMED on an FTDX10 (2026-07-29): it refuses this outright, answering "?;"
// to both KY; and KY <text>; — including with PC KEYING on DAKY, the obvious
// candidate, which does NOT help. That model keys from a PC only through the DTR
// line of its second (standard) COM port, which is OpsLog's "serial" engine and
// is confirmed working.
//
// The code stays because KY is documented for the FTDX101 / FT-991A / FT-710
// family. On a rig that refuses it, the error now names the route that works.
//
// STOP is the other uncertain half: Yaesu documents no buffer-clear, see StopCW.
import (
"errors"
"fmt"
"strings"
"time"
)
// yaesuCWChunk is the most characters one KY command accepts. Longer text is
// split and fed as the rig drains its buffer.
const yaesuCWChunk = 24
// cwStatusLogged makes the KY status reply appear in the log once per run: it
// is a line we have no verified sample of, so the first one is worth keeping.
var cwStatusLogged bool
// yaesuCWAllowed is what the rig's keyer can actually send. Anything else is
// dropped rather than passed through: an unsupported byte can abort the whole
// buffer, losing the rest of the message with no error anywhere.
const yaesuCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
// SendCW queues a message on the rig's keyer.
//
// Text is filtered, upper-cased and fed in 24-character pieces, waiting for room
// between pieces. Without that wait a long macro would silently lose its tail:
// the rig drops what does not fit rather than reporting an error.
func (y *Yaesu) SendCW(text string) error {
msg := filterYaesuCW(text)
if msg == "" {
return nil
}
for len(msg) > 0 {
n := yaesuCWChunk
if len(msg) < n {
n = len(msg)
}
chunk := msg[:n]
msg = msg[n:]
if err := y.waitCWBuffer(4 * time.Second); err != nil {
return err
}
y.mu.Lock()
err := y.write("KY " + chunk + ";")
if err == nil {
// KY produces no reply when it is accepted — but a rig that REJECTS it
// answers "?;", and that frame then sat in the buffer until the poll
// loop's next query picked it up, failed, and the Manager tore the link
// down. That is the CAT dropping for a few seconds on every macro click.
//
// Reading here does two things: it keeps the stray frame off the poll
// loop, and it turns a silent non-transmission into a stated reason.
err = y.drainCWReply()
}
y.mu.Unlock()
if err != nil {
return err
}
// When the rig will not tell us how full its buffer is (an FTDX10 answers
// "?;" to KY;), pace the next piece by how long this one takes to key.
// Sending everything at once overruns the 24-character buffer and the rig
// silently drops the rest.
if len(msg) > 0 && y.cwStatusUnsupported() {
time.Sleep(yaesuCWDuration(chunk, y.keyerWPM()))
}
}
return nil
}
// drainCWReply reads for a moment after a KY write.
//
// An accepted KY says nothing at all, so silence here is success. A rejection
// answers "?;" — and left unread, that frame was picked up by the poll loop's
// next query, which failed to parse it and took the whole CAT link down with it.
// Reading it here keeps the link up AND turns "nothing was transmitted, no idea
// why" into a stated reason.
//
// The caller holds the mutex.
func (y *Yaesu) drainCWReply() error {
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
deadline := time.Now().Add(200 * time.Millisecond)
buf := make([]byte, 0, 32)
tmp := make([]byte, 32)
for time.Now().Before(deadline) {
n, err := y.port.Read(tmp)
if err != nil {
return nil // a read problem here is the poll loop's business, not ours
}
if n == 0 {
continue
}
buf = append(buf, tmp[:n]...)
for {
i := strings.IndexByte(string(buf), ';')
if i < 0 {
break
}
frame := strings.TrimSpace(string(buf[:i+1]))
buf = buf[i+1:]
if frame == "?;" {
// An FTDX10 refuses KY whatever PC KEYING is set to — DAKY included,
// tested on the radio 2026-07-29. The message therefore points
// straight at what works, rather than at a setting that will not
// help.
return fmt.Errorf("this radio does not accept CW over CAT (it answered \"?;\" to KY). " +
"Switch the keyer engine to \"Serial port (DTR=CW / RTS=PTT)\" on the rig's OTHER " +
"COM port — the standard one, CAT keeping the enhanced one — with PC KEYING set " +
"to DTR in the rig's CW menu")
}
debugLog.Printf("yaesu cw: rig answered %q to KY — ignoring", frame)
}
}
return nil // silence = accepted
}
// cwStatusUnsupported reports whether this rig refused the KY status query.
func (y *Yaesu) cwStatusUnsupported() bool {
y.mu.Lock()
defer y.mu.Unlock()
return y.unsupported["KY;"]
}
// keyerWPM is the speed the rig is keying at, for pacing. Falls back to a
// middling 20 wpm when the rig does not report it: too slow only wastes a
// moment, too fast overruns the buffer.
func (y *Yaesu) keyerWPM() int {
y.mu.Lock()
defer y.mu.Unlock()
if y.panel.KeySpeed >= 4 {
return y.panel.KeySpeed
}
return 20
}
// yaesuCWDuration estimates how long a piece of text takes to key.
//
// PARIS timing: one word is 50 dit-lengths and a word is 5 characters, so a
// character averages 10 dits and a dit is 1.2/wpm seconds.
func yaesuCWDuration(text string, wpm int) time.Duration {
if wpm < 4 {
wpm = 20
}
ditMs := 1200.0 / float64(wpm)
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
}
// StopCW aborts the message being sent.
//
// Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0
// takes the rig out of transmit, which is what an operator pressing Escape
// actually wants. Anything still queued is not keyed on the air.
//
// It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig
// would read as the CHARACTER zero and dutifully send. A wrong guess here
// transmits, which is worse than an imperfect abort.
func (y *Yaesu) StopCW() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
return y.write("TX0;")
}
// waitCWBuffer blocks until the keyer reports room, or the deadline passes.
// A rig that never answers the status query is not a reason to refuse to send —
// we go ahead once, and the worst case is the truncation we were avoiding.
func (y *Yaesu) waitCWBuffer(within time.Duration) error {
deadline := time.Now().Add(within)
for {
y.mu.Lock()
if y.port == nil {
y.mu.Unlock()
return fmt.Errorf("yaesu: not connected")
}
if y.unsupported["KY;"] {
y.mu.Unlock()
return nil // this rig does not report buffer state — pacing covers it
}
r, err := y.ask("KY;")
if err != nil {
if errors.Is(err, errYaesuUnsupported) {
if y.unsupported == nil {
y.unsupported = map[string]bool{}
}
y.unsupported["KY;"] = true
debugLog.Printf("yaesu cw: this rig does not answer KY; — pacing the send by keying time instead")
} else {
debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err)
}
y.mu.Unlock()
return nil
}
y.mu.Unlock()
// Only an explicit "full" holds us back.
//
// The first version required the reply to be exactly "KY0;" at byte 2, and
// on a real FTDX10 that refused to send at all: anything the rig phrases
// differently — a space before the digit, a longer status — read as "still
// full" and the send died after four seconds having keyed nothing. The rig
// is the one that knows; when its answer is not a clear "1", the right move
// is to go ahead rather than to sit and time out.
// Log the shape once per session: this is a reply we have no verified
// sample of, and the log is what turns the next surprise into a fact.
if !cwStatusLogged {
cwStatusLogged = true
debugLog.Printf("yaesu cw: KY status reply is %q (full=%v)", r, yaesuCWBufferFull(r))
}
if !yaesuCWBufferFull(r) {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("yaesu: the keyer buffer stayed full for %s (last reply %q)", within, r)
}
time.Sleep(50 * time.Millisecond)
}
}
// yaesuCWBufferFull reads the KY status reply.
//
// Deliberately asymmetric: only a clear "1" means full. Anything else — an
// unexpected shape, a reply from another command that arrived first, an empty
// string — is treated as "go ahead". Refusing to send because a status line was
// phrased unexpectedly is the worse failure: the operator gets silence with no
// explanation, where at worst sending early truncates a long message.
func yaesuCWBufferFull(reply string) bool {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
return false
}
for _, c := range r[2:] {
switch c {
case '0':
return false
case '1':
return true
case ' ', ';':
continue
default:
return false // not a status digit — don't block on it
}
}
return false
}
// filterYaesuCW upper-cases and strips what the keyer cannot send.
func filterYaesuCW(text string) string {
var b strings.Builder
for _, r := range strings.ToUpper(text) {
// Any whitespace becomes a word gap FIRST. Dropping tabs and newlines as
// "not in the allowed set" glued the words either side together: a macro
// written on two lines went out as CQCQ.
if r == '\t' || r == '\n' || r == '\r' {
b.WriteByte(' ')
continue
}
if strings.ContainsRune(yaesuCWAllowed, r) {
b.WriteRune(r)
}
}
// Runs of spaces become one: the rig sends each as a word gap, so three in a
// row is three gaps and the message crawls.
return strings.Join(strings.Fields(b.String()), " ")
}
+122
View File
@@ -0,0 +1,122 @@
package cat
import (
"strings"
"testing"
"time"
)
// What reaches the rig's keyer.
//
// An unsupported byte does not produce an error on a Yaesu — it can abort the
// whole buffer, so the rest of the message is lost silently. Filtering is
// therefore part of correctness, not tidiness.
func TestFilterYaesuCW(t *testing.T) {
cases := []struct{ in, want string }{
{"cq cq de f4bpo", "CQ CQ DE F4BPO"},
{"F4BPO/P", "F4BPO/P"},
{"599 tu 73", "599 TU 73"},
// Accents and symbols the keyer has no code for.
{"héllo", "HLLO"},
{"a*b#c", "ABC"},
// Runs of whitespace collapse: the rig sends each space as a word gap, so
// three in a row is three gaps and the message crawls.
{"cq cq", "CQ CQ"},
{" padded ", "PADDED"},
{"\tcq\ncq\t", "CQ CQ"},
{"", ""},
{" ", ""},
}
for _, c := range cases {
if got := filterYaesuCW(c.in); got != c.want {
t.Errorf("filterYaesuCW(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// Long macros have to be split: one KY command carries 24 characters and the rig
// DROPS what does not fit rather than reporting an error, so a contest CQ would
// lose its tail with no sign anywhere.
func TestYaesuCWChunking(t *testing.T) {
msg := filterYaesuCW("cq cq cq de f4bpo f4bpo f4bpo pse k")
if len(msg) <= yaesuCWChunk {
t.Fatalf("test message is %d chars — too short to exercise chunking", len(msg))
}
var chunks []string
for rest := msg; len(rest) > 0; {
n := yaesuCWChunk
if len(rest) < n {
n = len(rest)
}
chunks = append(chunks, rest[:n])
rest = rest[n:]
}
for i, c := range chunks {
if len(c) > yaesuCWChunk {
t.Errorf("chunk %d is %d chars, over the %d limit", i, len(c), yaesuCWChunk)
}
}
// Nothing added, nothing lost: the message the operator typed is what the
// keyer receives.
if joined := strings.Join(chunks, ""); joined != msg {
t.Errorf("chunks rejoin to %q, want %q", joined, msg)
}
}
// Reading the KY status reply.
//
// The first version demanded exactly "KY0;" with the digit at byte 2, and on a
// real FTDX10 that refused to send at all: the reply was phrased differently,
// every poll read as "still full", and after four seconds the send gave up
// having keyed nothing. Hence the asymmetry — only a clear "1" blocks.
func TestYaesuCWBufferFull(t *testing.T) {
cases := []struct {
reply string
full bool
}{
{"KY0;", false},
{"KY1;", true},
{"KY 0;", false}, // a space before the digit
{"KY 1;", true},
{"ky1;", true}, // lower case
{" KY0; ", false},
// Shapes we have no sample of must NOT block: silence with no explanation
// is a worse failure than sending a moment early.
{"KY;", false},
{"", false},
{"FA014074000;", false}, // another command's reply arriving first
{"KYX;", false},
}
for _, c := range cases {
if got := yaesuCWBufferFull(c.reply); got != c.full {
t.Errorf("yaesuCWBufferFull(%q) = %v, want %v", c.reply, got, c.full)
}
}
}
// Pacing when the rig will not report its buffer.
//
// An FTDX10 answers "?;" to KY;, so there is nothing to wait on and the send has
// to be spaced by how long the text takes to key — otherwise the second piece
// overruns the 24-character buffer and the rig drops it silently.
func TestYaesuCWDuration(t *testing.T) {
// PARIS: at 20 wpm a dit is 60 ms and a character averages 10 dits, so 24
// characters take about 14.4 s.
got := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 20)
if got < 13*time.Second || got > 16*time.Second {
t.Errorf("24 chars at 20 wpm = %s, expected about 14.4s", got)
}
// Twice the speed, half the time.
fast := yaesuCWDuration("ABCDEFGHIJKLMNOPQRSTUVWX", 40)
if fast >= got || fast < got/3 {
t.Errorf("40 wpm = %s vs 20 wpm = %s — should be about half", fast, got)
}
// A nonsense speed must not produce a zero wait (send everything at once) nor
// an absurd one (the operator waiting minutes).
if d := yaesuCWDuration("TEST", 0); d <= 0 || d > 10*time.Second {
t.Errorf("4 chars at an unknown speed = %s, want a sane fallback", d)
}
if d := yaesuCWDuration("", 20); d != 0 {
t.Errorf("empty text = %s, want 0", d)
}
}
+837
View File
@@ -0,0 +1,837 @@
package cat
// The Yaesu control panel: meters and the settings an operator reaches for
// mid-QSO. Split out from yaesu.go, which stays the small fast path the whole
// application depends on — a panel read that hangs must never delay the
// frequency display.
//
// Reads are STAGGERED. Meters change constantly and are polled every cycle;
// settings (power, gains, AGC, filters) only change when someone turns a knob,
// so they are refreshed every 8th cycle and immediately after any set. At the
// default 250 ms cycle that is a two-second worst case for a knob turned on the
// radio, against a serial link that would otherwise carry twenty queries a
// second and starve the frequency poll it shares.
//
// Verified on: FTDX10. Commands come from its CAT reference; a control a model
// does not implement simply never answers, and askNum keeps the previous value
// rather than showing a zero that reads as "it reset itself".
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// YaesuTXState is the panel snapshot handed to the frontend.
type YaesuTXState struct {
Available bool `json:"available"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
// RawMode is the rig mode with its sideband (CW-U, DATA-L…), which ADIF
// deliberately does not record but the panel has to show.
RawMode string `json:"raw_mode,omitempty"`
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
// SplitTXHz is where the rig will TRANSMIT while split — the panel showed a
// lit SPLIT button and nothing else, which does not tell an operator whether
// they are up 1 or up 5.
SplitTXHz int64 `json:"split_tx_hz"`
SMeter int `json:"s_meter"` // 0-100 (raw 0-255)
PowerMeter int `json:"power_meter"` // 0-100, TX only
SWRMeter int `json:"swr_meter"` // 0-100, TX only
RFPower int `json:"rf_power"` // watts
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
Att int `json:"att"` // 0=off, else dB
NB bool `json:"nb"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"` // 1-15
Narrow bool `json:"narrow"` // NAR filter
// SWR is the RATIO (1.0, 1.5…), computed from the reflection coefficient —
// what an operator reads on the rig, not a percentage of meter travel.
SWR float64 `json:"swr"`
// PowerW is the output in WATTS, taken from the METER rather than from the
// power setting — the setting says what was asked for, not what came out.
PowerW float64 `json:"power_w"`
VOX bool `json:"vox"`
// CW-only controls. Read (and shown) only in CW, where MIC and VOX mean
// nothing and these are what an operator reaches for.
KeySpeed int `json:"key_speed"` // WPM
BreakIn bool `json:"break_in"`
}
// YaesuController is the typed escape the Manager exposes for the panel, in the
// same shape as FlexController and IcomController.
type YaesuController interface {
YaesuState() YaesuTXState
RefreshYaesu() error
SetYaesuPower(int) error
SetYaesuMicGain(int) error
SetYaesuAFGain(int) error
SetYaesuRFGain(int) error
SetYaesuSquelch(int) error
SetYaesuAGC(string) error
SetYaesuPreamp(int) error
SetYaesuAtt(int) error
SetYaesuNB(bool) error
SetYaesuNR(bool) error
SetYaesuNRLevel(int) error
SetYaesuNarrow(bool) error
SetYaesuVOX(bool) error
SetYaesuKeySpeed(int) error
SetYaesuBreakIn(bool) error
YaesuZeroIn() error
// CW keying through the rig's own keyer (KY) — the fifth CW engine.
SendCW(string) error
StopCW() error
SetYaesuSplit(bool) error
SetYaesuSplitOffset(int64) error
SetYaesuBand(string) error
SetYaesuModeRaw(string) error
TuneYaesuATU() error
}
func (y *Yaesu) YaesuState() YaesuTXState {
y.mu.Lock()
defer y.mu.Unlock()
st := y.panel
st.Available = y.port != nil
st.Model = y.model
return st
}
// readPanel refreshes the meters, and the settings on the slower beat. Called
// from ReadState with the mutex HELD, so it shares the same serialised link.
func (y *Yaesu) readPanel(mode string, split bool, txHz int64) {
y.panel.Mode = mode
y.panel.Split = split
y.panel.SplitTXHz = 0
if split {
y.panel.SplitTXHz = txHz
}
// TX state first: which meters mean anything depends on it, and a power
// reading shown while receiving is how a panel lies.
if r, err := y.ask("TX;"); err == nil && len(r) >= 3 {
y.panel.Transmitting = r[2] != '0'
}
if v, ok := y.askNum("SM0;", "SM0", 3); ok {
y.panel.SMeter = scale255(v)
}
if y.panel.Transmitting {
// One-shot survey of every meter while transmitting.
//
// Which RM index carries which meter is NOT the same across the family, and
// an operator reported an SWR bar at 80 with a real SWR of 1.1 — the shape
// of reading the wrong index (ALC, say) rather than of a scaling error.
// Guessing again would just move the wrong number; this prints all six
// once, and the log then says which is which on THIS radio.
// Sampled on EVERY poll while transmitting, capped — one snapshot taken as
// the transmission starts catches the meters still at rest (RM4=13, the
// rest zero), which says nothing. What identifies a meter is which index
// TRACKS the power over a few seconds of steady carrier.
if y.metersLogged < 40 {
y.metersLogged++
raw := make([]string, 0, 6)
for i := 1; i <= 6; i++ {
cmd := fmt.Sprintf("RM%d;", i)
if v, ok := y.askNum(cmd, fmt.Sprintf("RM%d", i), 3); ok {
raw = append(raw, fmt.Sprintf("RM%d=%d", i, v))
} else {
raw = append(raw, fmt.Sprintf("RM%d=-", i))
}
}
// The POWER SETTING goes on the same line. Which index is the wattmeter
// cannot be read off one transmission — RM4 rose while RM5 sat still,
// but RM5 differed BETWEEN transmissions (208 then 105), so both are
// candidates. What settles it is transmitting at two different power
// settings and seeing which index follows: printing the setting here
// makes that a one-line comparison instead of a memory exercise.
debugLog.Printf("yaesu: meters at PC=%dW: %s (compare two different power settings)",
y.panel.RFPower, strings.Join(raw, " "))
}
// Measured on an FTDX10 (2026-07-29). The FM carrier was inconclusive —
// constant by definition — so the answer came from CW at 100 W, where the
// keying itself is the experiment:
//
// key down: RM4=25 RM5=207 RM6=13
// key up: RM4=25 RM5=0 RM6=0
//
// RM5 follows the RF envelope exactly, so RM5 is the POWER meter. RM4 sits
// near 25 whether the key is down or up, so it is not measuring output at
// all — reading it as power is what showed 8 W on a 100 W transmission.
// Peak-hold. In CW the key is up between elements and the meters genuinely
// read 0 there — the log shows RM5 alternating 207, 0, 207 — so following
// the raw value makes the bars flicker to nothing several times a second
// and the number unreadable. A real meter has needle inertia; this is the
// same idea, and it only ever holds a value the radio actually reported.
now := time.Now()
if v, ok := y.askNum("RM5;", "RM5", 3); ok {
y.panel.PowerMeter = y.powerPeak.update(scale255(v), now)
y.panel.PowerW = float64(y.powerWPeak.update(int(yaesuWatts(v)+0.5), now))
}
// SWR is RM6, and a second measurement at a KNOWN mismatch settled both the
// index and the scale: 0 at SWR 1.1, then 52 at SWR 1.5. 52/255 = 0.204,
// which is the reflection coefficient of a 1.5 SWR to three decimals — so
// the raw value is rho scaled to 255, and the ratio follows from physics
// rather than from a fitted curve.
if v, ok := y.askNum("RM6;", "RM6", 3); ok {
y.panel.SWRMeter = y.swrPeak.update(scale255(v), now)
// The RATIO is only meaningful while power is going out: between words
// the reading is 0, which would display as a perfect 1.0 match — worse
// than a stale figure, because it looks like good news.
if v > 0 {
y.panel.SWR = swrFromReflection(v)
}
}
} else {
// Zeroed rather than frozen: a stale reading from the last transmission
// reads as a live one.
//
// EVERY transmit value has to be cleared here, and the peak-hold state with
// them. Clearing only the two bar percentages left the watts and the SWR
// ratio standing — the PWR bar stayed at full scale after the operator
// stopped transmitting — and a peak left in the holder would have carried
// the old reading into the next transmission.
y.panel.PowerMeter, y.panel.SWRMeter = 0, 0
y.panel.PowerW, y.panel.SWR = 0, 0
y.powerPeak, y.powerWPeak, y.swrPeak = meterPeak{}, meterPeak{}, meterPeak{}
}
y.panelCycle++
if y.panelLoaded && y.panelCycle < 8 {
return
}
y.panelCycle = 0
y.panelLoaded = true
y.readPanelSettings()
}
// readPanelSettings re-reads everything a knob can change. Separate so a set can
// force it without waiting for the slow beat.
func (y *Yaesu) readPanelSettings() {
if v, ok := y.askNum("PC;", "PC", 3); ok {
y.panel.RFPower = v // watts, not a 0-255 scale
}
if v, ok := y.askNum("MG;", "MG", 3); ok {
y.panel.MicGain = scale255(v)
}
if v, ok := y.askNum("AG0;", "AG0", 3); ok {
y.panel.AFGain = scale255(v)
}
if v, ok := y.askNum("RG0;", "RG0", 3); ok {
y.panel.RFGain = scale255(v)
}
if v, ok := y.askNum("SQ0;", "SQ0", 3); ok {
y.panel.Squelch = scale255(v)
}
if v, ok := y.askNum("GT0;", "GT0", 1); ok {
y.panel.AGC = yaesuAGCName(v)
}
if v, ok := y.askNum("PA0;", "PA0", 1); ok {
y.panel.Preamp = v
}
if v, ok := y.askNum("RA0;", "RA0", 1); ok {
y.panel.Att = yaesuAttDB(v)
}
if v, ok := y.askNum("NB0;", "NB0", 1); ok {
y.panel.NB = v != 0
}
if v, ok := y.askNum("NR0;", "NR0", 1); ok {
y.panel.NR = v != 0
}
if v, ok := y.askNum("RL0;", "RL0", 2); ok {
y.panel.NRLevel = v
}
if v, ok := y.askNum("NA0;", "NA0", 1); ok {
y.panel.Narrow = v != 0
}
if v, ok := y.askNum("VX;", "VX", 1); ok {
y.panel.VOX = v != 0
}
// CW keyer. Read unconditionally — it is two more queries on the SLOW beat,
// and having the value ready means the CW card is populated the instant the
// operator switches mode rather than a poll cycle later.
if v, ok := y.askNum("KS;", "KS", 3); ok {
y.panel.KeySpeed = v
}
if v, ok := y.askNum("BI;", "BI", 1); ok {
y.panel.BreakIn = v != 0
}
}
// askNum sends a query and reads a fixed-width decimal field out of the reply.
// ok=false when the rig does not answer, or answers something else — a control
// this model lacks then keeps its previous value instead of dropping to zero,
// which would look like a setting that reset itself.
func (y *Yaesu) askNum(cmd, prefix string, digits int) (int, bool) {
// A command this model refused once is never asked again. Models implement
// different subsets — an FTDX10 answers "?;" to MG; — and re-asking costs a
// 600 ms timeout on every slow beat, for a control that will never answer.
if y.unsupported[cmd] {
return 0, false
}
r, err := y.ask(cmd)
if err != nil {
if errors.Is(err, errYaesuUnsupported) {
if y.unsupported == nil {
y.unsupported = map[string]bool{}
}
y.unsupported[cmd] = true
debugLog.Printf("yaesu: this rig does not support %q — not asking again", cmd)
}
return 0, false
}
if !strings.HasPrefix(r, prefix) || len(r) < len(prefix)+digits {
debugLog.Printf("yaesu: unexpected reply %q to %q", r, cmd)
return 0, false
}
n, err := strconv.Atoi(r[len(prefix) : len(prefix)+digits])
if err != nil {
return 0, false
}
return n, true
}
// setAndRefresh writes a command then re-reads the settings, so the panel shows
// what the RIG ended up with rather than what we asked for — the two differ
// whenever a value is out of range or the current mode forbids the control.
func (y *Yaesu) setAndRefresh(cmd string) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
if err := y.write(cmd); err != nil {
return err
}
time.Sleep(30 * time.Millisecond) // let the rig apply it before reading back
y.readPanelSettings()
return nil
}
func (y *Yaesu) RefreshYaesu() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
y.readPanelSettings()
return nil
}
func (y *Yaesu) SetYaesuPower(w int) error {
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
}
func (y *Yaesu) SetYaesuMicGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("MG%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuAFGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("AG0%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuRFGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("RG0%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuSquelch(p int) error {
return y.setAndRefresh(fmt.Sprintf("SQ0%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuAGC(name string) error {
return y.setAndRefresh(fmt.Sprintf("GT0%d;", yaesuAGCCode(name)))
}
func (y *Yaesu) SetYaesuPreamp(n int) error {
return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2)))
}
func (y *Yaesu) SetYaesuAtt(db int) error {
return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
}
func (y *Yaesu) SetYaesuNB(on bool) error {
return y.setAndRefresh(fmt.Sprintf("NB0%d;", boolDigit(on)))
}
func (y *Yaesu) SetYaesuNR(on bool) error {
return y.setAndRefresh(fmt.Sprintf("NR0%d;", boolDigit(on)))
}
func (y *Yaesu) SetYaesuNRLevel(n int) error {
return y.setAndRefresh(fmt.Sprintf("RL0%02d;", clampInt(n, 1, 15)))
}
func (y *Yaesu) SetYaesuNarrow(on bool) error {
return y.setAndRefresh(fmt.Sprintf("NA0%d;", boolDigit(on)))
}
func (y *Yaesu) SetYaesuVOX(on bool) error {
return y.setAndRefresh(fmt.Sprintf("VX%d;", boolDigit(on)))
}
// SetYaesuSplit uses whichever command this rig answered at connect. Sending the
// other one would be silently ignored, and the operator would get a split button
// that does nothing.
func (y *Yaesu) SetYaesuSplit(on bool) error {
// Turning split ON also PLACES the transmit VFO. Flipping the flag alone
// transmits wherever the other VFO happens to sit — it held 18.115 from an
// earlier session while the operator was listening on 14.244, so pressing
// SPLIT threw them onto another band entirely. The other VFO is stale by
// nature; the only frequency that makes sense is one derived from where the
// operator is listening NOW.
//
// The distance is the mode's usual one: 1 kHz on CW and the data modes,
// 5 kHz on phone. The +1k / +5k buttons remain for anything else.
if on {
return y.SetYaesuSplitOffset(y.defaultSplitOffset())
}
y.mu.Lock()
cmd, vfo := y.splitCmd, y.curVFO
y.mu.Unlock()
if cmd == "" {
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
}
return y.setAndRefresh(yaesuSplitCommand(cmd, vfo, false))
}
// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
// data modes, up 5 kHz on phone — the offsets operators actually call.
func (y *Yaesu) defaultSplitOffset() int64 {
y.mu.Lock()
raw := strings.ToUpper(y.panel.RawMode)
y.mu.Unlock()
switch {
case strings.HasPrefix(raw, "CW"), strings.HasPrefix(raw, "RTTY"), strings.HasPrefix(raw, "DATA"):
return 1000
}
return 5000
}
// SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used
// frequency for that band — the radio's band memory, not a frequency we choose.
// SetYaesuModeRaw selects an exact rig mode, sideband included — "CW-U",
// "DATA-L", "RTTY-U"… The plain SetMode path takes an ADIF mode and can only
// choose a sideband by convention, but CW, RTTY and the data modes are routinely
// run on EITHER sideband and the operator is the one who knows which. This is
// what the panel's mode buttons use.
func (y *Yaesu) SetYaesuModeRaw(name string) error {
d, ok := yaesuRawModeDigit(name)
if !ok {
return fmt.Errorf("yaesu: unknown rig mode %q", name)
}
// 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.
func yaesuRawModeDigit(name string) (byte, bool) {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "LSB":
return '1', true
case "USB":
return '2', true
case "CW-U", "CWU":
return '3', true
case "CW-L", "CWL":
return '7', true
case "FM":
return '4', true
case "AM":
return '5', true
case "RTTY-L", "RTTYL":
return '6', true
case "RTTY-U", "RTTYU":
return '9', true
case "DATA-L", "DATAL", "DIGI-L":
return '8', true
case "DATA-U", "DATAU", "DIGI-U":
return 'C', true
}
return 0, false
}
func (y *Yaesu) SetYaesuBand(band string) error {
code, ok := yaesuBandCode(band)
if !ok {
return fmt.Errorf("yaesu: no band code for %q", band)
}
return y.setAndRefresh(fmt.Sprintf("BS%02d;", code))
}
// TuneYaesuATU starts a tuning cycle. Deliberately not followed by a settings
// read: the rig is transmitting into the tuner for several seconds and answers
// little, so the reads would just time out one after another.
func (y *Yaesu) TuneYaesuATU() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
return y.write("AC002;")
}
// ── small mappings ────────────────────────────────────────────────────────
func scale255(v int) int {
if v <= 0 {
return 0
}
if v >= 255 {
return 100
}
return v * 100 / 255
}
func from100(p int) int { return clampInt(p, 0, 100) * 255 / 100 }
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func boolDigit(b bool) int {
if b {
return 1
}
return 0
}
func yaesuAGCName(code int) string {
switch code {
case 0:
return "OFF"
case 1:
return "FAST"
case 2:
return "MID"
case 3:
return "SLOW"
case 4:
return "AUTO"
}
return ""
}
func yaesuAGCCode(name string) int {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "OFF":
return 0
case "FAST":
return 1
case "MID", "MEDIUM":
return 2
case "SLOW":
return 3
}
return 4 // AUTO — the safe default for anything unrecognised
}
// The FTDX10/FTDX101 attenuator is a THREE-step pad (RA00..RA03 = off, 6, 12,
// 18 dB), not the single step this first assumed — a panel offering only one
// step hides two thirds of the control. Reporting the dB rather than the raw
// code lets the buttons label themselves honestly.
func yaesuAttDB(code int) int {
switch code {
case 1:
return 6
case 2:
return 12
case 3:
return 18
}
return 0
}
func yaesuAttCode(db int) int {
switch {
case db >= 18:
return 3
case db >= 12:
return 2
case db >= 6:
return 1
}
return 0
}
// yaesuBandCode maps an ADIF band to the BS command's band number.
func yaesuBandCode(band string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 0, true
case "80m":
return 1, true
case "60m":
return 2, true
case "40m":
return 3, true
case "30m":
return 4, true
case "20m":
return 5, true
case "17m":
return 6, true
case "15m":
return 7, true
case "12m":
return 8, true
case "10m":
return 9, true
case "6m":
return 10, true
}
return 0, false
}
// yaesuRawModeName is the inverse of yaesuRawModeDigit: what the rig is on,
// sideband included, for the panel's mode buttons to highlight.
func yaesuRawModeName(d byte) string {
switch d {
case '1':
return "LSB"
case '2':
return "USB"
case '3':
return "CW-U"
case '4':
return "FM"
case '5':
return "AM"
case '6':
return "RTTY-L"
case '7':
return "CW-L"
case '8':
return "DATA-L"
case '9':
return "RTTY-U"
case 'C':
return "DATA-U"
}
return ""
}
// SetYaesuSplitOffset puts the transmit VFO a fixed distance above the receive
// one and turns split on, in a single action.
//
// This is the split an operator actually uses when working a pile-up: listen on
// the DX, transmit up 5 kHz on phone or up 1 kHz on CW. Doing it by hand means
// swapping VFOs, retuning and swapping back, which is exactly the fumbling a
// panel should remove.
//
// The offset is applied to the RECEIVE frequency and written to the OTHER VFO —
// whichever that is. On VFO B the roles are mirrored, so listening on B writes A.
func (y *Yaesu) SetYaesuSplitOffset(offsetHz int64) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
rx := y.curRXFreq
if rx <= 0 {
return fmt.Errorf("yaesu: no receive frequency read yet")
}
tx := rx + offsetHz
if tx <= 0 || tx > 999_999_999 {
return fmt.Errorf("yaesu: split frequency %d out of the CAT range", tx)
}
// Write the VFO we are NOT listening on.
cmd := "FB"
if y.curVFO == "B" {
cmd = "FA"
}
if err := y.write(fmt.Sprintf("%s%09d;", cmd, tx)); err != nil {
return err
}
if y.splitCmd == "" {
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
}
time.Sleep(30 * time.Millisecond)
if err := y.write(yaesuSplitCommand(y.splitCmd, y.curVFO, true)); err != nil {
return err
}
y.panel.Split = true
y.panel.SplitTXHz = tx
return nil
}
// SetYaesuKeySpeed sets the internal keyer speed in words per minute. The rig
// clamps to its own 4-60 range; clamping here too keeps a slider from sending a
// value that would simply be ignored, which reads as a dead control.
func (y *Yaesu) SetYaesuKeySpeed(wpm int) error {
return y.setAndRefresh(fmt.Sprintf("KS%03d;", clampInt(wpm, 4, 60)))
}
// SetYaesuBreakIn toggles CW break-in (BK).
func (y *Yaesu) SetYaesuBreakIn(on bool) error {
return y.setAndRefresh(fmt.Sprintf("BI%d;", boolDigit(on)))
}
// YaesuZeroIn is the CW ZIN function: the rig retunes itself so the station
// being received lands exactly on the operator's CW pitch. It is a one-shot
// action with no state to read back, so no settings refresh follows — and the
// frequency change arrives through the normal poll like any other.
func (y *Yaesu) YaesuZeroIn() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
return y.write("ZI;")
}
// swrFromReflection turns the rig's SWR meter reading into the ratio itself.
//
// The raw value is the reflection coefficient scaled to 255 — established on an
// FTDX10 by measuring at two known matches (0 at 1.1, 52 at 1.5; 52/255 = 0.204,
// which is rho for a 1.5 SWR). So SWR = (1+rho)/(1-rho), physics rather than a
// curve fitted to two points.
//
// Capped at 9.9: past that the number stops meaning anything to an operator, and
// rho approaching 1 sends the ratio to infinity.
func swrFromReflection(raw int) float64 {
if raw <= 0 {
return 1.0
}
rho := float64(raw) / 255.0
if rho >= 0.98 {
return 9.9
}
swr := (1 + rho) / (1 - rho)
if swr > 9.9 {
return 9.9
}
return swr
}
// yaesuWatts converts the power-meter reading to watts.
//
// The meter is NOT linear in power, which one calibration point could never
// reveal: scaling 207 = 100 W straight down read 30 W where the rig showed 10,
// and 75 where it showed 50. Three points measured against the radio's own
// display settle the curve:
//
// raw 62 → 10 W
// raw 155 → 50 W
// raw 207 → 100 W
//
// Interpolating between them reproduces the rig exactly at those points and
// stays close in between — better than fitting a formula to three samples and
// pretending it holds everywhere. Above the last point it keeps the final
// segment's slope, so an amplifier-driving rig does not flatten at 100 W.
//
// Measured on an FTDX10 (2026-07-29). Another model may well need its own row.
var yaesuPowerCurve = []struct {
raw int
watts float64
}{
{0, 0},
{62, 10},
{155, 50},
{207, 100},
}
func yaesuWatts(raw int) float64 {
if raw <= 0 {
return 0
}
for i := 1; i < len(yaesuPowerCurve); i++ {
hi := yaesuPowerCurve[i]
if raw <= hi.raw {
lo := yaesuPowerCurve[i-1]
span := float64(hi.raw - lo.raw)
if span <= 0 {
return hi.watts
}
f := float64(raw-lo.raw) / span
return lo.watts + f*(hi.watts-lo.watts)
}
}
// Past the top of the curve: extend the last segment rather than clamp.
n := len(yaesuPowerCurve)
lo, hi := yaesuPowerCurve[n-2], yaesuPowerCurve[n-1]
slope := (hi.watts - lo.watts) / float64(hi.raw-lo.raw)
return hi.watts + float64(raw-hi.raw)*slope
}
// meterPeak gives a meter the inertia a needle has: it jumps to a higher reading
// at once, HOLDS it for a moment, then falls back gradually.
//
// The hold is the part that matters on the air. Between CW elements the gap is
// milliseconds, but between the words of a CQ — in CW as in SSB — it is most of
// a second, and a meter that decays straight away reads 0 in every one of those
// gaps: the operator sees a bar flashing rather than the power they are running.
type meterPeak struct {
val int
at time.Time
}
const (
meterHold = 1500 * time.Millisecond // how long a peak stands before it starts to fall
meterDecay = 4 // then a quarter of the remaining gap per poll
)
// update returns the value to show for this sample.
func (m *meterPeak) update(sample int, now time.Time) int {
if sample >= m.val {
m.val, m.at = sample, now
return m.val
}
if now.Sub(m.at) < meterHold {
return m.val // still inside the hold — the needle has not started to fall
}
// At least one step down, always. A proportional decay on integers stalls
// near the end: with the needle at 13 and the truth at 10, a quarter of the
// gap rounds to zero and the meter sits three units high for ever.
step := (m.val - sample) / meterDecay
if step < 1 {
step = 1
}
m.val -= step
if m.val <= sample {
m.val = sample
}
return m.val
}
// yaesuSplitCommand builds the command that turns split on or off.
//
// Writing is as asymmetric as reading. ST takes the state directly — ST1 is
// split on. FT sets which VFO TRANSMITS, so "split on" means transmit on the
// OTHER VFO from the one being listened to, and "split off" means transmit on
// the same one. Sending FT1 for "on" regardless is right only for an operator on
// VFO A; on SUB it would clear the split it was asked to set.
func yaesuSplitCommand(cmd, vfo string, on bool) string {
if cmd == "ST" {
if on {
return "ST1;"
}
return "ST0;"
}
onSub := strings.HasPrefix(strings.ToUpper(vfo), "B")
txOnB := onSub // split off = transmit where we listen
if on {
txOnB = !onSub
}
if txOnB {
return cmd + "1;"
}
return cmd + "0;"
}
+163
View File
@@ -0,0 +1,163 @@
package cat
import (
"testing"
"time"
)
// What SPLIT means when the operator presses it.
//
// Flipping the rig's split flag alone transmits wherever the OTHER VFO happens
// to sit — reported from a real FTDX10: listening on 14.244 with VFO B left on
// 18.115 from an earlier session, pressing SPLIT threw the transmitter onto
// another band. The other VFO is stale by nature, so the transmit frequency has
// to be derived from where the operator is listening now.
func TestYaesuDefaultSplitOffset(t *testing.T) {
cases := []struct {
raw string
want int64
}{
// CW and the data modes work 1 kHz up.
{"CW-U", 1000},
{"CW-L", 1000},
{"RTTY-U", 1000},
{"RTTY-L", 1000},
{"DATA-U", 1000},
{"DATA-L", 1000},
// Phone works 5 kHz up.
{"USB", 5000},
{"LSB", 5000},
{"AM", 5000},
{"FM", 5000},
// Unknown or not yet read: the phone offset is the safer default — too
// wide is audible and obvious, too narrow lands on top of the DX.
{"", 5000},
}
for _, c := range cases {
y := &Yaesu{}
y.panel.RawMode = c.raw
if got := y.defaultSplitOffset(); got != c.want {
t.Errorf("mode %q → split offset %d Hz, want %d", c.raw, got, c.want)
}
}
}
// The SWR scale, pinned to the two measurements it was derived from.
//
// Taken on an FTDX10 (2026-07-29) against an operator watching the rig's own
// meter: raw 0 at SWR 1.1, raw 52 at SWR 1.5. The second point is what proved
// the raw value is the reflection coefficient scaled to 255 — 52/255 = 0.204,
// rho for a 1.5 SWR — rather than a percentage of meter travel, which is how the
// bar came to read 81 on a perfect antenna.
func TestSWRFromReflection(t *testing.T) {
cases := []struct {
raw int
want float64
tol float64
}{
{0, 1.0, 0.01}, // no reflected power
{52, 1.5, 0.02}, // the measured mismatch
{85, 2.0, 0.05}, // rho = 1/3
{128, 3.0, 0.1}, // rho = 0.5
{-5, 1.0, 0.01}, // nonsense reading — never below 1.0, which is physical
{255, 9.9, 0.01}, // full scale is capped rather than infinite
}
for _, c := range cases {
got := swrFromReflection(c.raw)
if got < c.want-c.tol || got > c.want+c.tol {
t.Errorf("swrFromReflection(%d) = %.2f, want %.2f ±%.2f", c.raw, got, c.want, c.tol)
}
}
// It must rise with the reflected power, or a worsening match would read
// better on the panel than on the rig.
prev := 0.0
for raw := 0; raw <= 200; raw += 20 {
v := swrFromReflection(raw)
if v < prev {
t.Fatalf("SWR fell from %.2f to %.2f at raw=%d", prev, v, raw)
}
prev = v
}
}
// Needle inertia on the TX meters.
//
// The gaps are what this is for: milliseconds between CW elements, but most of a
// second between the words of a CQ — in CW as in SSB. A meter that decays
// immediately reads 0 in every one of those gaps, so the operator sees a bar
// flashing instead of the power they are running.
func TestMeterPeakHold(t *testing.T) {
var m meterPeak
t0 := time.Now()
if got := m.update(80, t0); got != 80 {
t.Fatalf("first sample = %d, want 80 — a meter must show a reading at once", got)
}
// A gap between two words: still inside the hold, so the reading stands.
if got := m.update(0, t0.Add(400*time.Millisecond)); got != 80 {
t.Errorf("during a word gap = %d, want 80 held", got)
}
if got := m.update(0, t0.Add(1400*time.Millisecond)); got != 80 {
t.Errorf("just before the hold expires = %d, want 80 held", got)
}
// Past the hold it falls — but gradually, not to zero in one step.
after := m.update(0, t0.Add(1600*time.Millisecond))
if after >= 80 || after <= 0 {
t.Errorf("after the hold = %d, want a value falling between 80 and 0", after)
}
// A HIGHER reading is taken immediately: a needle rises fast and falls slow.
if got := m.update(95, t0.Add(1700*time.Millisecond)); got != 95 {
t.Errorf("rising sample = %d, want 95 straight away", got)
}
// And it does reach the real value eventually, or a power drop would never show.
v := 0
for i := 0; i < 60; i++ {
v = m.update(10, t0.Add(time.Duration(2000+i*250)*time.Millisecond))
}
if v != 10 {
t.Errorf("settled at %d, want 10 — the meter must converge on the truth", v)
}
}
// The power curve, pinned to the readings taken against the rig's own display.
//
// It is NOT linear, and one calibration point could not show that: scaling
// 207 = 100 W straight down read 30 W where the radio showed 10, and 75 where it
// showed 50. These are the three measured pairs, so a change to the curve that
// breaks them is a regression against the radio, not against a preference.
func TestYaesuPowerCurve(t *testing.T) {
cases := []struct {
raw int
watts float64
tol float64
}{
{0, 0, 0.1},
{62, 10, 0.5}, // measured
{155, 50, 0.5}, // measured
{207, 100, 0.5}, // measured
// Between the measured points it interpolates, so it must land inside the
// bracket rather than shooting past it.
{100, 30, 10},
{180, 75, 10},
}
for _, c := range cases {
got := yaesuWatts(c.raw)
if got < c.watts-c.tol || got > c.watts+c.tol {
t.Errorf("yaesuWatts(%d) = %.1f W, want %.1f ±%.1f", c.raw, got, c.watts, c.tol)
}
}
// Monotonic: more meter must never mean less power.
prev := -1.0
for raw := 0; raw <= 255; raw++ {
v := yaesuWatts(raw)
if v < prev {
t.Fatalf("power fell from %.1f to %.1f at raw=%d", prev, v, raw)
}
prev = v
}
// Above the top of the curve it keeps rising rather than flattening at 100 W —
// a rig driving an amplifier can read past its own full scale.
if v := yaesuWatts(230); v <= 100 {
t.Errorf("yaesuWatts(230) = %.1f, want more than 100 — the curve should extend", v)
}
}
+382
View File
@@ -0,0 +1,382 @@
package cat
import "testing"
func TestParseYaesuFreq(t *testing.T) {
cases := []struct {
reply, prefix string
want int64
ok bool
}{
{"FA014074000;", "FA", 14074000, true},
{"FB007100000;", "FB", 7100000, true},
{"FA000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
{"FA010368000000;", "FA", 10368000000, true},
{"FB;", "FB", 0, false}, // query echoed back with no value
{"FA014074000;", "FB", 0, false}, // wrong VFO — never silently accepted
{"", "FA", 0, false},
{"FAxxxxxxxxx;", "FA", 0, false},
}
for _, c := range cases {
got, ok := parseYaesuFreq(c.reply, c.prefix)
if got != c.want || ok != c.ok {
t.Errorf("parseYaesuFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
}
}
}
// The split rules. Getting these wrong writes a WRONG TX frequency into the log,
// which is why an ambiguous state resolves to "not split" rather than to a
// guess — the same principle the OmniRig backend arrived at the hard way.
func TestResolveYaesuVFOs(t *testing.T) {
const a, b = 14074000, 14100000
cases := []struct {
name string
fa, fb int64
vfo string
split bool
wantTX, wantRX int64
wantSplit bool
}{
{"simplex on A", a, b, "A", false, a, 0, false},
{"simplex on B", a, b, "B", false, b, 0, false},
{"split, listening on A → TX on B", a, b, "A", true, b, a, true},
{"split, listening on B → TX on A", a, b, "B", true, a, b, true},
{"split flag but the other VFO is unread", a, 0, "A", true, a, 0, false},
{"split flag but both VFOs identical", a, a, "A", true, a, 0, false},
}
for _, c := range cases {
tx, rx, sp := resolveYaesuVFOs(c.fa, c.fb, c.vfo, c.split)
if tx != c.wantTX || rx != c.wantRX || sp != c.wantSplit {
t.Errorf("%s: got tx=%d rx=%d split=%v — want tx=%d rx=%d split=%v",
c.name, tx, rx, sp, c.wantTX, c.wantRX, c.wantSplit)
}
}
}
// ST and FT say different things, and reading them alike inverted the split
// display on an FTDX101 (F4NBZ, 2026-07-29): the panel showed split ON with the
// radio OFF and the reverse.
//
// ST is a split FLAG — ST1 means split, whatever VFO is in use.
// FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B.
//
// Split is on when the rig transmits on a DIFFERENT VFO from the one it listens
// to, so FT has to be compared with the current VFO. That is why the same model
// behaved correctly for one operator and backwards for another: one was on MAIN,
// the other on SUB.
func TestYaesuSplitReply(t *testing.T) {
cases := []struct {
reply, cmd, vfo string
want bool
}{
// The flag is absolute.
{"ST1;", "ST", "A", true},
{"ST0;", "ST", "A", false},
{"ST1;", "ST", "B", true},
{"ST0;", "ST", "B", false},
// Listening on A: transmit on B is split, transmit on A is not.
{"FT1;", "FT", "A", true},
{"FT0;", "FT", "A", false},
// Listening on B: exactly the opposite — the reported inversion.
{"FT0;", "FT", "B", true},
{"FT1;", "FT", "B", false},
// The pair enums the Yaesus also report start with the listening VFO.
{"FT0;", "FT", "BA", true},
{"FT1;", "FT", "AB", true},
{"ST1;", "FT", "A", false}, // reply for the other command — not accepted
{"ST", "ST", "A", false}, // truncated
{"", "ST", "A", false},
}
for _, c := range cases {
if got := yaesuSplitFromReply(c.reply, c.cmd, c.vfo); got != c.want {
t.Errorf("yaesuSplitFromReply(%q, %q, vfo=%q) = %v, want %v",
c.reply, c.cmd, c.vfo, got, c.want)
}
}
}
// The sideband follows the frequency, worldwide convention — a CAT backend that
// puts USB on 40 m makes every SSB QSO wrong.
func TestYaesuModeDigit(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'}, // explicit wins over the convention
{"USB", 7150000, '2'},
{"CW", 7030000, '3'},
{"RTTY", 14080000, '6'},
{"AM", 7150000, '5'},
{"FM", 145000000, '4'},
{"FT8", 7074000, '8'}, // DATA-LSB
{"FT8", 14074000, 'C'}, // DATA-USB
{"JS8", 14078000, 'C'}, // any unknown digital rides on DATA
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
}
}
}
// A reply belongs to the command that asked for it.
//
// Without this, a CW macro knocked the CAT link over: KY produces no reply, so
// the poll loop's next FA; collected a leftover frame, failed to parse it as a
// frequency, and the Manager treated that as "lost the rig" and reconnected —
// the CAT dropping for a few seconds on every macro click.
func TestYaesuCmdPrefix(t *testing.T) {
cases := []struct{ cmd, want string }{
{"FA;", "FA"},
{"FB;", "FB"},
{"MD0;", "MD"},
{"KY;", "KY"},
{"KY CQ TEST;", "KY"},
{"SM0;", "SM"},
{"RM4;", "RM"},
{"AG0;", "AG"},
{"TX;", "TX"},
{"", ""},
{";", ""},
}
for _, c := range cases {
if got := cmdPrefix(c.cmd); got != c.want {
t.Errorf("cmdPrefix(%q) = %q, want %q", c.cmd, got, c.want)
}
}
}
// Writing split is as asymmetric as reading it.
//
// ST takes the state directly. FT sets which VFO TRANSMITS, so "split on" means
// transmit on the OTHER VFO from the one being listened to. Sending FT1 for "on"
// regardless is right only on VFO A — on SUB it would CLEAR the split it was
// asked to set, which is the same inversion that showed on the FTDX101 panel.
func TestYaesuSplitCommand(t *testing.T) {
cases := []struct {
cmd, vfo string
on bool
want string
}{
{"ST", "A", true, "ST1;"},
{"ST", "B", true, "ST1;"}, // the flag does not care which VFO
{"ST", "B", false, "ST0;"},
// Listening on A: split means transmit on B.
{"FT", "A", true, "FT1;"},
{"FT", "A", false, "FT0;"},
// Listening on B: split means transmit on A — the reverse.
{"FT", "B", true, "FT0;"},
{"FT", "B", false, "FT1;"},
// Pair enums start with the listening VFO.
{"FT", "BA", true, "FT0;"},
{"FT", "AB", true, "FT1;"},
}
for _, c := range cases {
if got := yaesuSplitCommand(c.cmd, c.vfo, c.on); got != c.want {
t.Errorf("yaesuSplitCommand(%q, vfo=%q, on=%v) = %q, want %q",
c.cmd, c.vfo, c.on, got, c.want)
}
}
}
// Which VFO the operator is listening on, on a rig with separate RX and TX
// selection.
//
// Reported on an FTDX101 (2026-07-29): moving RX alone to SUB displayed VFO B
// correctly, but moving BOTH RX and TX to SUB displayed VFO A — the operator was
// entirely on B and OpsLog showed the other one. FR reports the receive VFO; VS
// does not answer that question on this rig.
//
// With FR read correctly the split follows too, since split is "transmit VFO
// differs from receive VFO".
func TestYaesuReceiveVFOAndSplit(t *testing.T) {
cases := []struct {
name string
fr, ft string // replies
wantVFO string
wantSplit bool
}{
{"everything on main", "FR0;", "FT0;", "A", false},
{"RX on sub, TX still on main — split", "FR1;", "FT0;", "B", true},
{"RX and TX both on sub — NOT split", "FR1;", "FT1;", "B", false},
{"RX on main, TX on sub — split", "FR0;", "FT1;", "A", true},
}
for _, c := range cases {
vfo := "A"
if len(c.fr) >= 3 && c.fr[2] == '1' {
vfo = "B"
}
if vfo != c.wantVFO {
t.Errorf("%s: receive VFO = %s, want %s", c.name, vfo, c.wantVFO)
}
if got := yaesuSplitFromReply(c.ft, "FT", vfo); got != c.wantSplit {
t.Errorf("%s: split = %v, want %v", c.name, got, c.wantSplit)
}
}
}
// The state digit of a reply, when the parameter is not one character.
//
// An FTDX101 answers "FR01;" where an FTDX10 answers "FR0;". The state is the
// FIRST digit on both — the second is a separate parameter.
//
// This test asserted the opposite for one evening. Reading the LAST digit turned
// "FR01" into SUB, so an operator with RX and TX on MAIN saw the main frequency
// freeze and a spot click tune VFO B (F4NBZ, 2026-07-29). The fault it was meant
// to fix — "SUB shows MAIN" — came from reading VS, not from this digit, and the
// FR probe alone had already fixed it.
func TestYaesuStateDigit(t *testing.T) {
cases := []struct {
reply, cmd string
want byte
}{
{"FR0;", "FR", '0'}, // FTDX10 form
{"FR1;", "FR", '1'},
{"FR01;", "FR", '0'}, // FTDX101 form: MAIN — the reported bug read this as SUB
{"FR11;", "FR", '1'}, // …and this is SUB
{"ST1;", "ST", '1'},
{"FT0;", "FT", '0'},
{"VS1;", "VS", '1'},
{"FR1", "FR", '1'}, // terminator already stripped
{"ST1;", "FR", 0}, // another command's reply is never accepted
{"FR;", "FR", 0}, // query echoed with no value
{"FRx;", "FR", 0}, // not a digit
{"", "FR", 0},
}
for _, c := range cases {
if got := yaesuStateDigit(c.reply, c.cmd); got != c.want {
t.Errorf("yaesuStateDigit(%q, %q) = %q, want %q", c.reply, c.cmd, got, c.want)
}
}
// End to end, both directions of the reported fault:
if d := yaesuStateDigit("FR01;", "FR"); d != '0' {
t.Fatalf("FR01 read as %q — the operator is on MAIN and must be seen there", d)
}
if d := yaesuStateDigit("FR11;", "FR"); d != '1' {
t.Fatalf("FR11 read as %q — the operator is on SUB", d)
}
}
// A spot click tunes the VFO the operator is ON, and the display reads that same
// VFO. Both follow from the receive-VFO digit, which is why it is pinned here in
// the operator's terms rather than only as a byte.
//
// Reported both ways round on an FTDX101 (F4NBZ, 2026-07-29): with RX and TX on
// SUB everything worked, and with them on MAIN the frequency froze and a spot
// clicked tuned the sub VFO.
func TestYaesuActiveVFOFollowsReceiveVFO(t *testing.T) {
// Mirrors ReadState's choice of VFO and SetFrequency's choice of command.
activeVFO := func(frReply string) string {
if yaesuStateDigit(frReply, "FR") == '1' {
return "B"
}
return "A"
}
tuneCmd := func(vfo string) string {
if vfo == "B" {
return "FB" // sub
}
return "FA" // main
}
cases := []struct {
name, fr, wantVFO, wantCmd string
}{
{"RX and TX on MAIN (FTDX101 two-digit reply)", "FR01;", "A", "FA"},
{"RX and TX on SUB (FTDX101)", "FR11;", "B", "FB"},
{"MAIN on a one-digit rig", "FR0;", "A", "FA"},
{"SUB on a one-digit rig", "FR1;", "B", "FB"},
}
for _, c := range cases {
vfo := activeVFO(c.fr)
if vfo != c.wantVFO {
t.Errorf("%s: active VFO = %s, want %s", c.name, vfo, c.wantVFO)
}
if cmd := tuneCmd(vfo); cmd != c.wantCmd {
t.Errorf("%s: a spot click would write %s, want %s", c.name, cmd, c.wantCmd)
}
}
}
// Which split command to ASK, given what else the rig answers.
//
// ST is a bare flag and its meaning varies: an FTDX101 with RX and TX both on
// SUB reports ST1, which is plain simplex on the sub VFO, and OpsLog showed
// split with the main frequency as the transmit one (F4NBZ, 2026-07-29).
//
// FT names the transmit VFO. Where the receive VFO is known too (FR), split is
// derived from the pair — they differ or they do not — which is a fact about the
// rig rather than a flag to be interpreted. So FT is preferred when FR answered.
func TestYaesuSplitProbeOrder(t *testing.T) {
order := func(rxVFOCmd string) []string {
if rxVFOCmd != "" {
return []string{"FT", "ST"}
}
return []string{"ST", "FT"}
}
if got := order("FR")[0]; got != "FT" {
t.Errorf("with FR available the first split probe is %q, want FT", got)
}
if got := order("")[0]; got != "ST" {
t.Errorf("without FR the first split probe is %q, want ST", got)
}
// Both remain available: a rig answering only one must still be handled.
for _, rx := range []string{"FR", ""} {
if len(order(rx)) != 2 {
t.Errorf("rxVFOCmd=%q: both probes must remain, got %v", rx, order(rx))
}
}
// The case that was reported, end to end: RX and TX both on sub is NOT split.
if yaesuSplitFromReply("FT1;", "FT", "B") {
t.Error("RX and TX both on SUB reported as split")
}
// And the flag alone would have got it wrong, which is why the order changed.
if !yaesuSplitFromReply("ST1;", "ST", "B") {
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)
}
}
+113 -14
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"log"
"regexp"
"strings"
"time"
@@ -35,8 +36,14 @@ import (
var (
reStrftimeDefault = regexp.MustCompile(`\(strftime\('%Y-%m-%dT%H:%M:%fZ',\s*'now'\)\)`)
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// The column name may be bare or quoted. The SQLite baseline dump quotes its
// identifiers, and those double quotes are already BACKTICKS by the time this
// runs — so a pattern matching only a bare name left every column in the
// baseline as TEXT. That produced both "BLOB/TEXT column 'state' used in key
// specification without a key length" and "column 'op_name' can't have a
// default value" on a MySQL logbook, neither of which names the real cause.
reColText = regexp.MustCompile("(`?)(\\w+)(`?)\\s+TEXT\\b")
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
@@ -103,27 +110,59 @@ func mysqlDDL(stmt string) string {
// right MySQL type, deciding per line so it can see whether the line carries a
// DEFAULT or an inline PRIMARY KEY. See varcharColumns / longTextColumns.
func translateTextColumns(s string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
m := reColText.FindStringSubmatchIndex(line)
if m == nil {
continue
}
col := line[m[2]:m[3]]
// EVERY column declaration is handled, wherever it sits.
//
// This used to work line by line and convert only the FIRST column on each
// line. That is fine for our hand-written migrations, which put one column
// per line, and wrong for the SQLite baseline: sqlite_master stores a table's
// CREATE statement with every ALTER-added column appended to the SAME line,
// so on a real logbook only one of them was converted and MySQL rejected the
// rest — "column 'op_name' can't have a default value".
//
// Each match is also decided from ITS OWN declaration — the text between the
// surrounding commas — rather than from the whole line, so a DEFAULT
// belonging to a neighbouring column cannot decide this one's type.
out := make([]byte, 0, len(s)+64)
last := 0
for _, m := range reColText.FindAllStringSubmatchIndex(s, -1) {
openQ, col, closeQ := s[m[2]:m[3]], s[m[4]:m[5]], s[m[6]:m[7]]
decl := declarationAround(s, m[0], m[1])
var typ string
switch {
case longTextColumns[col] != "":
typ = longTextColumns[col]
case varcharColumns[col],
strings.Contains(line, "DEFAULT"),
strings.Contains(line, "PRIMARY KEY"):
strings.Contains(decl, "DEFAULT"),
strings.Contains(decl, "PRIMARY KEY"):
typ = "VARCHAR(255)"
default:
typ = "TEXT"
}
lines[i] = line[:m[0]] + col + " " + typ + line[m[1]:]
out = append(out, s[last:m[0]]...)
out = append(out, (openQ + col + closeQ + " " + typ)...)
last = m[1]
}
return strings.Join(lines, "\n")
return string(append(out, s[last:]...))
}
// declarationAround returns the single column declaration containing [start,end)
// — the text between the commas, brackets or newlines either side.
//
// Types are decided from this rather than from the whole line because several
// declarations share a line in the SQLite baseline, and a neighbour's DEFAULT
// must not decide our column's type.
func declarationAround(s string, start, end int) string {
from := strings.LastIndexAny(s[:start], ",(\n")
if from < 0 {
from = 0
}
to := strings.IndexAny(s[end:], ",)\n")
if to < 0 {
to = len(s)
} else {
to += end
}
return s[from:to]
}
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
@@ -200,7 +239,15 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
// Existing database: repair any column that must be VARCHAR before the
// migrations run — an index on a TEXT column fails, and that failure
// otherwise repeats at every start with no way out from the UI.
if rerr := repairMySQLTextColumns(context.Background(), conn); rerr != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, rerr
}
// Then apply only the migrations it's missing.
err = migrate(conn, mysqlDDL, "", "mysql:"+name)
}
if err != nil {
@@ -534,3 +581,55 @@ func isIgnorableDDLError(err error) bool {
}
return false
}
// repairMySQLTextColumns converts columns that MUST be VARCHAR but are TEXT.
//
// Translating the schema correctly does not help a database that already exists.
// A logbook created by an earlier version — or left half-built by a migration
// that failed partway — keeps its TEXT columns, and every later run dies on the
// same statement: "CREATE INDEX … ON qso(state)" → 1170, because MySQL cannot
// index a TEXT column without a prefix length. The operator sees the connection
// fail at startup, for ever, with no way forward from the UI.
//
// So the schema is repaired before the migrations run. Only the columns in
// varcharColumns are touched — the ones that are indexed or part of a key — and
// only when they are actually TEXT, so this is a no-op on a healthy database.
func repairMySQLTextColumns(ctx context.Context, conn *sql.DB) error {
rows, err := conn.QueryContext(ctx, `
SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND DATA_TYPE IN ('text','mediumtext','longtext')`)
if err != nil {
// Not fatal: a server that will not answer information_schema can still
// run a healthy schema, and failing here would block a working logbook.
return nil
}
type col struct{ table, name, nullable string }
var todo []col
for rows.Next() {
var c col
if err := rows.Scan(&c.table, &c.name, &c.nullable); err != nil {
break
}
if varcharColumns[c.name] {
todo = append(todo, c)
}
}
rows.Close()
for _, c := range todo {
null := "NULL"
if c.nullable == "NO" {
null = "NOT NULL"
}
stmt := fmt.Sprintf("ALTER TABLE `%s` MODIFY `%s` VARCHAR(255) %s", c.table, c.name, null)
if _, err := conn.ExecContext(ctx, stmt); err != nil {
// Report it: this is the difference between a logbook that opens and
// one that does not, so a failure here must not be silent.
return fmt.Errorf("repair %s.%s to VARCHAR: %w", c.table, c.name, err)
}
log.Printf("db[mysql]: repaired %s.%s TEXT → VARCHAR(255) (it is indexed)", c.table, c.name)
}
return nil
}
+99
View File
@@ -151,3 +151,102 @@ func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
}
}
}
// A QUOTED column declaration must be translated like a bare one.
//
// The SQLite baseline dump quotes its identifiers, and mysqlDDL turns those
// double quotes into backticks before the TEXT rules run. The pattern only
// matched a bare name, so every column in the baseline stayed TEXT — and MySQL
// then refused the schema in two different ways, neither naming the real cause:
//
// CREATE INDEX … ON qso(state) → 1170 BLOB/TEXT column used in key
// specification without a key length
// op_name TEXT … DEFAULT '' → 1101 TEXT column can't have a default value
//
// The operator saw only "MySQL is enabled but the connection failed at startup",
// and fell back to the local SQLite database.
func TestMySQLDDL_QuotedTextColumns(t *testing.T) {
cases := []struct {
name, in, wantHas string
}{
{
// An indexed column must become VARCHAR, quoted or not.
name: "backticked indexed column",
in: "CREATE TABLE qso (`state` TEXT);",
wantHas: "`state` VARCHAR(255)",
},
{
name: "bare indexed column still works",
in: "ALTER TABLE qso ADD COLUMN state TEXT;",
wantHas: "state VARCHAR(255)",
},
{
// A default-bearing column must become VARCHAR, or MySQL rejects it.
name: "double-quoted column with a default",
in: `CREATE TABLE station_profiles ("op_name" TEXT NOT NULL DEFAULT '');`,
wantHas: "`op_name` VARCHAR(255)",
},
{
// And a plain unindexed column stays TEXT: VARCHAR everywhere would
// blow past MySQL's row-size limit, which is why the rule exists.
name: "unindexed column stays TEXT",
in: "CREATE TABLE qso (`comment` TEXT);",
wantHas: "`comment` TEXT",
},
}
for _, c := range cases {
got := mysqlDDL(c.in)
if !strings.Contains(got, c.wantHas) {
t.Errorf("%s:\n in %s\n got %s\n want it to contain %q", c.name, c.in, got, c.wantHas)
}
// The quoting must survive: a lost backtick is a syntax error further on.
if strings.Count(got, "`")%2 != 0 {
t.Errorf("%s: unbalanced backticks in %s", c.name, got)
}
}
}
// A CREATE TABLE with several columns on ONE line — the shape sqlite_master
// stores once columns have been added by ALTER TABLE.
//
// The old translation converted only the first column per line, so on a real
// logbook the baseline kept most of its columns as TEXT and MySQL refused the
// schema: "column 'op_name' can't have a default value". Our own migration files
// hide this — they put one column per line — which is why it only ever appeared
// against a database that had been through upgrades.
func TestMySQLDDL_ManyColumnsOnOneLine(t *testing.T) {
in := "CREATE TABLE station_profiles (id INTEGER PRIMARY KEY, `name` TEXT, " +
"`op_name` TEXT NOT NULL DEFAULT '', `comment` TEXT, `state` TEXT);"
got := mysqlDDL(in)
// Default-bearing and indexed columns must be VARCHAR…
for _, want := range []string{"`op_name` VARCHAR(255)", "`state` VARCHAR(255)"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n %s", want, got)
}
}
// …while the plain ones stay TEXT, or the row-size limit is breached.
for _, want := range []string{"`name` TEXT", "`comment` TEXT"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n %s", want, got)
}
}
// No column may be left as a bare TEXT that carries a default.
if strings.Contains(got, "TEXT NOT NULL DEFAULT") {
t.Errorf("a TEXT column still carries a DEFAULT — MySQL rejects that:\n %s", got)
}
}
// A neighbour's DEFAULT must not decide this column's type: deciding per LINE
// made every column on a shared line VARCHAR as soon as one of them had a
// default, which is how the row-size limit gets breached quietly.
func TestMySQLDDL_DefaultDoesNotLeakAcrossColumns(t *testing.T) {
in := "CREATE TABLE t (`a` TEXT NOT NULL DEFAULT '', `b` TEXT);"
got := mysqlDDL(in)
if !strings.Contains(got, "`a` VARCHAR(255)") {
t.Errorf("the column WITH the default should be VARCHAR:\n %s", got)
}
if !strings.Contains(got, "`b` TEXT") {
t.Errorf("the column without one should stay TEXT:\n %s", got)
}
}
+198
View File
@@ -0,0 +1,198 @@
package extsvc
// Withdrawing a QSO from the external services after it is deleted locally.
//
// The two services could hardly be less alike, and the difference decides what
// is possible for QSOs already in the log:
//
// - QRZ.com deletes ONLY by logid — the identifier it returns on INSERT.
// There is no delete-by-callsign-and-date. A QSO uploaded before OpsLog
// started recording that identifier therefore cannot be withdrawn from
// here; the operator has to do it on the website. QRZ's own documentation
// puts it plainly: "There is no undo from this operation."
// - Club Log deletes by MATCH: the worked callsign, the exact timestamp and
// the band. Nothing has to have been stored in advance, so this works for
// every QSO in the log, past or future.
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// clublogDeleteURL is Club Log's real-time single-QSO delete endpoint.
const clublogDeleteURL = "https://clublog.org/delete.php"
// DeleteQRZ removes QSOs from a QRZ logbook by their logid.
//
// The identifiers come from the LOGID QRZ returns on INSERT, which OpsLog
// stores on the QSO as APP_OPSLOG_QRZ_LOGID.
//
// QRZ answers RESULT=OK when every id was deleted, PARTIAL when some were not
// found, FAIL when none were. PARTIAL and FAIL are NOT treated as errors here:
// the QSO is being deleted locally either way, and "it was already gone from
// QRZ" is the outcome the operator wanted. The message is returned so the
// caller can log what actually happened.
func DeleteQRZ(ctx context.Context, client *http.Client, apiKey string, logIDs []string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", fmt.Errorf("qrz: api key not set")
}
ids := make([]string, 0, len(logIDs))
for _, id := range logIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return "", fmt.Errorf("qrz: no logid recorded for this QSO — it can only be removed on qrz.com")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "DELETE")
form.Set("LOGIDS", strings.Join(ids, ","))
body, err := postForm(ctx, client, qrzAPIURL, form, 20*time.Second)
if err != nil {
return "", fmt.Errorf("qrz: %w", err)
}
LogSink("qrz: DELETE raw response: %s", strings.TrimSpace(body))
vals, _ := url.ParseQuery(strings.TrimSpace(body))
result := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT")))
count := strings.TrimSpace(vals.Get("COUNT"))
switch result {
case "OK", "PARTIAL", "FAIL":
return fmt.Sprintf("RESULT=%s COUNT=%s", result, count), nil
case "AUTH":
return "", fmt.Errorf("qrz: the logbook key was refused")
}
return "", fmt.Errorf("qrz: unexpected reply %q", strings.TrimSpace(body))
}
// DeleteClublog removes one QSO from a Club Log logbook.
//
// whenUTC must be the QSO's start time; Club Log matches on it EXACTLY, to the
// second, so a rounded or local-time value simply finds nothing. band is an
// ADIF band name ("20m"), translated below to the band number Club Log wants.
func DeleteClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, dxCall string, whenUTC time.Time, band string) (string, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
dx := strings.ToUpper(strings.TrimSpace(dxCall))
switch {
case email == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case call == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
case dx == "":
return "", fmt.Errorf("clublog: no callsign to delete")
case whenUTC.IsZero():
return "", fmt.Errorf("clublog: the QSO has no time — Club Log matches on it exactly")
}
bandID, ok := clublogBandID(band)
if !ok {
return "", fmt.Errorf("clublog: band %q is not one Club Log accepts for deletion", band)
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("dxcall", dx)
form.Set("datetime", whenUTC.UTC().Format("2006-01-02 15:04:05"))
form.Set("bandid", strconv.Itoa(bandID))
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
body, err := postForm(ctx, client, clublogDeleteURL, form, 20*time.Second)
if err != nil {
return "", fmt.Errorf("clublog: %w", err)
}
msg := strings.TrimSpace(body)
LogSink("clublog: DELETE raw response: %s", msg)
// "QSO Not Deleted" means no record matched. That is not an error worth
// stopping for — the QSO is going away locally regardless, and a QSO absent
// from Club Log is the state being asked for.
return msg, nil
}
// clublogBandID maps an ADIF band name to Club Log's band number.
//
// The list is Club Log's own, and it is deliberately NOT derived from the
// frequency: Club Log accepts these values and no others, so a band outside it
// must be reported rather than approximated to a neighbour.
func clublogBandID(band string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 160, true
case "80m":
return 80, true
case "60m":
return 60, true
case "40m":
return 40, true
case "30m":
return 30, true
case "20m":
return 20, true
case "17m":
return 17, true
case "15m":
return 15, true
case "12m":
return 12, true
case "10m":
return 10, true
case "6m":
return 6, true
case "4m":
return 4, true
case "2m":
return 2, true
case "70cm":
return 70, true
case "23cm":
return 23, true
case "13cm":
return 13, true
}
return 0, false
}
// postForm is the shared request/read for both deletes.
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, timeout time.Duration) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: timeout}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
b := string(raw)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(b))
}
return b, nil
}
+32
View File
@@ -0,0 +1,32 @@
package extsvc
import "testing"
// Club Log matches a deletion on callsign, exact time and BAND NUMBER. A band
// that maps to the wrong number does not fail — it points the delete at a
// different QSO, or at nothing, silently. So the table is pinned.
func TestClublogBandID(t *testing.T) {
for _, c := range []struct {
band string
want int
}{
{"160m", 160}, {"80m", 80}, {"60m", 60}, {"40m", 40}, {"30m", 30},
{"20m", 20}, {"17m", 17}, {"15m", 15}, {"12m", 12}, {"10m", 10},
{"6m", 6}, {"4m", 4}, {"2m", 2},
{"70cm", 70}, {"23cm", 23}, {"13cm", 13},
{"20M", 20}, {" 20m ", 20}, // ADIF case and stray spaces
} {
got, ok := clublogBandID(c.band)
if !ok || got != c.want {
t.Errorf("clublogBandID(%q) = %d,%v — want %d", c.band, got, ok, c.want)
}
}
// Bands Club Log does not accept must be REFUSED, never approximated to a
// neighbour: 6 cm silently becoming 13 cm would delete somebody else's QSO.
for _, bad := range []string{"", "6cm", "3cm", "2200m", "630m", "9cm", "banana"} {
if id, ok := clublogBandID(bad); ok {
t.Errorf("clublogBandID(%q) accepted as %d — it is not a Club Log band", bad, id)
}
}
}
+7
View File
@@ -128,6 +128,13 @@ type ExternalServices struct {
HRDLog ServiceConfig `json:"hrdlog"`
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.
+36 -2
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"net"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
@@ -14,6 +16,14 @@ import (
"hamlog/internal/applog"
)
// remoteFreqRe / remoteModeRe pull the optional tune request out of a
// ServiceRemoteCall packet: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
// an optional closing tag for proper-XML senders.
var (
remoteFreqRe = regexp.MustCompile(`(?i)<FREQ>\s*([0-9]+(?:\.[0-9]+)?)`)
remoteModeRe = regexp.MustCompile(`(?i)<MODE>\s*([A-Z0-9-]+)`)
)
// reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR
// (and SO_REUSEPORT on Unix) on the underlying socket before bind. This
// is the only way for two processes to share a UDP port on Windows — Go
@@ -57,6 +67,13 @@ type Event struct {
// Call after previously reporting one — i.e. the operator cleared the call in
// the digital app. OpsLog clears its entry to match.
ClearCall bool
// TuneFreqHz / TuneMode carry an explicit "tune the radio" request embedded
// in a ServiceRemoteCall packet (DXHunter spot click sends
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). Zero/empty = no tune requested —
// the packet only fills the entry callsign, exactly as before.
TuneFreqHz int64
TuneMode string
}
// Server is a single inbound UDP listener.
@@ -248,9 +265,25 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// "CALL F4XYZ" (text prefix)
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8" (DXHunter with CAT tune)
// Strip every angle-bracket tag, normalise whitespace, take the
// last non-empty token. Upper-case for downstream consistency.
text := string(pkt)
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
// (and cut) them BEFORE the generic tag-stripping below, which would
// otherwise leave their values as stray tokens and corrupt the
// "last token = callsign" heuristic.
if m := remoteFreqRe.FindStringSubmatch(text); m != nil {
if mhz, err := strconv.ParseFloat(m[1], 64); err == nil && mhz > 0 {
ev.TuneFreqHz = int64(mhz * 1e6)
}
text = strings.Replace(text, m[0], " ", 1)
}
if m := remoteModeRe.FindStringSubmatch(text); m != nil {
ev.TuneMode = strings.ToUpper(m[1])
text = strings.Replace(text, m[0], " ", 1)
}
// Drop every <...> tag (open or close) — works for both
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
for {
@@ -286,8 +319,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
return
}
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
// empty (the DX Call was cleared in the digital app).
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
// empty (the DX Call was cleared in the digital app), and a tune-only
// request (freq with no callsign).
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall && ev.TuneFreqHz == 0 {
return
}
select {
+74
View File
@@ -0,0 +1,74 @@
package qso
import (
"context"
"path/filepath"
"testing"
"hamlog/internal/db"
)
// End-to-end proof that filtering a confirmation STATUS actually filters.
//
// Reported from the field: "QRZ.com received status = N" returned rows showing
// both N and Y. The SQL is a plain `col = ?`, so either the query is right and
// the fault is elsewhere (the UI keeping the previous rows on an error, say), or
// it is wrong here. Reading the code cannot tell those apart — running it can.
func TestFilterOnConfirmationStatus(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "logbook.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
r := NewRepo(conn)
ctx := context.Background()
for _, c := range []struct{ call, status string }{
{"W1AAA", "Y"},
{"W1BBB", "N"},
{"W1CCC", "Y"},
{"W1DDD", "N"},
{"W1EEE", ""}, // never touched by a download — NULL/empty, neither Y nor N
} {
q := QSO{Callsign: c.call, Band: "20m", Mode: "SSB", QRZComDownloadStatus: c.status}
if _, err := r.Add(ctx, q); err != nil {
t.Fatalf("insert %s: %v", c.call, err)
}
}
got, err := r.ListFiltered(ctx, QueryFilter{
Conditions: []Condition{{Field: "qrzcom_qso_download_status", Op: "eq", Value: "N"}},
Match: "AND",
})
if err != nil {
t.Fatalf("ListFiltered: %v", err)
}
if len(got) != 2 {
t.Fatalf("filter = N returned %d rows, want 2", len(got))
}
for _, q := range got {
if q.QRZComDownloadStatus != "N" {
t.Errorf("filter = N returned %s with status %q", q.Callsign, q.QRZComDownloadStatus)
}
}
// A count that disagreed with the list would show "2 matches" above a grid of
// five rows — the exact shape of the report.
n, err := r.CountFiltered(ctx, QueryFilter{
Conditions: []Condition{{Field: "qrzcom_qso_download_status", Op: "eq", Value: "N"}},
})
if err != nil {
t.Fatalf("CountFiltered: %v", err)
}
if n != 2 {
t.Errorf("CountFiltered = %d, want 2", n)
}
}
// An unknown field must ERROR rather than be silently dropped: a dropped
// condition returns the whole logbook, which reads as "the filter did nothing".
func TestFilterRejectsUnknownField(t *testing.T) {
if _, _, err := conditionSQL(Condition{Field: "not_a_column", Op: "eq", Value: "N"}); err == nil {
t.Error("unknown filter field accepted — it would silently return every QSO")
}
}
+5
View File
@@ -793,6 +793,11 @@ var bulkEditableCols = map[string]bool{
// here, so choosing one failed at Apply. The rest of the contacted station
// (callsign, name, RST…) stays deliberately out: bulk-setting those would
// corrupt the log.
// grid: the locator an import loses wholesale. Correcting it one QSO at a
// time is what the operator is trying to avoid, and unlike the callsign it
// carries no risk of confusing two stations — a wrong value is simply
// overwritten again.
"grid": true,
"state": true,
"cnty": true,
// Contest — the exchange/label fields that are constant across a run.
+424
View File
@@ -0,0 +1,424 @@
// Package rigctld shares OpsLog's CAT link with other programs.
//
// A native CAT backend owns the rig's serial port, and Windows gives a COM port
// to ONE process. So the moment OpsLog talks to the radio directly, WSJT-X,
// MSHV or JTDX can no longer reach it — the cost of dropping OmniRig, which was
// itself a sharing layer.
//
// The answer is the one wfview uses: OpsLog becomes the server. It speaks the
// Hamlib "net rigctl" protocol, which WSJT-X, JTDX, MSHV, Log4OM and CQRLOG all
// support natively (rig model "Hamlib NET rigctl", host:4532) with no driver to
// install. The other program asks us, and we relay to whichever backend is
// connected — OmniRig, Flex, Icom, TCI or Yaesu alike.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Line-based ASCII. A lowercase letter reads, its uppercase counterpart writes,
// and long names are prefixed with a backslash. A write answers "RPRT 0" for
// success or "RPRT -n" for an error; a read answers the value(s), one per line.
//
// f → 14074000 get_freq
// F 14074000 → RPRT 0 set_freq
// m → USB\n2400 get_mode (mode + passband)
// M USB 2400 → RPRT 0 set_mode
// t / T 1 → 0 get/set PTT
// s → 0\nVFOB get_split_vfo
// v → VFOA get_vfo
// \dump_state → capability block asked once by WSJT-X at connect
//
// WSJT-X will not proceed past connect without a well-formed dump_state, which
// is why that block is written out in full rather than stubbed.
package rigctld
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
)
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
// package stays testable without a radio and without importing internal/cat.
type Rig interface {
Freq() int64 // current TX frequency in Hz, 0 if unknown
Mode() string // ADIF mode (SSB, CW, FT8…)
Split() (bool, int64) // split on?, and the other VFO's frequency
SetFreq(hz int64) error
SetMode(mode string) error
SetPTT(on bool) error
}
type Server struct {
port int
rig Rig
log func(string, ...any)
mu sync.Mutex
ln net.Listener
conns map[net.Conn]struct{}
closed bool
}
func New(port int, rig Rig, logf func(string, ...any)) *Server {
if port <= 0 || port > 65535 {
port = 4532 // the rigctld default every client pre-fills
}
if logf == nil {
logf = func(string, ...any) {}
}
return &Server{port: port, rig: rig, log: logf, conns: map[net.Conn]struct{}{}}
}
func (s *Server) Start() error {
s.mu.Lock()
if s.ln != nil {
s.mu.Unlock()
return nil // already listening
}
s.closed = false
s.mu.Unlock()
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
if err != nil {
return fmt.Errorf("rigctld: listen on %d: %w", s.port, err)
}
s.mu.Lock()
s.ln = ln
s.mu.Unlock()
s.log("rigctld: sharing CAT on port %d (Hamlib NET rigctl)", s.port)
go func() {
for {
c, err := ln.Accept()
if err != nil {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if !closed {
s.log("rigctld: accept failed: %v", err)
}
return
}
s.mu.Lock()
s.conns[c] = struct{}{}
s.mu.Unlock()
go s.serve(c)
}
}()
return nil
}
func (s *Server) Stop() {
s.mu.Lock()
s.closed = true
ln := s.ln
s.ln = nil
conns := make([]net.Conn, 0, len(s.conns))
for c := range s.conns {
conns = append(conns, c)
}
s.conns = map[net.Conn]struct{}{}
s.mu.Unlock()
if ln != nil {
_ = ln.Close()
}
// Close the live sessions too. Leaving them open would keep a client happily
// talking to a server the operator has switched off.
for _, c := range conns {
_ = c.Close()
}
}
func (s *Server) serve(c net.Conn) {
defer func() {
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
_ = c.Close()
}()
s.log("rigctld: client connected from %s", c.RemoteAddr())
r := bufio.NewReader(c)
w := bufio.NewWriter(c)
for {
// No deadline: WSJT-X polls every few seconds but a client may legitimately
// sit idle between band changes, and dropping it would look like a fault.
line, err := r.ReadString('\n')
if err != nil {
s.log("rigctld: client %s disconnected", c.RemoteAddr())
return
}
resp, quit := s.handle(strings.TrimSpace(line))
if resp != "" {
if _, err := w.WriteString(resp); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
}
if quit {
return
}
}
}
// handle answers one command line. Pure apart from the Rig calls, so the whole
// protocol is testable with a fake rig.
func (s *Server) handle(line string) (resp string, quit bool) {
if line == "" {
return "", false
}
// Extended mode: clients may prefix a command with '+' or '-' to ask for a
// verbose reply. We answer in the plain format, which every client also
// accepts, so the prefix is simply stripped.
line = strings.TrimLeft(line, "+-")
fields := strings.Fields(line)
if len(fields) == 0 {
return "", false
}
cmd, args := fields[0], stripVFOArg(fields[1:])
switch cmd {
case "\\dump_state", "dump_state":
return dumpState, false
case "\\chk_vfo", "chk_vfo":
// "is VFO mode on?" — we answer for one VFO at a time, so: no.
return "CHKVFO 0\n", false
case "\\get_powerstat", "get_powerstat":
return "1\n", false
case "q", "Q", "\\quit":
return "", true
case "f", "\\get_freq":
return fmt.Sprintf("%d\n", s.rig.Freq()), false
case "F", "\\set_freq":
if len(args) < 1 {
return rprt(-1), false
}
hz, err := parseFreq(args[0])
if err != nil {
// Logged with the RAW line: a client that phrases a command in a
// dialect we don't accept shows only "Invalid parameter" on its side,
// which says nothing about what it actually sent.
s.log("rigctld: cannot read a frequency from %q — client dialect not handled", line)
return rprt(-1), false
}
if err := s.rig.SetFreq(hz); err != nil {
s.log("rigctld: set_freq %d failed: %v", hz, err)
return rprt(-9), false
}
return rprt(0), false
case "m", "\\get_mode":
// Passband width is required by the protocol. We do not read the rig's
// filter, and a made-up number is harmless here: clients use it to display
// a bandwidth, never to decide anything.
return fmt.Sprintf("%s\n%d\n", adifToHamlib(s.rig.Mode()), passbandFor(s.rig.Mode())), false
case "M", "\\set_mode":
if len(args) < 1 {
return rprt(-1), false
}
if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil {
s.log("rigctld: set_mode %q failed: %v", args[0], err)
return rprt(-9), false
}
return rprt(0), false
case "t", "\\get_ptt":
// We do not read PTT back from every backend, and answering "transmitting"
// wrongly would make a client hold off for ever. Reporting RX is the safe
// direction: the worst case is a client that transmits when we said it
// could, which is what it was going to do anyway.
return "0\n", false
case "T", "\\set_ptt":
if len(args) < 1 {
return rprt(-1), false
}
on := args[0] != "0"
if err := s.rig.SetPTT(on); err != nil {
s.log("rigctld: set_ptt %v failed: %v", on, err)
return rprt(-9), false
}
return rprt(0), false
case "v", "\\get_vfo":
return "VFOA\n", false
case "V", "\\set_vfo":
// Accepted and ignored: OpsLog follows the rig's own VFO selection, and
// answering an error here makes WSJT-X abandon the connection entirely.
return rprt(0), false
case "s", "\\get_split_vfo":
on, _ := s.rig.Split()
n := 0
if on {
n = 1
}
return fmt.Sprintf("%d\nVFOB\n", n), false
case "S", "\\set_split_vfo":
return rprt(0), false // see set_vfo — split is driven from the rig
case "i", "\\get_split_freq":
_, tx := s.rig.Split()
if tx <= 0 {
tx = s.rig.Freq()
}
return fmt.Sprintf("%d\n", tx), false
case "I", "\\set_split_freq":
return rprt(0), false
default:
// RPRT -11 is "command not implemented". Answering something is essential:
// a client waiting on a silent socket hangs rather than degrading.
s.log("rigctld: unimplemented command %q", line)
return rprt(-11), false
}
}
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
// stripVFOArg drops a leading VFO name from a command's arguments.
//
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
// mode it names the target first — "F VFOA 14074000". MSHV uses the first and
// worked immediately; JTDX uses the second, so the frequency landed in the
// argument slot where a VFO was expected, the parse failed, and JTDX showed
// "Hamlib error: Invalid parameter while setting frequency" (our RPRT -1).
//
// Accepting both costs nothing here: OpsLog follows the rig's own VFO
// selection, so the name carries no information we act on — dropping it is not
// losing anything, and refusing it locks out a whole family of clients.
func stripVFOArg(args []string) []string {
if len(args) == 0 {
return args
}
switch strings.ToUpper(args[0]) {
case "VFOA", "VFOB", "VFOC", "VFO", "CURRVFO", "CURR", "MAIN", "SUB", "MEM", "A", "B":
return args[1:]
}
return args
}
// parseFreq accepts both the integer Hz and the "14074000.000000" form clients
// send interchangeably.
func parseFreq(s string) (int64, error) {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '.'); i >= 0 {
s = s[:i]
}
hz, err := strconv.ParseInt(s, 10, 64)
if err != nil || hz <= 0 {
return 0, fmt.Errorf("rigctld: bad frequency %q", s)
}
return hz, nil
}
// adifToHamlib maps our mode vocabulary to Hamlib's. Every digital sub-mode
// becomes PKTUSB: that is what a client expects to see when the rig is in DATA,
// and it is what WSJT-X sets when it takes control.
func adifToHamlib(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB":
return "USB"
case "LSB":
return "LSB"
case "CW":
return "CW"
case "AM":
return "AM"
case "FM":
return "FM"
case "RTTY":
return "RTTY"
case "":
return "USB"
default:
return "PKTUSB"
}
}
// hamlibToADIF is the reverse. PKTUSB/PKTLSB/DATA become "DATA": the CAT backend
// then applies the operator's configured digital mode, so a client switching the
// rig to data does not silently relabel their QSOs as FT8 when they run JS8.
func hamlibToADIF(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "USB":
return "USB"
case "LSB":
return "LSB"
case "CW", "CWR":
return "CW"
case "AM":
return "AM"
case "FM", "FMN", "WFM":
return "FM"
case "RTTY", "RTTYR":
return "RTTY"
case "PKTUSB", "PKTLSB", "PKTFM", "DATA", "DIGU", "DIGL":
return "DATA"
default:
return strings.ToUpper(strings.TrimSpace(mode))
}
}
func passbandFor(mode string) int {
switch adifToHamlib(mode) {
case "CW":
return 500
case "RTTY", "PKTUSB":
return 3000
case "AM":
return 6000
case "FM":
return 15000
default:
return 2400
}
}
// dumpState is the capability block Hamlib clients read once at connect. WSJT-X
// refuses to go further without it, and parses it positionally — the field
// ORDER is the contract, so this is kept as one literal rather than assembled.
//
// It declares protocol version 0, a generic rig, and one 150 kHz1500 MHz range
// with the common modes. The numbers are deliberately permissive: they say what
// a client may ASK for, and OpsLog's backend refuses anything the radio cannot
// really do.
const dumpState = `0
1
2
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
0 0 0 0 0 0 0
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
0 0 0 0 0 0 0
0 0
0 0
0x1ff 1
0x1ff 0
0 0
0x1e 2400
0x2 500
0x1 8000
0x1 2400
0x20 15000
0x20 8000
0x40 230000
0 0
9990
9990
10000
0
10
10 20 30
0x3effffff
0x3effffff
0x7fffffff
0x7fffffff
0x7fffffff
0x7fffffff
`
// dialTimeout is only used by tests, kept here so the value is one place.
const dialTimeout = 2 * time.Second
+235
View File
@@ -0,0 +1,235 @@
package rigctld
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"testing"
)
// fakeRig stands in for the CAT manager.
type fakeRig struct {
mu sync.Mutex
freq int64
mode string
split bool
txFreq int64
ptt bool
setFreqs []int64
setModes []string
failSet bool
}
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
func (f *fakeRig) Mode() string { f.mu.Lock(); defer f.mu.Unlock(); return f.mode }
func (f *fakeRig) Split() (bool, int64) { f.mu.Lock(); defer f.mu.Unlock(); return f.split, f.txFreq }
func (f *fakeRig) SetFreq(hz int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSet {
return fmt.Errorf("rig refused")
}
f.freq = hz
f.setFreqs = append(f.setFreqs, hz)
return nil
}
func (f *fakeRig) SetMode(m string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSet {
return fmt.Errorf("rig refused")
}
f.mode = m
f.setModes = append(f.setModes, m)
return nil
}
func (f *fakeRig) SetPTT(on bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSet {
return fmt.Errorf("rig refused")
}
f.ptt = on
return nil
}
// The command table. These exact strings are what WSJT-X and MSHV put on the
// wire, so they are the contract — a reply in the wrong shape does not degrade
// gracefully, the client simply refuses to work with the rig.
func TestHandleCommands(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8", split: true, txFreq: 14100000}
s := New(0, rig, nil)
cases := []struct{ in, want string }{
{"f", "14074000\n"},
{"\\get_freq", "14074000\n"},
{"m", "PKTUSB\n3000\n"}, // a digital mode reads as PKTUSB
{"t", "0\n"}, // PTT always reads RX — see the comment
{"v", "VFOA\n"},
{"s", "1\nVFOB\n"}, // split on, TX on B
{"i", "14100000\n"}, // split TX frequency
{"F 14200000", "RPRT 0\n"},
{"F 14200000.000000", "RPRT 0\n"}, // the float form clients also send
{"M USB 2400", "RPRT 0\n"},
{"T 1", "RPRT 0\n"},
{"V VFOB", "RPRT 0\n"}, // accepted and ignored, never an error
{"S 1 VFOB", "RPRT 0\n"},
{"\\chk_vfo", "CHKVFO 0\n"},
{"F", "RPRT -1\n"}, // missing argument
{"F not_a_number", "RPRT -1\n"},
{"Z", "RPRT -11\n"}, // unknown → answered, never silence
{"", ""},
}
for _, c := range cases {
got, _ := s.handle(c.in)
if got != c.want {
t.Errorf("handle(%q) = %q, want %q", c.in, got, c.want)
}
}
if q := func() bool { _, q := s.handle("q"); return q }(); !q {
t.Error("q must end the session")
}
}
// Hamlib's VFO dialect. JTDX names the target VFO before the value — "F VFOA
// 14074000" — where MSHV sends "F 14074000". Reading the VFO name as the
// frequency is what produced "Hamlib error: Invalid parameter while setting
// frequency" on JTDX while MSHV worked perfectly.
func TestHandleAcceptsVFOPrefixedCommands(t *testing.T) {
rig := &fakeRig{freq: 7074000, mode: "SSB"}
s := New(0, rig, nil)
if got, _ := s.handle("F VFOA 14074000"); got != "RPRT 0\n" {
t.Fatalf("handle(\"F VFOA 14074000\") = %q, want RPRT 0", got)
}
if got := rig.Freq(); got != 14074000 {
t.Errorf("frequency = %d, want 14074000 — the VFO name swallowed the value", got)
}
if got, _ := s.handle("M VFOA USB 2400"); got != "RPRT 0\n" {
t.Errorf("handle(\"M VFOA USB 2400\") = %q, want RPRT 0", got)
}
if got, _ := s.handle("T VFOA 1"); got != "RPRT 0\n" {
t.Errorf("handle(\"T VFOA 1\") = %q, want RPRT 0", got)
}
// A read with the VFO named must still answer the value, not an error.
if got, _ := s.handle("f VFOA"); got != "14074000\n" {
t.Errorf("handle(\"f VFOA\") = %q, want the frequency", got)
}
// And the plain dialect must keep working — this is an ADDITION, not a swap.
if got, _ := s.handle("F 21074000"); got != "RPRT 0\n" {
t.Errorf("plain set_freq broke: %q", got)
}
// "S 1 VFOB" starts with the split flag, not a VFO: nothing must be eaten.
if got, _ := s.handle("S 1 VFOB"); got != "RPRT 0\n" {
t.Errorf("handle(\"S 1 VFOB\") = %q, want RPRT 0", got)
}
}
// A rig that refuses must produce an error report, not a success — a client told
// "RPRT 0" believes the radio moved and will log the wrong frequency.
func TestHandleReportsBackendFailure(t *testing.T) {
s := New(0, &fakeRig{failSet: true}, nil)
for _, in := range []string{"F 14200000", "M USB 2400", "T 1"} {
if got, _ := s.handle(in); got != "RPRT -9\n" {
t.Errorf("handle(%q) with a failing rig = %q, want RPRT -9", in, got)
}
}
}
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
// line as the protocol version and refuses to continue if the block is short or
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
// breaking every client.
func TestDumpStateShape(t *testing.T) {
lines := strings.Split(strings.TrimRight(dumpState, "\n"), "\n")
if len(lines) < 20 {
t.Fatalf("dump_state has %d lines — clients expect the full capability block", len(lines))
}
if lines[0] != "0" {
t.Errorf("dump_state protocol version = %q, want \"0\"", lines[0])
}
// The frequency-range lines must carry seven fields, or the client's parse
// slides and every later capability is read from the wrong place.
for _, i := range []int{3, 5} {
if n := len(strings.Fields(lines[i])); n != 7 {
t.Errorf("dump_state line %d has %d fields, want 7: %q", i, n, lines[i])
}
}
}
// End to end over a real socket, because the framing (one reply per line,
// flushed immediately) is as much a part of the contract as the text.
func TestServerOverTCP(t *testing.T) {
rig := &fakeRig{freq: 7074000, mode: "SSB"}
s := New(0, rig, nil)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s.mu.Lock()
s.ln = ln
s.mu.Unlock()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go s.serve(c)
}
}()
defer s.Stop()
c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
r := bufio.NewReader(c)
if _, err := c.Write([]byte("f\n")); err != nil {
t.Fatalf("write: %v", err)
}
line, err := r.ReadString('\n')
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.TrimSpace(line) != "7074000" {
t.Errorf("get_freq over TCP = %q, want 7074000", strings.TrimSpace(line))
}
if _, err := c.Write([]byte("F 14074000\n")); err != nil {
t.Fatalf("write: %v", err)
}
line, _ = r.ReadString('\n')
if strings.TrimSpace(line) != "RPRT 0" {
t.Errorf("set_freq over TCP = %q, want RPRT 0", strings.TrimSpace(line))
}
if got := rig.Freq(); got != 14074000 {
t.Errorf("rig frequency = %d, want 14074000 — the command never reached it", got)
}
}
func TestModeMapping(t *testing.T) {
for _, c := range []struct{ adif, hamlib string }{
{"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"},
{"FT8", "PKTUSB"}, {"JS8", "PKTUSB"}, {"", "USB"},
} {
if got := adifToHamlib(c.adif); got != c.hamlib {
t.Errorf("adifToHamlib(%q) = %q, want %q", c.adif, got, c.hamlib)
}
}
// Digital comes back as DATA, never as a specific sub-mode: the CAT backend
// applies the operator's own digital default, so a client that switches the
// rig to data does not relabel a JS8 operator's QSOs as FT8.
for _, c := range []struct{ hamlib, adif string }{
{"PKTUSB", "DATA"}, {"PKTLSB", "DATA"}, {"DIGU", "DATA"},
{"USB", "USB"}, {"CWR", "CW"}, {"FMN", "FM"},
} {
if got := hamlibToADIF(c.hamlib); got != c.adif {
t.Errorf("hamlibToADIF(%q) = %q, want %q", c.hamlib, got, c.adif)
}
}
}
+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
+71
View File
@@ -0,0 +1,71 @@
package winkeyer
import "testing"
// The init sequence sent to a K1EL keyer, byte for byte.
//
// This exists because of a bug that no amount of reading caught and that a
// byte-level trace from a real WK2 settled in one line: the dit/dah ratio was
// sent as command 0x11, which is SET KEY COMPENSATION in milliseconds. A
// neutral ratio of 50 therefore asked for 50 ms of extra key-down on every
// element — at 25 wpm a dit is 48 ms — so elements more than doubled and ran
// into each other. The operator saw "one element, then it stalls".
//
// The command NUMBERS are the contract with the hardware. A test on the values
// is the only thing standing between a typo here and a keyer that misbehaves in
// a way nobody can debug from the UI.
func TestApplyConfigCommands(t *testing.T) {
cmds := configCommands(Config{
WPM: 25, Weight: 50, Ratio: 50, LeadInMs: 0, TailMs: 0, Sidetone: 0,
})
// Every command that must be present, with the value it must carry.
want := map[byte][]byte{
0x02: {25}, // speed
0x03: {50}, // weighting — 50 is neutral, and this one WAS right
0x17: {50}, // dit/dah ratio — the corrected opcode
0x11: {0x00}, // key compensation explicitly cleared
}
seen := map[byte][]byte{}
for _, c := range cmds {
if len(c) < 2 {
t.Fatalf("command %X has no argument", c)
}
seen[c[0]] = c[1:]
}
for op, args := range want {
got, ok := seen[op]
if !ok {
t.Errorf("command 0x%02X is not sent at all", op)
continue
}
if len(got) != len(args) || (len(args) > 0 && got[0] != args[0]) {
t.Errorf("command 0x%02X carries % X, want % X", op, got, args)
}
}
// The ratio must NEVER go out as 0x11 again: that is the whole bug.
if v, ok := seen[0x11]; ok && len(v) > 0 && v[0] != 0 {
t.Errorf("0x11 (key compensation) carries %d — it must be 0, not the ratio", v[0])
}
}
// A keyer left with compensation by an earlier version keeps it in EEPROM, so
// the fix has to CLEAR it rather than merely stop setting it.
func TestKeyCompensationIsCleared(t *testing.T) {
for _, ratio := range []int{33, 50, 66} {
cmds := configCommands(Config{WPM: 20, Weight: 50, Ratio: ratio})
found := false
for _, c := range cmds {
if c[0] == 0x11 {
found = true
if c[1] != 0 {
t.Errorf("ratio %d: key compensation sent as %d, want 0", ratio, c[1])
}
}
}
if !found {
t.Errorf("ratio %d: key compensation is never cleared", ratio)
}
}
}
+33 -5
View File
@@ -243,21 +243,39 @@ func (m *Manager) connectSerial(cfg Config) error {
return nil
}
// applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error {
// configCommands is the init sequence for a config — separated from the sending
// so the BYTES can be tested. The command numbers are the contract with the
// hardware, and a wrong one produces a keyer that misbehaves in a way no amount
// of reading the UI explains.
func configCommands(c Config) [][]byte {
cmds := [][]byte{
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
{0x02, byte(c.WPM)}, // set speed (WPM)
{0x03, byte(c.Weight)}, // set weighting
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
{0x11, byte(c.Ratio)}, // set dit/dah ratio
// Dit/dah ratio is 0x17. It was being sent as 0x11, which on a WinKeyer 2
// is SET KEY COMPENSATION — in milliseconds. So a neutral ratio of 50 was
// read as 50 ms of extra key-down on every element: at 25 wpm a dit is
// 48 ms, so each element more than doubled and ran into the next. That is
// the reported "it sends one element then stalls", and it was in the trace
// as "TX 11 32".
{0x17, byte(c.Ratio)},
// And clear the compensation explicitly. A keyer left at 50 ms by the
// previous version — or by another program — keeps it in EEPROM, so
// merely stopping the wrong command would not fix an affected keyer.
{0x11, 0x00},
}
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
if c.Farnsworth > 0 {
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
}
for _, cmd := range cmds {
return cmds
}
// applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error {
for _, cmd := range configCommands(c) {
if err := m.write(cmd); err != nil {
return err
}
@@ -478,10 +496,12 @@ func cmdName(b []byte) string {
return "clear buffer"
case 0x0D:
return "farnsworth wpm"
case 0x17:
return "set dit/dah ratio"
case 0x0E:
return "set mode register"
case 0x11:
return "0x11 (WK2: key compensation / WK3: see datasheet)"
return "set key compensation (ms)"
case 0x15:
return "request status"
default:
@@ -609,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() }
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"testing"
"hamlog/internal/adif"
)
// What counts as a QRZ.com confirmation.
//
// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The
// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag,
// which they uploaded to QRZ themselves, so every QSO with a card came back
// looking QRZ-confirmed. On a log full of paper QSLs that is most of it.
//
// This status feeds the award slots, so a false Y is a QSO counted as confirmed
// when it is not — the reason the rule is narrow rather than generous.
func TestQRZRecordConfirmed(t *testing.T) {
cases := []struct {
name string
rec adif.Record
want bool
}{
// QRZ's own statements.
{"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true},
{"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true},
{"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true},
{"padded", adif.Record{"app_qrzlog_status": " C "}, true},
// NOT confirmations by QRZ — the bug being fixed.
{"paper QSL received", adif.Record{"qsl_rcvd": "Y"}, false},
{"paper QSL plus a card sent", adif.Record{"qsl_rcvd": "Y", "qsl_sent": "Y"}, false},
{"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false},
{"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false},
// Explicit negatives and nothing at all.
{"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false},
{"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},
{"bare record", adif.Record{"call": "F4XYZ"}, false},
{"empty", adif.Record{}, false},
}
for _, c := range cases {
if got := qrzRecordConfirmed(c.rec); got != c.want {
t.Errorf("%s: qrzRecordConfirmed = %v, want %v (%v)", c.name, got, c.want, c.rec)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.21.8"
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.
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"testing"
"hamlog/internal/uls"
)
// The ULS grid rules, which are NOT the same as refineGrid's.
//
// cty.dat answers a US call with the ENTITY centroid — a 4-character square in
// the middle of the country. refineGrid keeps it (the ULS square is not an
// extension of it, it is a different square), which is right for a QRZ grid and
// wrong here: a per-callsign FCC square beats any entity centroid. But a
// 6-character grid already present came from a provider and must survive.
func TestULSGridPreference(t *testing.T) {
cases := []struct {
name string
have string // grid already on the result
uls string // grid from the FCC database
want string
}{
{"nothing known → take ULS", "", "EM12AB", "EM12AB"},
{"cty.dat entity centroid → ULS wins", "EN90", "EM12AB", "EM12AB"},
{"4-char square, same square → ULS extends it", "EM12", "EM12AB", "EM12AB"},
{"provider gave 6 chars → untouched", "FN31PR", "EM12AB", "FN31PR"},
{"ULS has only 4 chars, provider has 6 → untouched", "FN31PR", "EM12", "FN31PR"},
{"ULS empty → untouched", "EN90", "", "EN90"},
}
for _, c := range cases {
got := c.have
if g := refineGrid(got, c.uls); g != "" && g != got {
got = g
} else if len(got) <= 4 && len(c.uls) >= 6 {
got = c.uls
}
if got != c.want {
t.Errorf("%s: have=%q uls=%q → %q, want %q", c.name, c.have, c.uls, got, c.want)
}
}
}
// CNTY is what gets written to the QSO's county field, so its shape is part of
// the contract: ADIF wants "State,County" and nothing when either half is
// missing — a lone county name would be an invalid CNTY on export.
func TestULSCountyFormat(t *testing.T) {
cases := []struct {
loc uls.Location
want string
}{
{uls.Location{State: "MA", County: "Middlesex"}, "MA,Middlesex"},
{uls.Location{State: "", County: "Middlesex"}, ""},
{uls.Location{State: "MA", County: ""}, ""},
}
for _, c := range cases {
if got := c.loc.CNTY(); got != c.want {
t.Errorf("Location%+v.CNTY() = %q, want %q", c.loc, got, c.want)
}
}
}