Compare commits

...
114 Commits
Author SHA1 Message Date
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
rouggy c9d1fc1cc0 chore: release v0.21.8 2026-07-28 19:10:14 +02:00
rouggy f43d41892e docs: changelog — the QRZ date window covers both cases 2026-07-28 15:56:37 +02:00
rouggy 1f2e86c04d feat: a typed QRZ start date fetches that period, not what QRZ edited in it
"Last download" and an operator-chosen date are different questions, and they
now ask QRZ different ones. The automatic run wants everything TOUCHED since it
last ran (MODSINCE) — that is what returns a 2015 QSO confirmed yesterday. A date
typed by the operator names a period of OPERATING, so it maps to BETWEEN date and
today, on the QSO date.

The client-side date filter is skipped only under MODSINCE, where it would throw
away the older-but-newly-confirmed records the query exists to fetch. Under
BETWEEN it agrees with the server and stays on as a backstop.
2026-07-28 15:48:00 +02:00
rouggy 305da583e1 fix: QRZ download used an option QRZ does not accept
The date window was sent as OPTION=AFTER:<date>. That was a guess, and QRZ
rejects the request outright — RESULT=FAIL with no reason — so the confirmation
download failed completely for everyone, whatever the account.

The documented option is MODSINCE, and it is the better window anyway: it selects
records MODIFIED since the date, so a QSO from years ago that was confirmed
yesterday comes back. A QSO-date window structurally cannot return those, and
confirmations are the whole point of this download — which is why the client-side
date filter is now skipped when the server did the windowing, instead of throwing
those same records away on arrival.

A refusal now falls back to ALL rather than leaving the operator with nothing.
2026-07-28 15:43:47 +02:00
rouggy 71bde30e24 chore: log the window geometry that is saved and restored
Reported as a regression — reopens on the wrong screen — but the window code is
untouched since it was verified working: the only change to main.go since is the
background colour. So the fault is in the DATA, and there was no way to see it.

Both ends now log their coordinates, plus where the window actually landed after
the un-maximise / move / re-maximise dance. That separates the cases, which need
opposite fixes: nothing was captured, something wrong was captured, or the right
corner was captured and Windows moved the window anyway.
2026-07-28 15:35:57 +02:00
rouggy 31b13cfbc0 Revert "fix: no right-click Cut/Copy/Paste in release builds"
Reverts 30d8827. Ctrl+C / Ctrl+V already work in the app, so the WebView's
default menu only added Refresh / Save as on the neutral areas — noise in an
application window, for nothing gained. Changelog entry dropped with it.
2026-07-28 15:33:30 +02:00
rouggy 30d88276f3 fix: no right-click Cut/Copy/Paste in release builds
Wails disables the WebView context menu in production, so an operator could not
paste a cluster address, an API key or a callsign anywhere in the app. Ctrl+V
did work — nothing intercepts it — but the menu is where people look, and a key
copied off a web page is exactly what you paste rather than retype.

EnableDefaultContextMenu turns it back on.
2026-07-28 15:28:51 +02:00
rouggy b18db7acfc fix: a port field could not be cleared
Every port box was parseInt(e.target.value) || <default>. Delete the contents
and parseInt gives NaN, so the default is written straight back: the digits
reappear under the cursor and the only way to enter a different port is to
overwrite it character by character. Reported on the cluster editor; the same
line existed in eight other places.

One PortInput component now holds the raw TEXT as its state and only tells the
parent about a value that is actually a port. An empty box stays empty while you
type; on blur an invalid one falls back to the default, so nothing is ever saved
as 0 or NaN. It adopts a value arriving from the backend only while the operator
has not started typing — re-syncing mid-edit is the behaviour being fixed.

This is the controlled-input trap the project notes already warn about: keep the
raw text in local state and derive the stored value from it.
2026-07-28 15:25:21 +02:00
rouggy d303dee768 chore: microwave-band entry belongs to 0.21.8, not the published 0.21.7
Same slip as before the 0.21.6 release: I appended to a block that had already
shipped. 0.21.7 is back to its 12 published entries; the microwave-band fix
opens 0.21.8.
2026-07-28 15:15:37 +02:00
rouggy ad02583ef7 fix: recognise the microwave bands — 10 GHz resolved to no band at all
Reported on 3 cm. cat.BandFromHz stopped at 23 cm, so a 10 GHz frequency came
back EMPTY — and an empty band is not cosmetic: the QSO is logged without one,
counts in no award slot, and exports with no BAND field. The low end was short
too (2190m / 630m / 560m).

Four band tables had to be extended because four exist: the CAT one, the award
plan in app.go, the cluster spot classifier, and the frontend's. Plus the places
that LIST bands — the QSO editor and award-definition pickers (you could not set
3 cm by hand either), the statistics axis, and the award band ORDER, where a
missing band sorts to the end instead of in frequency order.

Ranges and names are ADIF 3.1.7, which is what an export has to carry.

A test now pins one frequency per band across the Go tables and asserts they
agree — that is what was missing, four hand-maintained copies with nothing
checking them. It also pins that an out-of-band frequency stays empty rather
than snapping to the nearest band, which would file a QSO under a band the
operator never used.
2026-07-28 14:50:17 +02:00
rouggy add4b175fc chore: release v0.21.7 2026-07-28 14:43:26 +02:00
rouggy 11940ae843 chore: changelog entry for the two diagnostic-log commits
Left out as "internal", but the log is something operators read and send —
and the Flex meter removal, which is the same nature, was listed. Consistent
now.
2026-07-28 14:42:34 +02:00
rouggy 91f046444a chore: log a DELAYED frequency readback after an OmniRig set
An FTDX10 operator reports that changing band from OpsLog moves the rig but the
displayed frequency stays on the old band until they nudge the VFO. The existing
readback is taken immediately after the write, so it always shows the old value
and proves nothing: OmniRig queues the CAT command and sends it over serial.

A second readback is now logged ~1.5 s later, from ReadState (the goroutine that
owns the COM apartment), together with what OpsLog concluded. That separates the
two candidates, which look identical to the operator: the rig ignored the write,
or the rig moved and its rig file never re-reads the frequency.
2026-07-28 14:39:04 +02:00
rouggy 386fb7f030 feat: ATU controls in the FlexRadio panel
The backend already parsed the atu object and exposed ATUStart / ATUBypass /
SetATUMemories through the Flex controller and the App bindings — only the UI
was missing, so the radio's own tuner was the one thing on the panel you still
had to reach SmartSDR for.

Placed under TUNE, which is the order of operations: the tuner needs a carrier
to measure against.

The status is decoded into words rather than shown raw. That is the point of the
block: TUNE_FAIL and "never tuned" leave the radio looking identical on its own
front panel, and the operator transmits into a bad match either way. Failed is
red, tuning amber, tuned green.
2026-07-28 14:35:56 +02:00
rouggy 5a6db2b656 feat: the header callsign is the station switcher; clock moves to the status bar
Switching station is the frequent act and editing a profile the rare one, but
the callsign opened the settings — so changing station took four clicks and a
scroll. It is now a dropdown of every profile: pick one and it activates. The
active one is ticked and disabled, and "Manage profiles…" stays at the bottom
for the rare case.

The list reloads on every profile change, so a profile added or renamed in the
settings appears without a restart.

The UTC clock leaves the header for the status bar, next to the database: in the
header it sat beside the frequency in the same weight and competed with it for
the eye, while being something you consult rather than watch.
2026-07-28 14:25:06 +02:00
rouggy c3958be0b2 chore: move the post-release entries out of 0.21.6 into 0.21.7
0.21.6 was published before these landed, so its notes must stay exactly as they
shipped — a released block is a record of what an operator actually got, not a
place to keep appending. Restored it to the 11 entries in the release commit and
opened 0.21.7 for the eight that followed.

The bulk-edit line was the one subtlety: it was published in 0.21.6 and then
REWRITTEN in place to cover the filter builder. 0.21.6 keeps its published
wording; the filter half is new and gets its own 0.21.7 entry.
2026-07-28 11:15:50 +02:00
rouggy a42a3d5713 chore: name the database in every migration log line
An operator reported migrations being very slow and sent a log with three
interleaved runs — and no way to tell one database migrated three times from
three databases migrated once. Each line now carries its target: the SQLite file
name, mysql:<database>, or baseline:memory for the in-memory pass that derives
the schema for a FRESH MySQL database (that one is fast and is not a real
database — in the report it sat between two slow runs and looked like a third).

A pass also announces how many migrations it will apply, so a run that applies
nothing is visibly distinct from one that does.
2026-07-28 10:40:04 +02:00
rouggy 0a1569dbbd fix: QRZ confirmation download read a third of a large logbook, then skipped the rest for good
Three faults compounding, all visible in one operator's report (117k QSOs):

1. We asked for OPTION=ALL every time, so QRZ serialised the entire logbook —
   56 MB — and cut it short. The parser stopped at record 27832 with "unexpected
   EOF" and everything past that was never read. The window is now sent to the
   server (OPTION=AFTER:date); the client-side date filter stays as a backstop.

2. The last-download date was stored RIGHT AFTER the fetch, before knowing
   whether the records had been processed. So the truncated run still advanced
   the window past data it had never read — and every later run skipped it. It
   is now written at the end, and only when the ADIF parsed cleanly. A window
   that moves past unseen data is worse than one that re-reads.

3. Which is why the run in the report ended "Confirmed 0, added 0 (of 0
   returned)" despite parsing 27832 records: an earlier truncated run had
   already pushed the window to yesterday, so every record was filtered out
   client-side. The operator could not recover without clearing the key by hand.

A truncated download now says so in plain words, and says the window was not
moved.
2026-07-28 09:52:20 +02:00
rouggy 843dccf0c4 fix: bulk edit refused the fields it offered (#3)
My mistake yesterday: the new confirmation columns went into uploadStatusCols —
the QSL Manager's FILTER whitelist — instead of bulkEditableCols. The dialog
listed QRZ.com received status and the ten dates, and the repository refused
them at Apply, after the operator had selected 54 QSOs.

Moved to the right map. And two fields have been broken this way since long
before: State and County were offered under "Contacted station" (whose IOTA /
POTA / SOTA / SIG siblings are all allowed) but were missing from the whitelist,
so choosing one always failed. Added — they are exactly what an import loses and
what a whole run shares.

The real fix is the test. Two lists in two languages had to agree by hand and
nothing checked it, so the failure only ever surfaced as an error message at the
last click. The new tests read the ACTUAL field ids out of BulkEditModal.tsx and
FilterBuilder.tsx and assert the repository accepts every one — a field added to
the UI alone now fails the build instead of the operator.
2026-07-28 09:45:22 +02:00
rouggy d49126d45c chore: stop logging FlexRadio meter values
They arrive many times a second and were dumped every 5 s, drowning the rest of
the log. They only ever existed to confirm the meter NAMES during development,
which the subscription line already does — and that one is now a single summary
instead of one line per meter, a Flex announcing dozens of them at each connect.
2026-07-27 22:20:47 +02:00
rouggy cc1ad06a9d fix: keep the precise locator on digital QSOs; make the manual lookup real
Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid,
that is all the FT8 protocol carries. The enrichment rule there is "fill only
what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away:
roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is
now taken when it EXTENDS the received square. A lookup that disagrees outright
(JN18 against JN05) is not a refinement — the station may be portable and what
came over the air is then the better record. Both rules are pinned by tests.

Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An
operator who upgraded their QRZ subscription went on getting the thin
free-account record and clearing the cached row by hand was the only way out.
The button now forces a lookup that bypasses the cache, reaches the provider and
REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click
must reach the network, where giving up early would fall back to cty.dat and
look like nothing happened. The automatic type-ahead lookup keeps the cache,
which is where it earns its keep.

Same fetch: it used `??`, which only guards against null. Go marshals an unset
string as "", so a QRZ record with no grid BLANKED the grid already in the QSO.
The lookup still wins — that is the point of asking for it — but an empty
result no longer erases a good value.
2026-07-27 22:10:00 +02:00
rouggy 4dd15b09e9 chore: release v0.21.6 2026-07-27 21:26:31 +02:00
rouggy 4eec272c0b fix: bulk edit — name the confirmation fields properly, add the missing ones (#3)
"Upload" was wrong for half of them: a paper QSL is not uploaded. Every entry
now says whether it sets a STATUS or a DATE, and in which direction — "QRZ.com
sent status", "LoTW received date" — which is also how the ADIF fields read.

Added: the QRZ.com received status (the column existed, it was simply never
offered) and the ten confirmation DATES — QSL / LoTW / eQSL / QRZ.com in both
directions, Club Log and HRDLog outbound only, those two having no return
channel. Dates use the same picker as the QSL Info tab, with a Today (UTC)
button; clearing the field writes an empty value, which is how a wrong date is
removed from a batch.

Fields are grouped by CHANNEL now, each status followed by its date, in the same
order as the QSL Info tab — a longer list, but nothing to hunt for.

The repository whitelist is extended to match: without it the new columns would
have been refused at write time. The old ids (qrz_upload, clublog_upload,
hrdlog_upload) still resolve, in case anything still holds one.
2026-07-27 21:21:09 +02:00
rouggy 816a727e88 feat: stats timeline, band-map colours, frameless window, and four fixes
Statistics — the activity chart ignored the period selector: picking a year up
top left it showing the last 7 days, two controls contradicting each other. The
buttons were four fixed rolling windows anchored on the newest QSO, not
granularities. They now choose the BUCKET SIZE over the selected period, with an
Auto default and a step-up when a bucket would be too fine (the backend drops a
series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day
histogram covered a single day, too little to read anything from; it moves to
its own card with a weekday view, over the whole period.

Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the
same amber that means "new band" on the pills drawn on top of them. A sub-band
is an identity, not a state: categorical hues now, validated in both themes
(violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and
added to the legend, since identity must never be colour-alone.

Frameless window — the OS title bar was a dead 32px band above a window that
already has its own. Minimise/maximise/close move into the app header, which
carries the drag region and double-click-to-maximise. The drag opt-out is by
ROLE in CSS, so a future button in that bar keeps working without anyone
remembering the rule.

Fixes:
- Saving settings froze the window while a device was slow: restarting a link
  waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM
  Connect when another program holds the rig. The restart is now off the UI
  path; a slow one is logged.
- Multi-screen: a MAXIMISED window was never repositioned, so it came back on
  the primary screen every launch. The corner is now recorded even when
  maximised (it names the monitor) and re-applied while still hidden.
- The Callsign field is marked out at rest — operators were typing the call into
  Name, which sat beside it and looked identical.
- QSL dates get a real picker (native, for the OS calendar and locale order); a
  malformed old value stays in a text box rather than vanishing behind an empty
  one.

Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2
report (one element then a ten-second pause, sidetone that will not switch off)
is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would
break the operators it works for today. The trace logs every byte with the
command name and spells out the firmware family, which is what will settle it.
2026-07-27 20:50:44 +02:00
rouggy 2b5c195ab4 chore: release v0.21.5 2026-07-27 13:49:19 +02:00
rouggy 139b4675e3 fix: portable folder — stop storing absolute database paths
Moving the folder (C:\OpsLog → D:\OpsLog, or onto a stick) broke everything:
config.json and each profile's logbook path were absolute, so on the new
machine the pointer named a drive that no longer applied.

The logbook case was the dangerous one. db.Open CREATES what is missing, so if
the stale C: path happened to be creatable, the operator silently got a NEW
EMPTY logbook instead of an error — with their QSOs sitting untouched in the
folder they had just copied.

A path inside the application folder is now stored relative to it and resolved
against the current location at read time. A path OUTSIDE it (a synced folder,
a chosen drive) stays absolute and untouched — that is a deliberate choice; it
is only re-rooted when it has gone missing AND a file of the same name exists in
this install's data folder, i.e. the copied-folder case. With no such twin the
path is left alone so the failure is reported rather than papered over with an
unrelated database. Both rules are pinned by tests.
2026-07-27 13:48:11 +02:00
rouggy 2ad72b19fb feat: separate column layout for Recent QSOs in the Main tab
The Main-tab pane and the full Recent QSOs tab are the same component sharing
one storage key, but the pane is about half as wide — so it wants fewer and
narrower columns. Whichever was opened last rewrote the other's widths.

The pane now stores under "mainpane", which also scopes its award-column
visibility and widths. The full tab keeps the historical key, so its layout is
untouched; the pane starts from defaults once.
2026-07-27 11:58:24 +02:00
rouggy 678c8821c2 fix: column layouts would not stick — five separate faults
1. A language change rebuilt the column defs, which sets the anti-clobber
   guard, but the effect that clears it listened to only two of the rebuild's
   causes. Guard stuck on → every column change was silently discarded for the
   rest of the session. Now keyed on the rebuilt defs themselves, so any future
   cause is covered.
2. The cluster grid had no guard at all: the same rebuild wrote its defaults
   over the saved layout, in the cache AND the database. Unrecoverable.
3. Worked-before and cluster read their state before the active profile was
   known, so they looked up the unscoped cache key (always a miss) and then
   saved under the scoped one. They now wait for the scope and remount on a
   profile switch, like the main log.
4. GetUIPref returns an ERROR, not "", while the settings store is not yet
   scoped — deliberately distinct from "unset". The frontend read it as "no
   preference", rendered defaults, and the first column event overwrote the
   good copy. It now retries instead of giving up.
5. The QSL manager had no storageKey, so it shared the main log's layout and
   each rewrote the other.

Also: the DB write is debounced (a resize drag fired dozens of writes) with a
flush on close, and a rejected write is retried rather than dropped.
2026-07-27 11:56:43 +02:00
rouggy 5394b55bb7 feat: amplifier band-follow — answer the amp's frequency polls
On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and
changes band from the reply, so nothing can be pushed to it. internal/catemu
answers those polls on a second serial port, in ACOM command set 5 (Kenwood /
Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is
worse than none, it desynchronises the amp's parser for the following poll.

Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT
line in parallel; those never hear a responder, so an optional unprompted send
(500/1000 ms) covers them.

The TX frequency is what is sent: in split the amp must be tuned where we
transmit. Frame lengths are pinned by tests — the failure they guard against is
silent and only shows up as an amp that mistunes.

Untested on hardware.
2026-07-27 11:56:30 +02:00
rouggy 05d64024ef fix: version string was 021.4, not 0.21.4
The release prompt was answered with a missing dot and the script propagated
it verbatim into both constants, so the shipped exe reported "021.4".

That is not cosmetic: versionLess splits on dots, so "021.4" parses as [21,4]
and compares GREATER than "0.21.5" — a client on that build would be told it
is up to date forever and never offered another update. It also broke the
What's new dialog on a fresh install, where the changelog entry ("0.21.4") is
matched against appVersion by string equality.

Same code as the release otherwise; only the version string changes.
2026-07-27 10:03:57 +02:00
rouggy cb27aa5ebf chore: release v021.4 2026-07-27 00:16:17 +02:00
rouggy aefb984974 feat: follow the SUB VFO over OmniRig; distance column in the QSO grids
OmniRig reports the VFO pair (AA/AB/BA/BB) on the whole Yaesu range and never
the single-letter form. Only the latter was honoured, so every rig in that
family stayed pinned to VFO A: pressing SUB moved the radio but not OpsLog,
and a QSO worked on SUB was logged on the main VFO's frequency. The first
letter of the pair is the VFO being listened on — Log4OM reads it and gets the
right frequency on the same rigs, which is what showed the data was there.
The enum now wins over the Yaesu Freq==FreqB inference, which is only a
fallback for a rig file that names no VFO at all (the stock FTDX10 one answers
neither VS; nor FR; usably — verified on the air).

Split: the ON flag is still latched to survive a rig file that flips it on its
own, but the latch is now ARMED only after 8 flips in 30 s. A first cut at
3-in-15s was armed by the operator toggling split while testing, imposing the
6 s clearing delay on a radio that did not need it; a misreading file flips a
dozen times in that window untouched, so the two cases separate cleanly.

Distance (km) column added to Recent QSOs and Worked before (shared catalog).
Computed from the QSO's OWN my_grid/my_lat/lon first, falling back to the
current profile's locator: a log spans years and portable outings, so the
station a QSO was made from is not necessarily today's.

Locator: a precise QRZ/HamQTH grid is no longer overwritten by the cty.dat
entity centroid. The lookup runs several times per QSO and the provider gets
2 s; a slow second answer fell back to cty.dat and downgraded JN05JG to JN16
while name and QTH survived (they are only written when non-empty).

The OmniRig diagnostic line now logs what OpsLog concluded, not just what
OmniRig reported. icomnet.go: gofmt alignment only.
2026-07-26 23:36:56 +02:00
rouggy 91b5af1c7b chore: release v0.21.3 2026-07-26 16:57:19 +02:00
rouggy 4fd70f6a9d chore: release v0.21.2 2026-07-25 20:05:50 +02:00
rouggy 51e279887d feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait
- SCP/N+1: new internal/scp downloads the community MASTER.SCP master list and a
  docked two-column widget shows, as you type a call, the known calls containing it
  (Partial) and the calls one edit away (N+1) — click to fix a busted call. Opt-in
  in Settings → General; top-bar toggle; queried debounced on callsign input.
- Flex: OpsLog's GUI-client detection was too loose and could bind to "SmartSDR CAT"
  (or DAX) — both carry "smartsdr" in the program name — instead of the real GUI
  client, making SmartSDR CAT drop and reconnect in a loop while OpsLog was open.
  Now it binds only to a real SmartSDR/Maestro GUI client (e.g. a FLEX-8600M's
  integrated screen) and excludes cat/dax; dropped the risky empty-program fallback.
- Telemetry: on a fresh install the callsign isn't set at launch, so the once-a-day
  heartbeat recorded the machine UUID. Now it waits (~10 min) for the operator to
  enter their callsign before sending, falling back to the UUID only if none appears.
2026-07-25 20:05:33 +02:00
rouggy 7e08553e6e feat: prefer the last QSO's precise locator over the cty.dat centroid; changelog → 0.21.2
For an entity resolved only via cty.dat (e.g. a French call not on QRZ/HamQTH),
the provider block sets a coarse country-centroid grid. The worked-before backfill
now overrides that with the precise locator from the last QSO with this call, and
takes that QSO's lat/lon (derived from the grid when the record stored none) so
the map and saved record stay consistent. A real provider grid still wins.

Moved the (unreleased) worked-before backfill entry into a new 0.21.2 changelog
block, since 0.21.1 is already out.
2026-07-25 15:54:25 +02:00
rouggy 3564eecc36 feat: backfill name/QTH/grid/address from the last QSO when lookup finds nothing
When a callsign resolves only to cty.dat (not on QRZ/HamQTH, or no lookup service
configured) — or the lookup errors — enrich the entry from the most recent QSO
already in the log with that call. Fills ONLY the fields the provider left empty
and the operator hasn't edited (name, QTH, grid, country, address, state, county,
lat/lon, zones, continent, email, QSL-via), so a real QRZ/HamQTH hit is never
overridden. Uses the worked-before entries (qso_date DESC, [0] = latest) via a
live ref so it works regardless of the lookup/worked-before debounce ordering.
2026-07-25 15:49:23 +02:00
rouggy 3e7f3832e0 chore: release v0.21.1 2026-07-25 13:54:58 +02:00
rouggy 34b60f9f20 fix(zoom): use transform:scale instead of CSS zoom to kill map tile seams
CSS `zoom` re-lays-out and pixel-rounds each element, which opened ~1px seams
between map tiles (a faint grid over the map when zoomed). Switch the persistent
UI zoom to `transform: scale(z)` from the app root's top-left, with the root
counter-sized to (100/z)vw × (100/z)vh so it reflows to fill and scales back to
the window. transform scales the subtree as one composited layer, so tiles stay
seamless. Removed the now-unneeded tile-overlap CSS hack.
2026-07-25 13:34:53 +02:00
rouggy b7bfd39652 fix(zoom): hide Leaflet tile seams that appear when the map is CSS-zoomed
Under our CSS zoom the map tiles scale by a fractional factor, opening ~1px gaps
between adjacent tiles that read as a faint grid over the map. Tag the document
with data-uizoom and, only while zoomed, enlarge each 256px tile by 1px so
neighbours overlap and cover the seams. The 100% view is untouched.
2026-07-25 13:30:43 +02:00
rouggy 6a1103bf5f fix(zoom): keep the window filled when zoomed out
CSS `zoom` doesn't rescale viewport units, so the 100vh app root left an empty
strip below the UI when zoomed under 100%. Counter-size the app root to
(100/z)vw × (100/z)vh so that, after the zoom scales it back down, it exactly
fills the window again. Zoom still applies to the whole document so portalled
menus/modals scale with it.
2026-07-25 13:25:05 +02:00
rouggy 91653bca57 feat: persistent Ctrl+wheel zoom, saved award-column widths, F9 + hide-empty CW macros
- Zoom: our own Ctrl+wheel zoom (CSS zoom on the root, 50–250%), persisted in
  localStorage and restored at startup; Ctrl+0 resets. Replaces the non-persistent
  native WebView2 zoom. The freq-digit wheel now ignores Ctrl so it passes through
  to zoom.
- Award column widths: they were stripped from AG Grid's saved column state (that
  strip fixes a visibility desync) which also dropped their width. Now persisted
  separately per award code (localStorage + portable DB copy) and re-applied on
  rebuild/reopen.
- CW keyer widget: macros padded to 9 (F1–F9 slot) and empty macros hidden like
  the voice keyer, with the F-number kept tied to the real macro index so the
  F-key shortcuts still line up. New QRZ? default for F9.
2026-07-25 13:14:05 +02:00
rouggy 6be0f43dd0 style(settings): drop the verbose CW Keyer explanations (Flex/Icom notes, ESM hint)
They took vertical space in the CW Keyer panel for text operators already know.
Kept the actionable CAT-backend mismatch warnings and the checkbox labels.
2026-07-25 13:05:42 +02:00
rouggy 370fde42f7 feat: ESM (Enter Sends Message) for CW keyers, N1MM-style
With ESM enabled (Settings → CW Keyer), the keyer active and mode CW, Enter in
the entry strip fires a macro by QSO stage instead of logging:
- callsign field empty            → F1 (CQ)
- callsign entered (in call field) → F2 (report), then focus jumps to RST-sent
- Enter in RST-sent / RST-rcvd     → F3 (TU), which logs via its own <LOGQSO>

Works for every keyer engine (WinKeyer / serial DTR-RTS / Flex CWX / Icom CI-V)
since it routes through the shared macro sender. Enter in other fields still logs
normally, and ESM off keeps the classic Enter-to-log behaviour.

Backend: new `esm` flag on WinkeyerSettings (winkeyer.esm). Frontend: esmHandleEnter
state machine keyed off data-esm markers on the call/RST blocks, a Settings
checkbox + hint, and i18n EN/FR.
2026-07-25 12:38:09 +02:00
rouggy e5ff30823d feat: scroll-tune the top frequency readout (100/10/1 kHz per digit)
Rolling the mouse wheel over the hundreds / tens / units-of-kHz digit of the
header (and compact top-bar) frequency steps the frequency by 100 / 10 / 1 kHz
(wheel up = higher). The display updates optimistically on every notch and the
rig QSYs over CAT, debounced so a fast scroll doesn't flood the link. Only the
kHz digits are wheel-sensitive; MHz and Hz stay static.

New FreqWheelDisplay component (replaces the plain fmtFreqDots span in both the
full header and the compact top bar) + nudgeFreqHz handler.
2026-07-25 12:22:17 +02:00
rouggy 9e86d57dac style(flex): pipe separator between S-value and dBm on the S-meter 2026-07-25 11:48:19 +02:00
rouggy 00bfee4ed2 refactor(flex): single shared MeterBar so Flex/amp/tuner meters are identical
The FlexRadio panel, AmpCard and TunerCard each carried their own copy of the
MeterBar (LED-bar) component. Even small drift between the copies made the tuner
meters look slightly off in height/LED size vs the others. Extracted one shared
components/MeterBar.tsx (segments, bar height, padding) and imported it in all
three, so every meter renders at exactly the same size. No behaviour change.
2026-07-25 11:44:23 +02:00
rouggy f4bc55cd41 fix(flex): link TX/RX collapse, equal-size meters, phone-only MIC/COMP, inline S-meter dBm
FlexRadio panel refinements from feedback:
- TRANSMIT and RECEIVE now share one collapse state — folding either folds both
  (Card gained an optional controlled open/onToggle mode; the parent owns the
  shared txrx state, persisted).
- Meter sizes: the Tuner Genius PWR/SWR meters used a 2-column grid (wider than
  the Flex/amp meters). Switched to the same grid-cols-2 sm:grid-cols-3 so every
  meter across the METERS, AMPLIFIER and TUNER cards is the same width.
- MIC and COMP meters are hidden outside phone modes (shown for SSB/AM/FM only).
- S-meter dBm moved inline next to the S-value (was a second line under the bar),
  and the redundant dBm line under PWR removed — keeps only the watts. Saves height.
2026-07-25 11:31:26 +02:00
rouggy 3a9dda13c4 feat: collapsible Flex/amp/tuner cards, matched meter sizes, faster + distinct-icon TGXL
Addresses three points of feedback on the Tuner Genius work:

- Meter sizes: the amplifier meters were rendered `compact` (smaller than the
  FlexRadio meters). Dropped compact so the amp, tuner and Flex meters are all
  the same size.
- Collapsible cards: the FlexRadio panel Card, AmpCard and TunerCard now fold
  from a chevron in the header, state persisted per card (opslog.cardOpen.*).
  The amplifier cards share the "amplifier" collapse key across their SPE/ACOM/
  PGXL variants so folding sticks regardless of the shown model.
- TGXL responsiveness: the tuner's device poll dropped 1500ms→400ms and the
  three UI pollers 1500ms→500ms, so the SWR/power meters track TX without the
  2–3s lag behind the amplifier the user saw.
- Icon: the Tuner Genius top-bar toggle used Zap, same as the CW keyer — changed
  the tuner's icon (top bar + widget + card) to Gauge so the two are distinct.
2026-07-25 11:22:56 +02:00
rouggy 9b677c6b35 feat: Tuner Genius XL — A/B channels, plus FlexRadio panel + Station Control cards
Push the tuner control further so it mirrors the native 4O3A app and the way the
PowerGenius XL is surfaced.

Backend (internal/tunergenius):
- Status now carries both RF channels A and B (source/mode, band, frequency,
  bound Flex nickname, per-channel bypass, antenna, PTT), the active channel,
  the C1/L/C2 relay-network positions, and the 3-way-vs-SO2R hardware variant
  (learned once from the `info` reply). Flat freq/antenna still mirror the active
  channel for the compact widget.
- New TunerGeniusActivate(ch) binding → `activate ch=N` (or `ant=N` on 3-way).

Frontend:
- New shared TunerCard, styled exactly like AmpCard (PWR/SWR meter bars,
  A/B channel selector with source+freq+antenna, Tune/Bypass/Operate). Used in
  BOTH the FlexRadio panel (its own card, like the PGXL) and Station Control.
- Docked TunerGeniusPanel widget now shows the two channels A/B with their
  source/frequency/antenna and lets you click to make one active — the missing
  A/B state the user flagged.
- i18n EN/FR for the new labels (channels, antenna, in-line/bypassed, title).

Still UNTESTED on hardware — verify the per-channel field names/units and the
activate behaviour on the real box.
2026-07-25 10:47:11 +02:00
rouggy 933d601c03 feat: control the 4O3A Tuner Genius XL directly over TCP (port 9010)
New internal/tunergenius client speaking the same "Genius Series" text API
as the Antenna Genius / PowerGenius XL: banner on connect (with optional
"AUTH" for remote access), "C<seq>|<cmd>\n" commands, "R<seq>|<code>|<msg>"
replies and the "S<seq>|status k=v …" snapshot; async "M|<msg>" info lines
are consumed inline. Polls status ~1.5s and exposes SWR (return-loss dB
converted to a VSWR ratio), forward power (dBm→W), and the operate/bypass/
tuning/active-channel state. Actions: autotune, global bypass, operate/standby.

Controlled directly (not via the radio) so OpsLog uses only one of the box's
four connection slots, per the device's protocol doc.

Wiring:
- app.go: tunergenius.* settings keys, Get/Save/start, GetTunerGeniusStatus,
  TunerGeniusAutotune/SetBypass/SetOperate; started in the background at launch.
- Settings → Tuner Genius panel (enable + IP + optional remote code).
- Docked TunerGeniusPanel widget (SWR/power readouts + Tune/Bypass/Operate),
  top-bar toggle, shown when enabled — mirrors the Antenna Genius widget.
- i18n EN/FR (sec.tunergenius, tg2.*, tgp.*).

Command verbs (operate/bypass/autotune/status/auth) come straight from the
4O3A "Tuner Genius XL — Protocol Description"; UNTESTED on hardware.
2026-07-25 10:32:00 +02:00
rouggy b4f0e0bc29 fix: clear entry-strip award refs when the callsign changes (not only on wipe)
award_refs was only reset when the callsign was emptied, so swapping from one
call to another (e.g. clicking successive spots) kept the previous station's
references in the F3 Awards tab. Move the reset to just past the same-call guard
so any change clears them; the new call's spot POTA + live detection re-populate
right after. Changelog 0.21.1.
2026-07-25 09:24:09 +02:00
rouggy 9f384402fa docs: Getting Started — fill station info + operating conditions before importing
Note that entering address/city/state and per-band rig & antenna, then ticking
"Fill my station fields from my profile" on ADIF import, backfills the MY_* fields
automatically for a fully-described log.
2026-07-25 09:15:44 +02:00
rouggy 18b69ee8b4 ui: drop the "← pick a reference" placeholder in the award ref selector
Empty-state dashed box added clutter; show nothing until a reference is picked.
2026-07-25 02:48:06 +02:00
rouggy 8c1b7af5b3 ui: compact the "will count for" detected-awards list in QSO details
In the QSO details Awards tab, the detected-refs line grew tall (DXCC/WAS/WAZ/
WAC/WPX/USA-CA… each with its full name), squishing the AwardRefSelector above so
you couldn't see the selected awards. Show just CODE@REF chips (full name on
hover), cap the block height with overflow scroll, and add a separator. Changelog 0.21.1.
2026-07-25 02:32:42 +02:00
rouggy 6e953ab1f4 feat: Ctrl+Up/Down hops spot-to-spot on the Main-view band map
Add a keyNav prop to BandMap: when set (only the docked Main-view map, not the
multi-band Band Map tab), Ctrl+ArrowUp / Ctrl+ArrowDown selects the next in-band
spot above / below the rig frequency and tunes to it via the existing spot-click
handler. Higher freq is up on the map (freqToY), so Up = next higher spot.
Ignored while typing in an input. Changelog 0.21.1.
2026-07-25 02:05:51 +02:00
rouggy 88202efddb ui: Station Control masonry layout — pack cards into balanced columns
flex-wrap with fixed-width cards started each new row below the TALLEST card of
the previous row, leaving big gaps under short panels (e.g. the amplifier alone
on a second line). Switch the dashboard to balanced CSS columns (column-width
430px, break-inside-avoid): cards flow top-to-bottom and pack tightly by height.
"Auto" fits as many columns as the window allows; 1-4 cap the column count via
the container max-width. Grip-drag reorder unchanged. Changelog 0.21.1.
2026-07-25 01:49:18 +02:00
rouggy 3f15608c59 fix: ADIF export field picker All/None buttons dead — hoist GroupCard
GroupCard was defined inside ExportFieldsDialog, so it got a new component
identity every render and React remounted the whole grid on each sel change,
making the per-group All/None buttons (and checkboxes) feel unresponsive. Hoist
GroupCard to module scope with sel + handlers passed as props. Changelog 0.21.1.
2026-07-25 00:51:32 +02:00
rouggy e6a6f04ccf docs: move theme fix to a new 0.21.1 (0.21.0 released) + shorten the entry 2026-07-24 19:21:32 +02:00
rouggy 3b1a8ef01a fix: theme sometimes reverts on reopen — gate per-profile UI prefs on scope
The theme (and other per-profile UI prefs) are read via a.settings.Get, which is
scoped to the active profile. GetUIPref only guarded on a.settings==nil, so in
the startup window AFTER NewStore but BEFORE SetProfile(active) it resolved with
the wrong (empty) scope. The frontend theme self-heal treats a resolved ""  as
"answered, unset → keep default, stop retrying", so a race landed on the light
default and stayed. Add a settingsScoped atomic flag set right after
SetProfile; GetUIPref/SetUIPref return "not ready" until then, so the frontend
keeps retrying and restores the real theme. Also prevents seeding prefs into the
wrong profile scope. Changelog entry added to 0.21.0 (EN+FR).
2026-07-24 19:14:26 +02:00
rouggy fd097a647f docs: un-mix 0.21.0 and 0.20.12 — 0.21.0 holds only the architecture change
0.20.12 was already released; restore its 11 entries under 0.20.12 and keep only
the two settings/logbook-split entries under 0.21.0.
2026-07-24 18:40:39 +02:00
rouggy b2bd818ac4 chore: release v0.21.0 2026-07-24 18:32:27 +02:00
rouggy d71d09cbb6 docs: bump upcoming release to 0.21.0 + prominent architecture-change warning
Big architecture change (settings/logbook DB split) warrants a minor bump. Rename
the unreleased 0.20.12 changelog block to 0.21.0 and prepend a clear ⚠️ warning
(EN+FR) explaining the automatic non-destructive split, that nothing is lost, and
advising a data-folder backup before updating.
2026-07-24 18:31:59 +02:00
rouggy 557fb162c3 ui: tidy Database panel — settings shows only Open-folder; DB actions on logbook
Per feedback: New/Open/Rename/Save-copy/Reset made no sense under the settings
database — moved the meaningful DB actions to the logbook (New database / Open
existing / Rename-relocate). The settings section now shows just its path + Open
folder. Also fixed the "Logbook switched…" confirmation rendering twice (dropped
the duplicate render in the SQLite block; kept the shared one). The unused
settings-db handlers/bindings stay for a later re-add.
2026-07-24 18:30:05 +02:00
rouggy 77a2350240 feat: rename/relocate a profile's SQLite logbook (QSOs carried across)
"Choose a dedicated file" points at a fresh/empty file; there was no way to
rename or move an existing logbook WITH its data. RenameLogbook VACUUM INTOs the
active profile's SQLite logbook to a new path, repoints the profile and switches
the live logbook (no restart). Deletes the old file only when it was a dedicated
per-profile file; the shared default logbook.db is left for other profiles.
Errors on a MySQL logbook. UI: "Rename / relocate…" button in the logbook section.
2026-07-24 18:22:17 +02:00
rouggy 2b3d118d84 feat: split settings DB from the QSO logbook (own file per profile)
Design flaw: opslog.db held BOTH the config (settings + profiles) AND the SQLite
logbook, so the only way to a separate SQLite logbook — the whole-app "change
database location" pointer — swapped the config too, wiping the operator's setup
(reported: creating a new SQLite for a visiting op lost all profiles/settings).

Now the two are separate files:
- Settings database: settings + profiles. Fresh installs name it settings.db;
  existing installs keep opslog.db.
- Logbook (QSOs): a dedicated logbook.db next to the settings db, or a
  per-profile file (profile.ProfileDB.Path), or MySQL. connectLogbook opens the
  logbook file (db.Open creates+migrates on first use); the settings db is used
  as the logbook only if the split ever fails.

One-time, non-destructive migration at startup: if there's no logbook file yet
but the settings db already holds QSOs (legacy combined opslog.db), seed
logbook.db with a clean copy via VACUUM INTO — contacts move to the logbook, the
originals stay in the settings db as an untouched backup.

UI: the Database panel now shows the settings database and this profile's logbook
as two clearly labelled sections (+ "Open folder"), and the logbook selector is
SQLite (default logbook.db, or a dedicated file via "Choose a dedicated file…")
vs MySQL — no more confusing shared/separate choice. New binding RevealDataFolder.
2026-07-24 18:13:08 +02:00
rouggy 638ffcb326 feat: per-profile separate SQLite logbook file (config never swapped)
Design flaw: the SQLite backend conflated the app/config database (opslog.db —
settings + profiles) with the QSO logbook. connectLogbook returned a.db for any
non-MySQL profile, so the ONLY way to get a separate SQLite logbook was the
whole-app "change database location" pointer — which swapped config + profiles
too, wiping the operator's setup (reported: creating Jerem.db lost all configs).

profile.ProfileDB gains a Path field: a non-empty path routes that profile's
logbook to its own SQLite file (db.Open creates + migrates it on first use),
while opslog.db keeps settings/profiles. connectLogbook opens the file; empty
path = shared db (unchanged default, backward compatible). MySQLSettings carries
sqlite_path; Get/Save wire it through.

UI: the Database panel gains a third backend option "SQLite — separate file"
with a file picker, and now clearly labels the shared application database
(settings + profiles) vs this profile's logbook, so the two are never confused.
2026-07-24 17:12:31 +02:00
rouggy bf4fba484a feat: H26 award recognises each canton by its main cities (regex per reference)
Operators rarely write the canton in their address/QTH but do write the city.
Add a per-reference city regex to every one of the 26 cantons (same mechanism as
WAPC): the description match now fires on the canton NAME or any of its main
cities (Genève→GE, Lausanne→VD, Zürich→ZH, Bellinzona→TI, …), with accented and
ASCII/localised spellings. Scoped to DXCC 287 so cross-border city-name clashes
can't misfire. Catalog version bumped 1→2 so the update reaches anyone already
running H26 v1 (unless they've edited it).
2026-07-24 16:29:59 +02:00
rouggy 3ea7f44fd1 telemetry: use station callsign as the PostHog distinct_id (dedupe users)
The random per-install UUID lives in the LOCAL SQLite, so one operator running
OpsLog on several machines (laptop / desktop / Maestro) or after a reinstall
counted as several distinct "users" — making the user total unreliable. Use the
station callsign as distinct_id when set (public in ham radio, stable across all
of an op's machines); the install UUID stays as a fallback and rides along as an
install_id property so per-machine detail isn't lost.
2026-07-24 16:20:23 +02:00
107 changed files with 13510 additions and 1091 deletions
+109
View File
@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
## Commands
```bash
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
wails build # full build → build/bin/OpsLog.exe (~25 s)
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
go build ./... # fast Go-only check
go test ./... # all Go tests
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
gofmt -w <file>
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
```
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task**Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
## Navigating app.go
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
```bash
grep -n "^// ──\|^// ---" app.go # table of contents
```
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
## Architecture
### Wails binding boundary
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
### Two separate databases
This split is easy to get wrong and matters:
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
### Per-profile settings scoping
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
### Hardware device pattern
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
2. **Settings**`key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
4. **Status binding**`Get<Thing>Status()`, polled by the frontend.
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
### CAT
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
### Logging
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
## Conventions
**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.
**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.
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
## Gotchas
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
+4 -4
View File
@@ -4,8 +4,8 @@
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" /> <img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
</a> </a>
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif, bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de **Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
@@ -32,7 +32,7 @@ Développé par **F4BPO**.
## Journalisation ## Journalisation
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator, - **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator,
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST. **drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du - **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
@@ -91,7 +91,7 @@ Développé par **F4BPO**.
matrice des déjà-contactés (Réglages → Général). matrice des déjà-contactés (Réglages → Général).
- Les spots **POTA** sont étiquetés avec leur référence de parc (via - Les spots **POTA** sont étiquetés avec leur référence de parc (via
`api.pota.app`). `api.pota.app`).
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande / - **Alertes de spots :** règles sur indicatif / pays / bande /
mode / spotter, avec notification sonore, visuelle et e-mail (Outils → mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
*Gestion des alertes*). *Gestion des alertes*).
+3 -3
View File
@@ -4,7 +4,7 @@
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" /> <img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
</a> </a>
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and** for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics), remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
@@ -31,7 +31,7 @@ Developed by **F4BPO**.
## Logging ## Logging
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode, - **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
TX/RX frequency (split), start/end time, comment/note. The contacted entity's TX/RX frequency (split), start/end time, comment/note. The contacted entity's
**flag** is shown large next to the RST fields. **flag** is shown large next to the RST fields.
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid - **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
@@ -81,7 +81,7 @@ Developed by **F4BPO**.
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and **digital modes count as one** (DXCC-style) for the new/new-slot colouring and
the worked-before matrix badges (Settings → General). the worked-before matrix badges (Settings → General).
- **POTA** spots are tagged with their park reference (via `api.pota.app`). - **POTA** spots are tagged with their park reference (via `api.pota.app`).
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode / - **Spot alerts:** rules on call / country / band / mode /
spotter, with sound, visual and e-mail notification (Tools → *Alert spotter, with sound, visual and e-mail notification (Tools → *Alert
management*). management*).
+1261 -58
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -26,6 +26,7 @@ var sensitiveSettingKeys = map[string]bool{
keyExtLoTWWebPassword: true, keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true, keyExtHRDLogCode: true,
keyExtEQSLPassword: true, keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
} }
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"testing"
"hamlog/internal/cat"
)
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
// logged, not counted in any award slot, and exports without a BAND field.
//
// One frequency per band, taken inside the range, plus the edges that used to be
// missing entirely.
func TestBandPlansAgree(t *testing.T) {
cases := []struct {
hz int64
want string
}{
{137000, "2190m"},
{475000, "630m"},
{1840000, "160m"},
{3650000, "80m"},
{7100000, "40m"},
{10120000, "30m"},
{14200000, "20m"},
{18100000, "17m"},
{21200000, "15m"},
{24940000, "12m"},
{28400000, "10m"},
{50150000, "6m"},
{70200000, "4m"},
{144300000, "2m"},
{223500000, "1.25m"},
{432000000, "70cm"},
{1296000000, "23cm"},
// The microwave bands that were missing. 3 cm is the one an operator
// reported: 10 GHz is worked and spotted, and resolved to nothing.
{2320000000, "13cm"},
{3400000000, "9cm"},
{5760000000, "6cm"},
{10368000000, "3cm"},
{24048000000, "1.25cm"},
{47088000000, "6mm"},
{76032000000, "4mm"},
{122250000000, "2.5mm"},
{134928000000, "2mm"},
{241920000000, "1mm"},
}
for _, c := range cases {
if got := cat.BandFromHz(c.hz); got != c.want {
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
}
if got := bandForHz(c.hz); got != c.want {
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
}
}
}
// A frequency in no amateur band must resolve to "" everywhere, not to the
// nearest band: guessing here would put a QSO in a band the operator never used.
func TestBandPlanRejectsOutOfBand(t *testing.T) {
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
if got := cat.BandFromHz(hz); got != "" {
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
}
if got := bandForHz(hz); got != "" {
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
"hamlog/internal/qso"
)
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
//
// These two lists live in different packages — a TypeScript field table on one
// side, a Go whitelist on the other — and they drifted apart the day the
// confirmation dates were added: the new columns went into the QSL Manager's
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
// could not write and the operator got "field … is not bulk-editable" only after
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
//
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
// field added to the UI alone fails here immediately.
func TestBulkEditFieldsAreWritable(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
if len(ids) < 10 {
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
}
for _, m := range ids {
id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json.
if id == "freq" || qso.IsBulkEditableExtra(id) {
continue
}
col, ok := bulkFieldColumns[id]
if !ok {
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
continue
}
// Extras live outside the qso table (owner_callsign and friends): they are
// written into extras_json, not a column, so the column whitelist does not
// apply to them.
if strings.HasPrefix(col, "extras.") {
continue
}
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
}
}
}
// Same contract for the filter builder: every field it offers must be one the
// query layer will accept.
func TestFilterFieldsAreQueryable(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
// not, and without it every operator (eq, contains, …) was read as a field.
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
if len(ids) < 10 {
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
}
for _, m := range ids {
col := m[1]
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
t.Errorf("filter builder offers %q, which the query layer refuses", col)
}
}
}
+282 -2
View File
@@ -1,10 +1,290 @@
[ [
{
"version": "0.22.0",
"date": "2026-07-28",
"en": [
"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.",
"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\".",
"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).",
"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.",
"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.",
"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 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.",
"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).",
"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.",
"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 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.",
"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.",
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
"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.",
"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.",
"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 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.",
"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."
],
"fr": [
"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.",
"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 ».",
"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).",
"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.",
"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.",
"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.",
"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.",
"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).",
"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.",
"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 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.",
"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.",
"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.",
"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.",
"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.",
"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 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.",
"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."
]
},
{
"version": "0.21.8",
"date": "2026-07-28",
"en": [
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
],
"fr": [
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
]
},
{
"version": "0.21.7",
"date": "2026-07-28",
"en": [
"The filter builder uses the same confirmation field names as bulk edit, gains the missing QRZ.com received status, and can filter on the sent and received dates (before / after).",
"A fresh install now starts on the dark theme. Existing installs keep the theme they are on.",
"A QSO logged from WSJT-X or MSHV now keeps the precise 6-character locator from QRZ/HamQTH instead of the 4-character one the digital protocol carries — about 100 km of accuracy that was being discarded on every digital QSO. A lookup that disagrees with the received square is not applied.",
"Edit QSO: the QRZ/HamQTH fetch button now bypasses the cache, so it really re-reads the callsign — upgrading a QRZ subscription used to change nothing until the cached entry expired a month later. It also no longer blanks an existing value when the lookup comes back with that field empty.",
"The diagnostic log no longer carries the FlexRadio meter readings, which drowned everything else.",
"OmniRig: a new \"VFO to read\" setting. OmniRig reports the active VFO from the rig file, and some files get it wrong — an IC-7610 file declared VFO B while the operator was on the main VFO, so OpsLog wrote to one VFO and read the other, and the frequency looked frozen. Force VFO A or B when that happens.",
"Bulk edit: fixed \"field is not bulk-editable\" on the QRZ.com received status and the confirmation dates added in 0.21.6 — they were declared in the wrong place and refused at Apply. Bulk-editing State and County, offered since the start, was refused the same way and now works.",
"QRZ.com confirmation download: the whole logbook was requested every time, which QRZ cuts short on a large log — the read stopped two thirds of the way through and the rest was never seen. Only the window is requested now. And the last-download date is no longer moved when the download was incomplete: it used to advance over records that had never been read, which skipped them for good.",
"The callsign in the top bar is now the station switcher: click it and pick a profile to activate it. Managing profiles is still there, at the bottom of the list.",
"The UTC clock moved from the top bar to the status bar at the bottom, beside the database.",
"FlexRadio panel: the built-in ATU is now controllable — Tune, Bypass, memories, and the tuner state in words (tuning, tuned, TUNE FAILED…), because a failed tune and one that never ran look identical otherwise.",
"Diagnostic log: each database migration now names the database it runs on, and a frequency set through OmniRig is checked again 1.5 s later — both to make a reported problem readable from the log instead of guessed at."
],
"fr": [
"Le constructeur de filtres reprend les mêmes noms de champs de confirmation que l'édition en lot, gagne le statut de réception QRZ.com manquant, et permet de filtrer sur les dates d'envoi et de réception (avant / après).",
"Une nouvelle installation démarre désormais sur le thème sombre. Les installations existantes conservent le leur.",
"Un QSO enregistré depuis WSJT-X ou MSHV conserve désormais le locator précis à 6 caractères de QRZ/HamQTH au lieu de celui à 4 caractères transporté par le protocole numérique — une centaine de kilomètres de précision perdus jusqu'ici à chaque QSO numérique. Une réponse qui contredit le carré reçu n'est pas appliquée.",
"Édition QSO : le bouton de récupération QRZ/HamQTH contourne désormais le cache et relit donc réellement l'indicatif — passer à un abonnement QRZ payant ne changeait rien tant que l'entrée en cache n'avait pas expiré un mois plus tard. Il n'efface plus non plus une valeur existante lorsque la recherche revient sans ce champ.",
"Le journal de diagnostic ne contient plus les mesures des instruments FlexRadio, qui noyaient tout le reste.",
"OmniRig : nouveau réglage « VFO à lire ». OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — un fichier IC-7610 déclarait le VFO B alors que l'opérateur était sur le VFO principal, si bien qu'OpsLog écrivait dans l'un et lisait l'autre, et la fréquence semblait figée. Forcez le VFO A ou B le cas échéant.",
"Édition en lot : correction du refus « field is not bulk-editable » sur le statut de réception QRZ.com et les dates de confirmation ajoutées en 0.21.6 — ils étaient déclarés au mauvais endroit et rejetés au moment d'appliquer. L'édition en lot de State et County, proposée depuis toujours, était refusée de la même façon et fonctionne désormais.",
"Téléchargement des confirmations QRZ.com : le carnet entier était demandé à chaque fois, ce que QRZ tronque sur un grand log — la lecture s'arrêtait aux deux tiers et le reste n'était jamais vu. Seule la fenêtre est désormais demandée. Et la date du dernier téléchargement n'avance plus lorsque le téléchargement est incomplet : elle passait par-dessus des enregistrements jamais lus, qui étaient alors ignorés définitivement.",
"L'indicatif en haut de la fenêtre est désormais le sélecteur de station : un clic, on choisit un profil et il devient actif. La gestion des profils reste accessible, en bas de la liste.",
"L'horloge UTC est passée de la barre du haut à la barre d'état en bas, à côté de la base de données.",
"Panneau FlexRadio : le coupleur intégré est désormais pilotable — Accord, Bypass, mémoires, et l'état du coupleur en clair (accord en cours, accordé, ÉCHEC ACCORD…), car sans ça un accord raté et un accord jamais lancé se ressemblent.",
"Journal de diagnostic : chaque migration de base indique désormais sur quelle base elle tourne, et une fréquence envoyée via OmniRig est relue 1,5 s plus tard — de quoi lire un problème signalé dans le journal au lieu de le deviner."
]
},
{
"version": "0.21.6",
"date": "2026-07-27",
"en": [
"Statistics: the activity chart now follows the period you selected, and Day / Week / Month / Year choose the bucket size (QSOs per week over the whole period, for instance). It used to show fixed windows — the last 7 days, the last 30 — regardless of the period.",
"Statistics: new \"When you are on the air\" card — by hour and by day of week, over the selected period.",
"Band map: the CW / data / phone sub-bands are no longer shaded in the status colours — the SSB portion was amber, the same amber that means \"new band\" on the spots above it. They now use distinct, colour-blind-checked hues and appear in the legend.",
"Help menu: a Support OpsLog entry, which opens the donation page in your browser.",
"The Callsign field is now visually marked out, so it is no longer mistaken for Name next to it.",
"Multi-screen: OpsLog reopens on the screen it was closed on, including when it was maximised — it used to come back on the primary screen every time.",
"The Windows title bar is gone: minimise, maximise and close now sit in the OpsLog bar, which you drag to move the window (double-click to maximise). That is 32 pixels of screen back.",
"Saving the settings no longer freezes OpsLog while a device is slow to answer. Choosing OmniRig while another program held the rig locked the window for about 45 seconds — the time OmniRig takes to give up. The link is now re-established in the background, and a slow restart is written to the diagnostic log.",
"Edit QSO → QSL Info: the sent and received dates now have a calendar picker, plus a button for today's date (UTC).",
"CW keyer: the diagnostic log now names the keyer generation (WK1 / WK2 / WK3), and Settings → CW keyer can log every byte exchanged with it — for reporting a keyer that behaves oddly.",
"Bulk edit: the confirmation fields are renamed \"sent/received status\" instead of \"upload\", the missing QRZ.com received status is added, and every channel now offers its sent and received DATE with a calendar."
],
"fr": [
"Statistiques : le graphique d'activité suit désormais la période choisie, et Jour / Semaine / Mois / Année en choisissent le pas (les QSO par semaine sur toute la période, par exemple). Il affichait auparavant des fenêtres fixes — les 7 derniers jours, les 30 derniers — quelle que soit la période.",
"Statistiques : nouvelle carte « Quand vous trafiquez » — par heure et par jour de semaine, sur la période choisie.",
"Carte de bande : les portions CW / numérique / phonie ne sont plus teintées avec les couleurs de statut — la partie SSB était ambre, l'ambre qui signifie « nouvelle bande » sur les spots posés dessus. Elles utilisent maintenant des teintes distinctes, vérifiées pour le daltonisme, et figurent dans la légende.",
"Menu Aide : une entrée « Soutenir OpsLog », qui ouvre la page de don dans votre navigateur.",
"Le champ Indicatif est désormais mis en évidence, pour ne plus être confondu avec le champ Nom voisin.",
"Multi-écrans : OpsLog se rouvre sur l'écran où il a été fermé, y compris s'il était maximisé — il revenait jusqu'ici sur l'écran principal à chaque fois.",
"La barre de titre Windows a disparu : réduire, agrandir et fermer sont désormais dans la barre OpsLog, qu'on saisit pour déplacer la fenêtre (double-clic pour agrandir). Autant de pixels d'écran repris.",
"Enregistrer les réglages ne fige plus OpsLog quand un appareil tarde à répondre. Choisir OmniRig alors qu'un autre logiciel tenait la radio bloquait la fenêtre une quarantaine de secondes — le temps qu'OmniRig renonce. La liaison est désormais rétablie en arrière-plan, et un redémarrage lent est noté dans le journal de diagnostic.",
"Édition QSO → Infos QSL : les dates d'envoi et de réception disposent d'un calendrier, et d'un bouton pour la date du jour (UTC).",
"Keyer CW : le journal de diagnostic nomme désormais la génération du keyer (WK1 / WK2 / WK3), et Réglages → Keyer CW permet de journaliser chaque octet échangé avec lui — pour signaler un keyer au comportement anormal.",
"Édition en lot : les champs de confirmation s'appellent désormais « envoi/réception (statut) » au lieu d'« upload », le statut de réception QRZ.com manquant a été ajouté, et chaque canal propose ses DATES d'envoi et de réception avec un calendrier."
]
},
{
"version": "0.21.5",
"date": "2026-07-27",
"en": [
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
],
"fr": [
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
]
},
{
"version": "0.21.4",
"date": "2026-07-26",
"en": [
"OmniRig on an FTDX101D: the frequency follows the SUB VFO again, and split is detected. The stock rig file never names a single VFO — it reports only the A/B pair and alternates between the two values, split included, from one poll to the next. OpsLog now takes the displayed frequency as the reference on Yaesu and keeps a split reported once for a few seconds, so the contradicting reading no longer cancels it.",
"The SMTP password can no longer be revealed in the settings — the eye button is gone. The settings window is often open while the screen is being shared or shown to someone in the shack.",
"Cloudlog and Wavelog: each QSO can now be uploaded to your own instance as it is logged. Settings → External services → CLOUDLOG: the address of your instance, a read/write API key, the station ID, and a Test connection button. Sending is immediate or delayed by 12 minutes so a mistake can still be corrected.",
"Recent QSOs and Worked before: new Distance (km) column, like the cluster's. It is computed from the QSO's own locator pair, so a contact made portable keeps the distance from where you actually were; QSOs with no locator on either side stay blank.",
"OmniRig: the frequency follows the SUB VFO on radios that report the two VFOs as a pair (AA / AB / BA / BB) — the whole Yaesu range. Only the single-VFO form was recognised, so pressing SUB left OpsLog on the main VFO, and a QSO worked on SUB was logged on the wrong frequency.",
"OmniRig split is held briefly ONLY for a radio whose rig file reports it erratically. On a radio that reports it correctly, split now clears the instant you cancel it on the front panel instead of a few seconds later.",
"The diagnostic log now records what OpsLog concluded from OmniRig (VFO, TX/RX frequencies, split), not just what OmniRig reported.",
"Locator: a precise grid from QRZ/HamQTH is no longer replaced by the country centroid (JN05JG becoming JN16 for a French station). The lookup runs more than once per QSO and the provider only gets two seconds to answer; on a slower connection the second answer fell back to cty.dat and downgraded the locator, while the name and QTH stayed — which made it look like QRZ had returned a wrong grid."
],
"fr": [
"OmniRig sur FTDX101D : la fréquence suit de nouveau le VFO SUB, et le split est détecté. Le fichier radio d'origine ne nomme jamais un VFO seul — il ne rapporte que la paire A/B, et alterne entre les deux valeurs, split compris, d'une interrogation à l'autre. OpsLog prend désormais la fréquence affichée comme référence sur Yaesu et conserve quelques secondes un split annoncé une fois, pour que la lecture contradictoire ne l'annule plus.",
"Le mot de passe SMTP ne peut plus être affiché en clair dans les réglages — le bouton en forme d'œil a été retiré. La fenêtre des réglages est souvent ouverte pendant un partage d'écran ou devant quelqu'un au shack.",
"Cloudlog et Wavelog : chaque QSO peut désormais être envoyé vers votre propre instance au moment où il est enregistré. Réglages → Services externes → CLOUDLOG : l'adresse de votre instance, une clé API lecture/écriture, l'ID de station, et un bouton de test de connexion. L'envoi est immédiat ou différé de 1 à 2 minutes pour laisser le temps de corriger une erreur.",
"QSO récents et Déjà contacté : nouvelle colonne Distance (km), comme celle du cluster. Elle est calculée à partir des locators propres au QSO, si bien qu'un contact fait en portable garde la distance depuis l'endroit où vous étiez réellement ; les QSO sans locator d'un côté ou de l'autre restent vides.",
"OmniRig : la fréquence suit le VFO SUB sur les radios qui rapportent les deux VFO sous forme de paire (AA / AB / BA / BB) — toute la gamme Yaesu. Seule la forme à un seul VFO était reconnue, si bien qu'un passage sur SUB laissait OpsLog sur le VFO principal, et qu'un QSO fait sur SUB était enregistré sur la mauvaise fréquence.",
"Le split OmniRig n'est maintenu brièvement que pour une radio dont le fichier de définition le rapporte de façon erratique. Sur une radio qui le rapporte correctement, le split disparaît désormais dès que vous le coupez en façade, au lieu de quelques secondes plus tard.",
"Le journal de diagnostic enregistre maintenant ce qu'OpsLog a déduit d'OmniRig (VFO, fréquences TX/RX, split), et pas seulement ce qu'OmniRig a rapporté.",
"Locator : un locator précis venu de QRZ/HamQTH n'est plus remplacé par le centre du pays (JN05JG devenant JN16 pour une station française). La recherche est lancée plusieurs fois par QSO et le fournisseur ne dispose que de deux secondes ; sur une connexion plus lente, la seconde réponse retombait sur cty.dat et dégradait le locator, alors que le nom et le QTH restaient — d'où l'impression que QRZ renvoyait un mauvais locator."
]
},
{
"version": "0.21.3",
"date": "2026-07-25",
"en": [
"SteppIR: the direction button no longer snaps back to Normal a few seconds after you select 180° or Bidirectional. OpsLog was reading status frames the controller had queued up minutes earlier; it now flushes them before each poll and holds the direction you chose until the controller confirms it.",
"Alert rules: Enter and Space now work in the callsigns box — you can type a list one call per line again.",
"Fixed the US county database (USA-CA) download, broken since the FCC moved its weekly files on 24 July 2026. OpsLog now locates the file instead of assuming a fixed address, so it keeps working when the FCC reshuffles its download directories.",
"Tuner Genius XL: the power and SWR meters now fill the card instead of leaving a third of it empty. In Station Control the amplifier and tuner cards span the dashboard, so their meters are readable.",
"The Audio devices settings panel is now translated — it was still entirely in English.",
"Award management: the editor no longer spills outside its window. Wide rows now scroll inside the panel, and the button bar wraps instead of stretching the dialog (it overflowed in French, where the labels are longer).",
"Tuner Genius XL: the power and SWR meters now peak-hold like the amplifier's. They were showing raw 400 ms samples, so on SSB they fell to zero between syllables and looked like a dropped connection.",
"Fixed recovering name/QTH/locator from the last QSO when you don't use QRZ/HamQTH: the callsign resolved from cty.dat faster than the logbook could answer, so the recovery found no history and never retried. It could also, on a slow logbook, fill in the PREVIOUS station's details — it is now tied to the callsign it belongs to.",
"Big logbooks: finding a callsign in your history no longer scans the whole log. The query could not use the callsign index — on a 190 000-QSO log that made it ~70x slower, on every keystroke, slow enough that the entry strip gave up before the history arrived.",
"One-off with this update: OpsLog has stored every callsign in your logbook in UPPER CASE — that is what lets the lookup above use the index. It changes letter case and nothing else, and runs once. Because the auto-updater migrates before you get to read this, a copy of your logbook was saved next to it first (logbook.db.pre-0024_normalise_callsign.bak) — delete it once you are happy.",
"The diagnostic log now also records what the entry strip does (auto-fill decisions), so a problem that only happens on your station can be reported from the log file instead of guessed at.",
"Worked-before grid: the right-click menu now offers bulk edit and the ADIF/Cabrillo export of the selected rows, like the Recent QSOs grid — no need to go looking for the same QSOs in the main log to change them.",
"Tuner Genius XL: the SWR meter no longer stays frozen on the last transmission once you stop transmitting.",
"Portable callsigns (F4LYI/M, .../P) are looked up on QRZ/HamQTH again. They need two requests — the full form, then the home call — and the time limit only allowed one, so they always fell back to cty.dat even though the operator was listed.",
"LoTW download: a rejected login now says so, instead of pasting a fragment of the ARRL web page into the error.",
"French UI: field names in the filter builder and bulk edit are back in English. They are ADIF field names — a standard vocabulary — and translating them made it harder to relate a filter to an export or to another logger. The operators and buttons around them stay translated.",
"Bulk edit: added Owner callsign. It was filterable but not bulk-editable, because unlike the other fields it has no dedicated column and lives among the ADIF extras; editing it now merges into those and leaves the rest of them alone.",
"Some installations opened with the window invisible: the saved size was sanity-checked but the saved position never was, so a window last closed on a monitor that is no longer attached reopened off-screen every time, with no way back. The position is now checked against the monitors actually present.",
"Icom console: it now follows the radio, not just drives it. AGC, attenuator, preamp and filter were read once when connecting and never again, so switching AGC from FAST to MID on the rig left the panel showing FAST for good. They are re-read continuously now, one per poll cycle so the CAT link stays free for your own commands.",
"Icom: fixed the CI-V model table. The IC-7800 (6Ah) and IC-7700 (74h) were missing, and their addresses were assigned to the wrong radios — 80h is the IC-7410 and 88h the IC-7100. On an IC-7800 that also meant a 20 dB attenuator button the radio does not have, instead of its real 6/12/18 dB steps.",
"Icom console: the band row now highlights the band the radio is actually on. The buttons only ever sent a frequency and never showed where you were.",
"Voice keyer PTT: the CAT option was labelled \"CAT (OmniRig)\", so operators on an Icom CI-V, FlexRadio or TCI rig assumed it was not for them and fell back to RTS/DTR or VOX. It has always driven whichever CAT backend is active — every one of them supports PTT. It now reads \"CAT — Icom CI-V (USB serial)\" and so on, naming the link you actually configured.",
"OmniRig: the frequency now follows the VFO you are actually on. It always read VFO A when the rig reported one, so pressing SUB VFO on an FTDX101D left OpsLog showing the main VFO — and the band and the logged frequency with it.",
"OmniRig split: the PM_SPLITON flag is now read as a bit rather than compared for exact equality, so a rig that reports it alongside another flag is no longer seen as simplex. Split still requires two distinct VFOs in the same band, which keeps a stale VFO B from faking one.",
"CAT connection failures are now written to the diagnostic log. The status pill condenses everything to a few words (\"OmniRig not found\"), and the real reason — the COM error code, the serial or TCP error — previously existed only in a tooltip, so it never reached a bug report. Logged once per distinct message.",
"OmniRig running as administrator while OpsLog is not (or the reverse) is now detected and named. Windows keeps the two privilege levels apart, so OpsLog could not reach OmniRig even with its window open on screen — and reported \"OmniRig not found\", which sent people hunting for a driver or COM-port fault. It now says to start both the same way, and stops repeating the failure in the log every five seconds."
],
"fr": [
"SteppIR : le bouton de direction ne repasse plus sur Normal quelques secondes après avoir choisi 180° ou Bidirectionnel. OpsLog lisait des trames d'état que le contrôleur avait empilées plusieurs minutes plus tôt ; elles sont désormais purgées avant chaque interrogation, et la direction choisie est conservée jusqu'à confirmation du contrôleur.",
"Règles d'alerte : Entrée et Espace fonctionnent de nouveau dans la zone des indicatifs — on peut ressaisir une liste, un indicatif par ligne.",
"Correction du téléchargement de la base des comtés américains (USA-CA), cassé depuis le déplacement des fichiers hebdomadaires par la FCC le 24 juillet 2026. OpsLog localise désormais le fichier au lieu de supposer une adresse fixe, et continuera donc de fonctionner lors des prochains remaniements de leurs répertoires.",
"Tuner Genius XL : les jauges de puissance et de ROS occupent toute la carte au lieu d'en laisser un tiers vide. Dans Station Control, les cartes ampli et tuner s'étendent sur toute la largeur du tableau de bord pour que leurs jauges soient lisibles.",
"Le panneau de réglages « Périphériques audio » est désormais traduit — il était resté entièrement en anglais.",
"Gestion des diplômes : l'éditeur ne déborde plus de sa fenêtre. Les lignes trop larges défilent à l'intérieur du panneau et la barre de boutons passe à la ligne au lieu d'élargir la boîte de dialogue (elle débordait en français, où les libellés sont plus longs).",
"Tuner Genius XL : les jauges de puissance et de ROS maintiennent la crête, comme celles de l'ampli. Elles affichaient l'échantillon brut toutes les 400 ms et retombaient donc à zéro entre les syllabes en BLU, ce qui ressemblait à une perte de connexion.",
"Correction de la récupération du nom/QTH/locator depuis le dernier QSO quand on n'utilise pas QRZ/HamQTH : l'indicatif était résolu par cty.dat plus vite que le log ne répondait, la récupération ne trouvait donc aucun historique et ne réessayait jamais. Sur un log lent, elle pouvait aussi reprendre les données de la station PRÉCÉDENTE — elle est désormais liée à l'indicatif concerné.",
"Gros logs : retrouver un indicatif dans l'historique ne parcourt plus tout le log. La requête ne pouvait pas utiliser l'index sur l'indicatif — sur un log de 190 000 QSO, ~70× plus lent que nécessaire, à chaque frappe, et assez lentement pour que la saisie renonce avant l'arrivée de l'historique.",
"Une seule fois, avec cette mise à jour : OpsLog a mis tous les indicatifs de votre log en MAJUSCULES — c'est ce qui permet à la recherche ci-dessus d'utiliser l'index. Seule la casse des lettres change, et l'opération ne se produit qu'une fois. Comme la mise à jour automatique s'exécute avant que vous puissiez lire ceci, une copie de votre log a été enregistrée à côté au préalable (logbook.db.pre-0024_normalise_callsign.bak) — supprimez-la quand vous serez rassuré.",
"Le journal de diagnostic enregistre désormais aussi ce que fait le bandeau de saisie (décisions de remplissage automatique), pour qu'un problème qui n'arrive que chez vous puisse être rapporté depuis le fichier de log au lieu d'être deviné.",
"Grille « Déjà contacté » : le menu du clic droit propose maintenant la modification en masse et l'export ADIF/Cabrillo des lignes sélectionnées, comme la grille des QSO récents — plus besoin d'aller rechercher les mêmes QSO dans le log principal pour les modifier.",
"Tuner Genius XL : la jauge de ROS ne reste plus figée sur la dernière émission une fois que vous cessez d'émettre.",
"Les indicatifs portables (F4LYI/M, .../P) sont de nouveau trouvés sur QRZ/HamQTH. Ils nécessitent deux requêtes — la forme complète, puis l'indicatif de base — et le délai n'en autorisait qu'une : on retombait donc toujours sur cty.dat alors que l'opérateur y était bien référencé.",
"Téléchargement LoTW : un login refusé est désormais annoncé comme tel, au lieu de recopier un fragment de la page web de l'ARRL dans l'erreur.",
"Interface française : les noms de champs du constructeur de filtres et de la modification en masse repassent en anglais. Ce sont des noms de champs ADIF — un vocabulaire standard — et les traduire compliquait le rapprochement avec un export ou un autre logiciel. Les opérateurs et les boutons autour restent traduits.",
"Modification en masse : ajout de « Owner callsign ». Il était filtrable mais pas modifiable en masse car, contrairement aux autres champs, il n'a pas de colonne dédiée et vit parmi les champs ADIF supplémentaires ; sa modification s'y intègre désormais sans toucher aux autres.",
"Chez certains, la fenêtre s'ouvrait invisible : la taille enregistrée était contrôlée mais jamais la position, si bien qu'une fenêtre fermée sur un écran depuis débranché se réouvrait hors champ à chaque fois, sans retour possible. La position est désormais vérifiée contre les écrans réellement présents.",
"Console Icom : elle suit désormais la radio et ne fait plus que la piloter. AGC, atténuateur, préampli et filtre étaient lus une seule fois à la connexion et plus jamais ensuite : passer l'AGC de FAST à MID sur le poste laissait le panneau sur FAST définitivement. Ils sont maintenant relus en continu, un par cycle d'interrogation pour laisser la liaison CAT libre pour vos propres commandes.",
"Icom : correction de la table des modèles CI-V. L'IC-7800 (6Ah) et l'IC-7700 (74h) étaient absents, et leurs adresses attribuées aux mauvais postes — 80h est l'IC-7410 et 88h l'IC-7100. Sur un IC-7800, cela donnait aussi un bouton d'atténuateur 20 dB que la radio ne possède pas, au lieu de ses vrais crans 6/12/18 dB.",
"Console Icom : la rangée des bandes met en évidence celle sur laquelle le poste se trouve réellement. Les boutons ne faisaient qu'envoyer une fréquence, sans jamais indiquer où l'on était.",
"PTT du manipulateur vocal : l'option CAT était libellée « CAT (OmniRig) », si bien que les opérateurs en Icom CI-V, FlexRadio ou TCI la croyaient hors de portée et se rabattaient sur RTS/DTR ou le VOX. Elle a toujours piloté le backend CAT actif, quel qu'il soit — tous gèrent le PTT. Elle affiche désormais « CAT — Icom CI-V (USB série) » et ainsi de suite, en nommant la liaison réellement configurée.",
"OmniRig : la fréquence suit désormais le VFO réellement actif. Le VFO A était toujours lu dès que le poste en rapportait un, si bien qu'appuyer sur SUB VFO sur un FTDX101D laissait OpsLog sur le VFO principal — et avec lui la bande et la fréquence enregistrée.",
"Split OmniRig : le drapeau PM_SPLITON est lu comme un bit au lieu d'être comparé par égalité exacte, donc un poste qui le rapporte accompagné d'un autre drapeau n'est plus vu comme simplex. Le split exige toujours deux VFO distincts sur la même bande, ce qui évite qu'un VFO B périmé en simule un.",
"Les échecs de connexion CAT sont désormais écrits dans le journal de diagnostic. La pastille d'état condense tout en quelques mots (« OmniRig not found ») et la vraie raison — le code d'erreur COM, l'erreur série ou TCP — n'existait que dans une infobulle : elle n'arrivait donc jamais jusqu'à un rapport de bug. Journalisé une fois par message distinct.",
"OmniRig lancé en administrateur alors qu'OpsLog ne l'est pas (ou l'inverse) est désormais détecté et nommé. Windows sépare les deux niveaux de privilège : OpsLog ne pouvait donc pas atteindre OmniRig, fenêtre ouverte à l'écran, et annonçait « OmniRig not found » — de quoi partir chercher un problème de pilote ou de port COM. Il indique maintenant de lancer les deux de la même façon, et cesse de répéter l'échec dans le journal toutes les cinq secondes."
]
},
{
"version": "0.21.2",
"date": "2026-07-25",
"en": [
"Fixed OpsLog disrupting another Flex client: it could bind to 'SmartSDR CAT' (or DAX) instead of the real GUI client, which made SmartSDR CAT keep disconnecting/reconnecting from the radio while OpsLog was open. OpsLog now only binds to the actual SmartSDR/Maestro GUI client (e.g. a FlexRadio 'M' integrated screen).",
"New: Super Check Partial + N+1 callsign helper (like N1MM/DXLog). Enable it in Settings → General to download the community MASTER.SCP list; a docked two-column widget then shows, as you type a call, the known calls that contain it (Partial) and the calls one character away (N+1) — click one to fix a busted call.",
"When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins."
],
"fr": [
"Correction : OpsLog pouvait perturber un autre client Flex — il pouvait se « binder » sur « SmartSDR CAT » (ou DAX) au lieu du vrai client GUI, ce qui faisait décrocher/reconnecter SmartSDR CAT de la radio en boucle tant qu'OpsLog était ouvert. OpsLog ne se binde désormais qu'au vrai client GUI SmartSDR/Maestro (ex. l'écran intégré d'un FlexRadio « M »).",
"Nouveau : assistant indicatifs Super Check Partial + N+1 (façon N1MM/DXLog). Active-le dans Réglages → Général pour télécharger la liste communautaire MASTER.SCP ; un widget ancré en 2 colonnes affiche alors, pendant que tu tapes, les indicatifs connus qui contiennent ta saisie (Partiel) et ceux à une lettre près (N+1) — clique pour corriger un call busté.",
"Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire."
]
},
{
"version": "0.21.1",
"date": "2026-07-24",
"en": [
"Ctrl + mouse wheel zoom is now remembered across restarts (Ctrl+0 resets to 100%).",
"Log grid: award column widths are now saved like the other columns, so a width you set survives a restart.",
"CW keyer widget: added an F9 macro slot, and empty macros are now hidden (like the voice keyer) — fill them in Settings → CW Keyer.",
"New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains <LOGQSO>.",
"The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.",
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
"FlexRadio panel tidy-up: cards collapse from the chevron in their header (Transmit and Receive fold together), all meters are the same size, the MIC and COMP meters only show in phone modes, and the S-meter dBm now sits next to the S-value instead of on a second line.",
"Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.",
"ADIF export field picker: the per-group All / None buttons work again.",
"Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.",
"Main-view band map: Ctrl+↑ / Ctrl+↓ jumps to the next spot above / below the current frequency and tunes to it.",
"QSO details → Awards: the 'this contact will count for' list is now compact (CODE@REF chips, full name on hover) with a capped height, so it no longer hides the awards you've selected.",
"Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references."
],
"fr": [
"Le zoom Ctrl + molette est maintenant conservé après un redémarrage (Ctrl+0 remet à 100 %).",
"Grille du log : les largeurs des colonnes de diplômes sont désormais sauvegardées comme les autres colonnes, une largeur réglée survit au redémarrage.",
"Widget keyer CW : ajout d'un emplacement de macro F9, et les macros vides sont maintenant masquées (comme le keyer vocal) — remplis-les dans Réglages → Keyer CW.",
"Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient <LOGQSO>.",
"L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.",
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
"Nettoyage du panneau FlexRadio : les cartes se replient via le chevron de leur en-tête (Transmit et Receive se replient ensemble), tous les meters ont la même taille, les meters MIC et COMP ne s'affichent qu'en phonie, et le dBm du S-mètre est maintenant à côté de la valeur S plutôt que sur une deuxième ligne.",
"Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.",
"Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.",
"Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.",
"Band map de l'écran principal : Ctrl+↑ / Ctrl+↓ saute au spot suivant au-dessus / en dessous de la fréquence courante et s'y accorde.",
"Détails du QSO → Diplômes : la liste « ce contact comptera pour » est maintenant compacte (pastilles CODE@REF, nom complet au survol) et de hauteur limitée, elle ne masque plus les diplômes sélectionnés.",
"Correction des références de diplômes (onglet Diplômes de la bande de saisie) qui ne se vidaient pas au changement d'indicatif — cliquer un spot puis un autre ne garde plus les références de la station précédente."
]
},
{
"version": "0.21.0",
"date": "2026-07-24",
"en": [
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it)."
],
"fr": [
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent)."
]
},
{ {
"version": "0.20.12", "version": "0.20.12",
"date": "2026-07-24", "date": "2026-07-24",
"en": [ "en": [
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (Interlock is preventing transmission). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the follow the rig loop re-sent a tune command on almost every poll, and each command re-armed the block TX while the antenna moves window — so the interlock never released. The follow loop now keys its deadband off the rigs frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an interlock set reason= command that SmartSDR rejects (its read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR Tunable range setting (Settings → Hardware → Antenna, default 1354 MHz = 20 m6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m6 m SteppIR no longer tries to move it or touches the interlock.", "Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (Interlock is preventing transmission). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the follow the rig loop re-sent a tune command on almost every poll, and each command re-armed the block TX while the antenna moves window — so the interlock never released. The follow loop now keys its deadband off the rigs frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an interlock set reason= command that SmartSDR rejects (its read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR Tunable range setting (Settings → Hardware → Antenna, default 1354 MHz = 20 m6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m6 m SteppIR no longer tries to move it or touches the interlock.",
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA). Ships with the full canton reference list; matches the canton from the QSO's address or QTH, on HF, for Swiss (HB) contacts. Enable it in Awards and rescan to see your standings.", "New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.", "FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.", "ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.", "Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
@@ -17,7 +297,7 @@
], ],
"fr": [ "fr": [
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.", "Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA). Livré avec la liste complète des cantons ; il reconnaît le canton depuis l'adresse ou le QTH du QSO, en HF, pour les contacts suisses (HB). Active-le dans les Diplômes et relance un scan pour voir ton avancement.", "Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.", "Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.", "L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.", "Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
+688 -85
View File
File diff suppressed because it is too large Load Diff
+23 -7
View File
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
const { t } = useI18n(); const { t } = useI18n();
const [rules, setRules] = useState<Rule[]>([]); const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null); const [draft, setDraft] = useState<Rule | null>(null);
// Raw text of the callsigns box, kept separately from draft.calls. The list is
// normalised (trim + drop empties) on the way into the rule, so binding the
// textarea straight to calls.join('\n') round-trips every keystroke through
// that normalisation — a trailing newline or space is stripped before React
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
// deriving the list from it keeps typing intact.
const [callsText, setCallsText] = useState('');
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select) const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState(''); const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState(''); const [err, setErr] = useState('');
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
}, []); }, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]); useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
// loadDraft opens a rule in the editor — always through here, so the raw
// callsigns text is re-seeded from whatever rule is now being edited.
const loadDraft = (r: Rule | null) => {
setDraft(r);
setCallsText((r?.calls ?? []).join('\n'));
};
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d)); const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => { const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d; if (!d) return d;
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
async function save() { async function save() {
if (!draft) return; if (!draft) return;
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; } if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); } try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function del() { async function del() {
if (!draft) return; if (!draft) return;
if (!draft.id) { setDraft(null); return; } if (!draft.id) { loadDraft(null); return; }
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return; if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); } try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md"> <div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60"> <div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button> <Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-1"> <div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>} {rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
{rules.map((r) => ( {rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }} <button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5', className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}> draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} /> <span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<Label className="text-xs">{t('altm.callsigns')}</Label> <Label className="text-xs">{t('altm.callsigns')}</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none" <textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'} placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')} value={callsText}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} /> onChange={(e) => {
setCallsText(e.target.value);
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
}} />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">{t('altm.countries')}</Label> <Label className="text-xs">{t('altm.countries')}</Label>
+18 -48
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { Flame } from 'lucide-react'; import { Flame, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App'; import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel, // AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
@@ -12,51 +13,20 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps // Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
// can each get their own card. // can each get their own card.
const METER_SEGMENTS = 26; function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: { const storeKey = 'opslog.cardOpen.' + (ckey || title);
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string; const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
segColor?: (frac: number) => string; compact?: boolean; const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2')}>
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return ( return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <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"> <button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} /> <Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span> <span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div> <ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
<div className="p-3 space-y-3">{children}</div> </button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div> </div>
); );
} }
@@ -95,7 +65,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
if (isSPE) { if (isSPE) {
const spe = amp.spe; const spe = amp.spe;
return ( return (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c"> <Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})} onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
@@ -151,7 +121,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
if (isACOM) { if (isACOM) {
const acom = amp.acom; const acom = amp.acom;
return ( return (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c"> <Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!acom.connected} <button type="button" disabled={!acom.connected}
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})} onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
@@ -199,7 +169,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
const connected = !!pg.connected || viaFlex; const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault; const fault = flex?.amp_fault;
return ( return (
<Card icon={Flame} title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c"> <Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected} <button type="button" disabled={!connected}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
@@ -242,7 +212,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => { {amps.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) { if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />; return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
} }
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
let lo = m.lo, hi = m.hi; let lo = m.lo, hi = m.hi;
@@ -250,7 +220,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
lo = 0; lo = 0;
hi = m.hi >= 25 ? m.hi : 25; hi = m.hi >= 25 ? m.hi : 25;
} }
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />; return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})} })}
</div> </div>
); );
+16 -6
View File
@@ -63,7 +63,7 @@ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }, { id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' }, { id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
]; ];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm']; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE']; const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL']; const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
@@ -377,7 +377,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden"> <div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
{/* Left: award list */} {/* Left: award list */}
<div className="border-r flex flex-col min-h-0"> <div className="border-r flex flex-col min-w-0 min-h-0">
<div className="p-2 border-b"> <div className="p-2 border-b">
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -422,8 +422,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</Button> </Button>
</div> </div>
{/* Right: tabbed editor for selected award */} {/* Right: tabbed editor for selected award.
<div className="flex flex-col min-h-0 overflow-hidden"> min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
track has min-width:auto — it refuses to shrink below its content's
intrinsic width, so a wide row (the band/mode chip lists, a long
translated label) grew the track and pushed the whole editor out
past the dialog instead of scrolling inside it. Same reason on the
Tabs below: a flex item defaults to min-width:auto too. */}
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>} {err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT {/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say apply it — that would destroy their work — so we offer it, and say
@@ -459,7 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{!cur ? ( {!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div> <div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : ( ) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden"> <Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start"> <TabsList className="px-3 justify-start">
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger> <TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger> <TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
@@ -729,7 +735,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div> </div>
</div> </div>
<DialogFooter className="px-5 py-3 border-t !flex-row"> {/* Eight buttons on one unwrappable row fitted in English and overflowed
in French, where the labels are half again as long. Wrap instead of
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
margin-left approach leaves no vertical gap once a row wraps. */}
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button> <Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}> <Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')} <Download className="size-3.5 mr-1" /> {t('awed.export')}
+2 -6
View File
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span> <span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div> </div>
{/* Selected ref chip */} {/* Selected ref chip (nothing shown until one is picked) */}
{selectedRef ? ( {selectedRef && (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0"> <div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span> <span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span> <span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<X className="size-3" /> <X className="size-3" />
</button> </button>
</div> </div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
{t('awrs.pickReference')}
</div>
)} )}
{/* Add — references are always scoped to the contacted DXCC */} {/* Add — references are always scoped to the contacted DXCC */}
+83 -17
View File
@@ -41,6 +41,10 @@ interface Props {
// globally from the band-map tab toolbar. // globally from the band-map tab toolbar.
hideDigital?: boolean; hideDigital?: boolean;
fitToBand?: boolean; fitToBand?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
// frequency (and tune to it). Only the docked Main-view band map sets this, so
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
keyNav?: boolean;
} }
const BAND_RANGES: Record<string, [number, number]> = { const BAND_RANGES: Record<string, [number, number]> = {
@@ -60,25 +64,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
'70cm': [430000, 440000], '70cm': [430000, 440000],
}; };
// Sub-band shading: CW / digital / phone.
//
// These are IDENTITIES (which mode the segment is for), not states, so they take
// categorical hues — never the status tokens. They used to use success / info /
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
// is "new band" in this very component's legend, so the whole SSB portion read as
// a giant "new band" wash. Status colours are reserved for status.
//
// All three are drawn from the COOL end of the categorical order and laid down at
// low opacity, so the band plan stays background context while the warm spot pills
// keep the foreground to themselves.
// Checked with the palette validator in BOTH themes. Violet was the first pick
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
// protan reader there, i.e. the same colour. Magenta clears every check on both
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
const SEG_CW = 'var(--chart-7)'; // magenta
const SEG_DIGI = 'var(--chart-1)'; // blue
const SEG_PHONE = 'var(--chart-2)'; // aqua
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;
const SEGMENT_COLORS: Record<string, [number, number, string][]> = { const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']], '160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']], '80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, 'fill-warning-muted']], '60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']], '40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']], '30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']], '20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']], '17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']], '15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']], '12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']], '10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']], '6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
}; };
// Small coloured dot + label used in the band-map legend strip. // Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) { function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return ( return (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} /> <span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
{label} {label}
</span> </span>
); );
@@ -153,7 +179,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq). // last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30; const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) { export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const range = BAND_RANGES[band]; const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? []; const segments = SEGMENT_COLORS[band] ?? [];
@@ -362,6 +388,38 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2); scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
} }
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0; const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
const showRigPointer = currentKHz >= lo && currentKHz <= hi; const showRigPointer = currentKHz >= lo && currentKHz <= hi;
const rigY = freqToY(currentKHz); const rigY = freqToY(currentKHz);
@@ -411,10 +469,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
height={totalH} height={totalH}
preserveAspectRatio="none" preserveAspectRatio="none"
> >
{segments.map(([s, e, cls], i) => { {segments.map(([s, e, colour], i) => {
const y1 = freqToY(Math.min(e, hi)); const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo)); const y2 = freqToY(Math.max(s, lo));
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />; return (
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
fill={colour} fillOpacity={SEG_OPACITY} />
);
})} })}
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} /> <line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg> </svg>
@@ -523,7 +584,12 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} /> <LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} /> <LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} /> <LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} /> <LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
</div> </div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0"> <div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')} {t('bmp.footerHint')}
+50 -9
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text' | 'freq'; type FieldKind = 'status' | 'text' | 'freq' | 'date';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean }; type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input. // Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
@@ -21,19 +21,36 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
// label holds an i18n key (resolved with t() at render time). // label holds an i18n key (resolved with t() at render time).
const FIELDS: FieldDef[] = [ const FIELDS: FieldDef[] = [
// QSL / upload status // QSL / upload status
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' }, // One channel at a time, each with its status and its date, in the same order
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' }, // as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' }, // QSL is not uploaded — so every entry now reads "<channel> sent/received
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' }, // status" or "… date", which is also what the ADIF field is called.
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' }, { id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' }, { id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' }, { id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' }, { id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator // My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true }, { id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true }, { id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true }, { id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' }, { id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' }, { id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
@@ -65,6 +82,7 @@ const FIELDS: FieldDef[] = [
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true }, { id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true }, { id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (location / activation refs / SIG) // 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: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' }, { id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true }, { id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
@@ -115,7 +133,7 @@ type Props = {
// so they become eligible for upload. // so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) { export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [field, setField] = useState('hrdlog_upload'); const [field, setField] = useState('qsl_sent');
const [statusValue, setStatusValue] = useState('R'); const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState(''); const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -179,6 +197,29 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)} {STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
) : def.kind === 'date' ? (
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
// writes an empty value — which is how a wrong date is removed
// from a batch, the same meaning as leaving a text field blank.
<div className="flex gap-1">
<Input
type="date"
className="h-8 text-xs font-mono"
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
/>
<Button
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
title={t('bulk.todayUtc')}
onClick={() => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// UTC: the whole log is UTC, and near midnight the local day
// is the wrong one.
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
}}
>{t('bulk.today')}</Button>
</div>
) : ( ) : (
<Input <Input
className="h-8 text-xs" className="h-8 text-xs"
+27 -6
View File
@@ -12,7 +12,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs'; import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string; type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => { // A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
// matching column events. Without this guard those events were persisted, so a
// single language change overwrote the saved cluster layout — in the cache AND
// in the database, with nothing to restore from.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
restoringRef.current = true;
return COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
}), [COL_CATALOG]); });
}, [COL_CATALOG]);
// Re-apply the saved state after every rebuild, then re-enable saving.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(tm);
}, [columnDefs]);
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false, sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
gridRef.current?.api?.refreshCells({ force: true }); gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]); }, [spotStatus]);
function onGridReady(e: GridReadyEvent) { // Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => { const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote); seedLocal(COL_STATE_KEY, remote);
} }
});
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, state);
}, []); }, []);
+3 -3
View File
@@ -306,11 +306,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
heightClass="flex-1 min-h-0" heightClass="flex-1 min-h-0"
/> />
{detected.length > 0 && ( {detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0"> <div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '} <span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.map((r) => ( {detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono"> <span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null} <span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
</span> </span>
))} ))}
</div> </div>
+38 -19
View File
@@ -12,6 +12,34 @@ import { adif } from '@/../wailsjs/go/models';
type FieldDef = adif.FieldDef; type FieldDef = adif.FieldDef;
const PREF_KEY = 'opslog.exportFields'; const PREF_KEY = 'opslog.exportFields';
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
// inside ExportFieldsDialog) so its component identity is stable across renders —
// an inner component is re-created every render, remounting the whole subtree and
// making the All/None buttons and checkboxes feel dead.
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
allLabel: string; noneLabel: string;
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
}) {
return (
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
<div className="flex items-center justify-between mb-1 gap-2">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
<span className="flex gap-1.5 shrink-0">
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
</span>
</div>
{tags.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
<span className="font-mono break-all">{name}</span>
</label>
))}
</div>
);
}
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export // ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category) // writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
// and the OpsLog / non-standard tags actually present in the log's extras. The // and the OpsLog / non-standard tags actually present in the log's extras. The
@@ -69,23 +97,14 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
onExport(fields); onExport(fields);
}; };
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => ( const allLabel = t('exf.all');
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}> const noneLabel = t('exf.none');
<div className="flex items-center justify-between mb-1 gap-2"> const cardProps = {
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span> sel, allLabel, noneLabel,
<span className="flex gap-1.5 shrink-0"> onAll: (tags: string[]) => setMany(tags, true),
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button> onNone: (tags: string[]) => setMany(tags, false),
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button> onToggle: toggle,
</span> };
</div>
{tags.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
<span className="font-mono break-all">{name}</span>
</label>
))}
</div>
);
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
@@ -104,9 +123,9 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1"> <div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
{/* OpsLog / non-standard group first (most relevant to keep or drop). */} {/* OpsLog / non-standard group first (most relevant to keep or drop). */}
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />} {extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
{groups.map(([g, list]) => ( {groups.map(([g, list]) => (
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} /> <GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
))} ))}
</div> </div>
+36 -7
View File
@@ -26,7 +26,9 @@ export interface QueryFilter {
// Curated field catalog. `value` MUST match a column in the backend whitelist // Curated field catalog. `value` MUST match a column in the backend whitelist
// (qso.FilterableFields); `type` only drives which operators/value input we show. // (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date'; // 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
const FIELDS: { value: string; label: string; type: FieldType }[] = [ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' }, { value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' }, { value: 'qso_date', label: 'fltb.fDate', type: 'date' },
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' }, { value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
{ value: 'rig', label: 'fltb.fRig', type: 'text' }, { value: 'rig', label: 'fltb.fRig', type: 'text' },
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' }, { value: 'ant', label: 'fltb.fAntenna', type: 'text' },
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
// of them — a paper QSL is not uploaded.
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' }, { value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' }, { value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' }, { value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' }, { value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' }, { value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' }, { value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' }, { value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' }, { value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' }, { value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' }, { value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' }, { value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' }, { value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' }, { value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
function opsFor(field: string): { value: FilterOp; label: string }[] { function opsFor(field: string): { value: FilterOp; label: string }[] {
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text'; const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
// comparison operators (before/after), not the text ones.
const allow = t === 'text' ? TEXT_OPS : NUM_OPS; const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
return OPS.filter((o) => allow.includes(o.value)); return OPS.filter((o) => allow.includes(o.value));
} }
@@ -253,12 +271,23 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'} // An ADIF date is picked with a calendar but STORED as the
// 8-digit form the column holds, so the comparison stays a
// plain string one on both sides.
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs" className="h-8 flex-1 text-xs"
disabled={!needsValue} disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'} placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value} // \d, not d: the escape was missing, so the test never matched
onChange={(e) => setCond(i, { value: e.target.value })} // 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, {
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
})}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }} onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/> />
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}> <button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
+123 -60
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
import { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
@@ -19,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst'; import { sMeterRST } from '@/lib/rst';
import { TunerCard } from '@/components/TunerCard';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus } from '@/components/TunerGeniusPanel';
type FlexState = { type FlexState = {
available: boolean; model?: string; available: boolean; model?: string;
@@ -121,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
} }
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…). // Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: { function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
}) { }) {
const onCls = { const onCls = {
emerald: 'bg-success border-success text-success-foreground', emerald: 'bg-success border-success text-success-foreground',
@@ -131,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
amber: 'bg-warning border-warning text-warning-foreground', amber: 'bg-warning border-warning text-warning-foreground',
}[accent]; }[accent];
return ( return (
<button type="button" onClick={onClick} disabled={disabled} <button type="button" onClick={onClick} disabled={disabled} title={title}
className={cn( className={cn(
// min width (not fixed) so a longer label like STONE keeps symmetric // min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned. // padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
@@ -142,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
); );
} }
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
// and the operator transmits into a bad match either way.
function atuLabel(status: string | undefined, t: (k: string) => string): string {
switch ((status || '').toUpperCase()) {
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
case 'TUNE_SUCCESSFUL':
case 'TUNE_OK': return t('flxp.atuOk');
case 'TUNE_FAIL':
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
case 'TUNE_BYPASS':
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
case 'TUNE_ABORTED': return t('flxp.atuAborted');
case 'TUNE_NOT_STARTED':
case 'NONE':
case '': return t('flxp.atuIdle');
default: return status || '';
}
}
function atuTone(status: string | undefined): string {
switch ((status || '').toUpperCase()) {
case 'TUNE_SUCCESSFUL':
case 'TUNE_OK': return 'text-success';
case 'TUNE_FAIL':
case 'TUNE_FAIL_BYPASS': return 'text-danger';
case 'TUNE_IN_PROGRESS': return 'text-warning';
default: return 'text-muted-foreground';
}
}
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: { function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void; label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
@@ -219,54 +254,29 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi. // MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their // `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak). // 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26; function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string; open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) { }) {
const span = hi - lo; // Collapsible: a chevron in the header hides the body. Uncontrolled by default,
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; // persisting per card (keyed by ckey, falling back to the title); when `open`/
const lit = Math.round((pct / 100) * METER_SEGMENTS); // `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
return ( const storeKey = 'opslog.cardOpen.' + (ckey || title);
<div onClick={onClick} title={title} const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0', const controlled = openProp !== undefined;
compact ? 'px-2 py-1' : 'px-2.5 py-2', const open = controlled ? openProp : openState;
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}> const toggle = controlled
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}> ? (onToggle ?? (() => {}))
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span> : () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return ( return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <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"> <button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} /> <Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span> <span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div> <ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
<div className="p-3 space-y-3">{children}</div> </button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div> </div>
); );
} }
@@ -354,6 +364,25 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
// one folds the other and keeps the row tidy). Persisted like the other cards.
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
};
tick();
const id = window.setInterval(tick, 500); // fast so meters track TX (see App.tsx)
return () => { alive = false; window.clearInterval(id); };
}, []);
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops // Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks // run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
// which, and the choice is remembered per panel. // which, and the choice is remembered per panel.
@@ -422,6 +451,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => off(); return () => off();
}, [st.rit, st.rit_freq]); }, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW'); const isCW = (st.mode || '').toUpperCase().includes('CW');
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
// hidden in CW and digital.
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }]; const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }]; const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500]; const CW_BW = [100, 200, 300, 400, 500];
@@ -550,22 +582,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}; };
const cur = [ const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return ( sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`} // dBm sits inline next to the S-value (no separate line below) to save height.
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} | ${dbm.toFixed(0)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined} title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined} onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} /> segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(), ); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return ( fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(), ); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />, swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. // Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a" isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />, segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression — original working meter, only the top of the // Speech compression — original working meter, only the top of the
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max). // scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />, isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
].filter(Boolean); ].filter(Boolean);
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
@@ -582,7 +614,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* TX + RX columns */} {/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */} {/* TRANSMIT */}
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626"> <Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span> <span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} /> <Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
@@ -623,6 +655,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)} )}
</div> </div>
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
the order of operations: the tuner needs a carrier to measure
against, so TUNE first, then start a tuning cycle. */}
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
<button type="button" disabled={off}
title={t('flxp.atuTuneHint')}
onClick={() => FlexATUStart().catch(() => {})}
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
{t('flxp.atuTune')}
</button>
<button type="button" disabled={off}
title={t('flxp.atuBypassHint')}
onClick={() => FlexATUBypass().catch(() => {})}
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
{t('flxp.atuBypass')}
</button>
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
title={t('flxp.atuMemHint')}
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
{/* The status is the whole point: a tune that FAILED and one that
never ran look identical on the radio's own front panel. */}
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
{atuLabel(st.atu_status, t)}
</span>
</div>
{!isCW ? ( {!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
@@ -721,7 +780,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card> </Card>
{/* RECEIVE */} {/* RECEIVE */}
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2"> <Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
{/* Antenna selection sits at the very top of the RX column. */} {/* Antenna selection sits at the very top of the RX column. */}
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && ( {((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2 pb-3 border-b border-border/60"> <div className="flex items-center gap-2 pb-3 border-b border-border/60">
@@ -910,7 +969,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
SPE link rather than the Flex amplifier object. */} SPE link rather than the Flex amplifier object. */}
{isSPE && ( {isSPE && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c"> <Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{ampPicker} {ampPicker}
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
@@ -968,7 +1027,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven {/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */} by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
{isACOM && ( {isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c"> <Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{ampPicker} {ampPicker}
<button type="button" disabled={!acom.connected} <button type="button" disabled={!acom.connected}
@@ -1015,7 +1074,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
this Flex-reported card so two amps don't both show. */} this Flex-reported card so two amps don't both show. */}
{st.amp_available && !isSPE && !isACOM && ( {st.amp_available && !isSPE && !isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c"> <Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{ampPicker} {ampPicker}
<button type="button" disabled={off} <button type="button" disabled={off}
@@ -1063,7 +1122,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => { {amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) { if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />; return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
} }
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// Drain current (ID): the PGXL reports a full-scale far too small // Drain current (ID): the PGXL reports a full-scale far too small
@@ -1076,7 +1135,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
lo = 0; lo = 0;
hi = m.hi >= 25 ? m.hi : 25; hi = m.hi >= 25 ? m.hi : 25;
} }
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />; return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})} })}
</div> </div>
); );
@@ -1084,6 +1143,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card> </Card>
)} )}
{/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings →
Tuner Genius). Same card shown in Station Control. */}
{tgEnabled && <TunerCard status={tg} t={t} />}
</div> </div>
</div> </div>
); );
+28 -3
View File
@@ -73,6 +73,24 @@ function attOptions(model?: string): { v: string; l: string }[] {
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
} }
// bandOfHz names the amateur band a frequency falls in, so the band row can show
// where the rig actually is. The buttons only ever SENT a frequency and carried
// no active state at all, so nothing was highlighted whatever the rig reported.
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
function bandOfHz(hz?: number): string {
if (!hz || hz <= 0) return '';
const mhz = hz / 1_000_000;
const bands: [string, number, number][] = [
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0], ['70', 430.0, 450.0],
];
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
return '';
}
// fmtVFO renders a Hz frequency the way an Icom front panel does: // fmtVFO renders a Hz frequency the way an Icom front panel does:
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00". // MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
function fmtVFO(hz?: number): string { function fmtVFO(hz?: number): string {
@@ -731,12 +749,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{/* Band buttons + antenna selection. */} {/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2"> <Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<div className="grid grid-cols-5 gap-1.5"> <div className="grid grid-cols-5 gap-1.5">
{BANDS.map((b) => ( {BANDS.map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})} <button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors"> title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
: 'border-border bg-card text-foreground hover:bg-muted')}>
{b.l} {b.l}
</button> </button>
))} );
})}
</div> </div>
<Row label={t('icmp.antenna')}> <Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]} <Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
+5 -1
View File
@@ -6,7 +6,10 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export type MenuItem = export type MenuItem =
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean } // accent highlights an item that is an OFFER rather than a command (Donate).
// Generic on purpose: a hard-coded label test in the renderer would break the
// moment the menu is translated.
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
| { type: 'separator' }; | { type: 'separator' };
export interface Menu { export interface Menu {
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
<DropdownMenuItem <DropdownMenuItem
key={i} key={i}
disabled={item.disabled} disabled={item.disabled}
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
onSelect={() => onAction(item.action)} onSelect={() => onAction(item.action)}
> >
<span>{item.label}</span> <span>{item.label}</span>
+46
View File
@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils';
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
// cards and the Tuner Genius card, so every meter renders at exactly the same size
// (segment count, bar height, padding). Keep it the single source of truth — don't
// re-declare a local copy in a panel, or the meters drift out of sync.
export const METER_SEGMENTS = 26;
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
// drives which QSOs the apply-form below updates; a search selects all. // drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid <RecentQSOsGrid
// Its OWN column layout. Without a storageKey this fell back to
// the main log's key, so every resize or hidden column here
// silently rewrote the Recent QSOs layout, and vice versa.
storageKey="qslmgr.paper"
rows={paperRows as any} rows={paperRows as any}
total={paperRows.length} total={paperRows.length}
selectAllSignal={paperSelAllSig} selectAllSignal={paperSelAllSig}
@@ -532,6 +536,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : ( ) : (
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid <RecentQSOsGrid
storageKey="qslmgr.upload"
rows={rows as any} rows={rows as any}
total={rows.length} total={rows.length}
selectAllSignal={uploadSelAllSig} selectAllSignal={uploadSelAllSig}
+75 -16
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base; return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
} }
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm']; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9']; const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time). // label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [ const QSL_STATUSES = [
@@ -148,6 +148,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
); );
} }
// AdifDateInput — a real date picker over an ADIF date.
//
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
// are converted at the edge and the log keeps its ADIF form. The native control
// is used on purpose: it brings the OS calendar, the locale's day/month order
// and keyboard entry for free, which a hand-rolled popover would have to
// reimplement and get wrong.
//
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
// a plain text box instead, so it stays visible and correctable rather than
// silently disappearing behind an empty picker.
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
const raw = (value ?? '').trim();
const valid = /^\d{8}$/.test(raw);
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
// UTC: every date in the log is UTC, and near midnight the local day is the
// wrong one.
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
};
if (raw !== '' && !valid) {
return (
<div className="flex gap-1">
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
</div>
);
}
return (
<div className="flex gap-1">
<Input
type="date"
value={iso}
disabled={disabled}
onChange={(e) => {
const v = e.target.value; // "" when cleared
onChange(v ? v.replace(/-/g, '') : '');
}}
className="font-mono"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
onClick={today} title="Today (UTC)">
<CalendarDays className="size-3.5" />
</Button>
</div>
);
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) { function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -280,19 +330,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
setLooking(true); setLooking(true);
setLocalErr(''); setLocalErr('');
try { try {
const r: any = await LookupCallsign(call); // FRESH: this fetch is a deliberate click, so it bypasses the cache and
// refreshes it. A cached answer from a thinner QRZ subscription (or any
// stale row) otherwise stayed for its whole 30-day life and the button
// appeared to do nothing.
const r: any = await LookupCallsignFresh(call);
// The lookup WINS over what is in the record — that is the point of asking
// for it. But an EMPTY result must never blank a good value: `??` only
// guards against null, and Go marshals an unset string as "", so a QRZ
// record with no grid used to wipe the grid that was already there.
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
setDraft((d) => ({ setDraft((d) => ({
...d, ...d,
name: r.name ?? d.name, name: keep(r.name, d.name),
qth: r.qth ?? d.qth, qth: keep(r.qth, d.qth),
address: r.address ?? (d as any).address, address: keep(r.address, (d as any).address),
email: r.email ?? (d as any).email, email: keep(r.email, (d as any).email),
country: r.country ?? d.country, country: keep(r.country, d.country),
grid: r.grid ?? d.grid, grid: keep(r.grid, d.grid),
state: r.state ?? d.state, state: keep(r.state, d.state),
cnty: r.cnty ?? d.cnty, cnty: keep(r.cnty, d.cnty),
cont: r.cont ?? d.cont, cont: keep(r.cont, d.cont),
qsl_via: r.qsl_via ?? d.qsl_via, qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc, dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz, cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz, ituz: r.ituz || d.ituz,
@@ -580,8 +639,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} /> ? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />} : <Input disabled value="—" />}
</div> </div>
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div> <div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div> <div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && ( {def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div> <div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)} )}
+96 -7
View File
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs'; import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
// Register every Community feature once. v32+ requires explicit registration; // Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/ // AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -27,6 +28,9 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
type Props = { type Props = {
rows: QSOForm[]; rows: QSOForm[];
total: number; total: number;
// Operator's CURRENT locator — fallback for the distance column on older QSOs
// that carry no my_grid of their own.
myGrid?: string;
// Bump this number to programmatically select every row (e.g. after a search // Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected"). // in the QSL Manager, where the default is "all selected").
selectAllSignal?: number; selectAllSignal?: number;
@@ -111,7 +115,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
// hooks can't run at module level, so the component calls this with its own t. // hooks can't run at module level, so the component calls this with its own t.
type TFn = (key: string, vars?: Record<string, string | number>) => string; type TFn = (key: string, vars?: Record<string, string | number>) => string;
export const makeColCatalog = (t: TFn): ColEntry[] => [ // qsoDistanceKm returns the great-circle distance for one row, in km.
//
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
// portable outings, so the station the QSO was made from is not necessarily the
// one configured today. `myGrid` (the current profile) is only the fallback for
// the many older records that carry no my_* fields at all.
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
if (!d) return undefined;
const here =
(d.my_grid && gridToLatLon(d.my_grid)) ||
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
(myGrid ? gridToLatLon(myGrid) : null);
const there =
(d.grid && gridToLatLon(d.grid)) ||
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
if (!here || !there) return undefined;
return Math.round(pathBetweenLatLon(here, there).distanceShort);
}
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
// ── QSO basics ── // ── QSO basics ──
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true }, { group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
@@ -146,6 +169,11 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' }, { group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' }, { group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// Derived, not stored: computed from the two locations at display time, like
// the cluster grid's own distance column.
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 }, { group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 }, { group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
@@ -263,7 +291,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) { export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -275,7 +303,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
// Right-click: if the clicked row isn't already part of the selection, // Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection. // select just it; then open the bulk-action menu on the whole selection.
@@ -328,6 +356,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
saveState(AWARD_SHOWN_KEY, [...next]); saveState(AWARD_SHOWN_KEY, [...next]);
}, []); }, []);
// Award-column WIDTHS are persisted separately too, for the same reason as
// visibility: award columns are stripped from AG Grid's saved column-state
// round-trip, so their width would otherwise reset to the default on reopen.
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
const awardWidthsRef = useRef<Record<string, number>>({});
const awardWidthsInit = useRef(false);
if (!awardWidthsInit.current) {
awardWidthsInit.current = true;
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
}
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
// apply them to any award columns already on screen.
useEffect(() => {
if (loadLocal(AWARD_WIDTH_KEY)) return;
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
if (!remote || !remote.length) return;
for (const it of remote as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
seedLocal(AWARD_WIDTH_KEY, remote);
const api = gridRef.current?.api;
if (api && awardCols?.length) {
const ups = awardCols
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
.filter((u) => !!u.newWidth);
if (ups.length) api.setColumnWidths(ups);
}
});
}, []);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => { const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true; restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
@@ -354,7 +415,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
colId: `award_${a.code}`, colId: `award_${a.code}`,
headerName: a.code, headerName: a.code,
headerTooltip: t('rqg.awardTip', { name: a.name }), headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110, width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
cellClass: 'text-[11px]', cellClass: 'text-[11px]',
// Visibility comes from the persisted award-code set, so a column the user // Visibility comes from the persisted award-code set, so a column the user
// showed reappears on reopen and one they didn't stays hidden. // showed reappears on reopen and one they didn't stays hidden.
@@ -370,11 +431,17 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
if (!api || !awardCols?.length) return; if (!api || !awardCols?.length) return;
const widthUps: { key: string; newWidth: number }[] = [];
for (const a of awardCols) { for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase()); const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`); const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want); if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
// Re-apply the saved width — AG Grid keeps an existing column's width across
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
const w = awardWidthsRef.current[a.code.toUpperCase()];
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
} }
if (widthUps.length) api.setColumnWidths(widthUps);
}, [awardCols, awardShown]); }, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
@@ -421,8 +488,24 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(colStateKey, stripAwardCols(state)); if (!state) return;
}, []); saveState(colStateKey, stripAwardCols(state));
// Award columns are stripped above, so persist their widths on the side.
let changed = false;
for (const s of state) {
const id = String((s as any)?.colId ?? '');
if (id.startsWith('award_') && (s as any).width) {
const code = id.slice('award_'.length).toUpperCase();
if (awardWidthsRef.current[code] !== (s as any).width) {
awardWidthsRef.current[code] = (s as any).width;
changed = true;
}
}
}
if (changed) {
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
}
}, [colStateKey, AWARD_WIDTH_KEY]);
// columnDefs is rebuilt whenever the award columns load OR the user toggles an // columnDefs is rebuilt whenever the award columns load OR the user toggles an
// award column (both change the memo → restoringRef flips true at line 316). Each // award column (both change the memo → restoringRef flips true at line 316). Each
@@ -438,7 +521,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [awardCols, awardShown]); // Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
// causes here (awardCols/awardShown) missed the others — switching the UI
// language rebuilds the memo through `t`, which set restoringRef and left it
// set, so every column change for the rest of the session was silently
// discarded and the layout reverted on reload. Any future dependency of the
// memo is now covered for free.
}, [columnDefs]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
+63
View File
@@ -0,0 +1,63 @@
import { SpellCheck, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type ScpResult = { partial?: string[]; nplus1?: string[] };
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
result: ScpResult;
currentCall: string;
count: number; // master-list size (0 = list not downloaded yet)
onPick: (call: string) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const cur = (currentCall || '').trim().toUpperCase();
const partial = result.partial ?? [];
const nplus1 = result.nplus1 ?? [];
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
<div className="flex-1 min-w-0 flex flex-col">
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
{calls.length === 0 ? (
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
) : calls.map((c) => (
<button key={c} type="button" onClick={() => onPick(c)}
title={t('scp.fill', { call: c })}
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
c === cur ? 'bg-success/20 text-success font-bold'
: 'hover:bg-primary/15 text-foreground/90')}>
{c}
</button>
))}
</div>
</div>
);
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
<X className="size-3.5" />
</button>
</div>
{count === 0 ? (
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
{t('scp.noList')}
</div>
) : (
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+40 -10
View File
@@ -9,12 +9,15 @@ import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard'; import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import { import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice, ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, GetFlexState, GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -283,6 +286,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
const id = window.setInterval(load, 1500); const id = window.setInterval(load, 1500);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
// panel still get the controls. Re-read the enabled flag so it appears/hides
// without a restart.
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
useEffect(() => {
let alive = true;
const load = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
};
load();
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
return () => { alive = false; window.clearInterval(id); };
}, []);
const loadDevices = useCallback(async () => { const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -397,7 +415,11 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
); );
}; };
const widgets: { id: string; node: React.ReactNode }[] = []; // `wide` cards span the whole dashboard instead of sitting in one masonry
// column. The amplifier and tuner carry meter rows and a channel selector that
// are unreadable squeezed into a ~430px column — they are the same cards the
// FlexRadio panel shows full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) { if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> }); widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
} }
@@ -405,14 +427,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> }); widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
} }
// One card per configured amplifier (identical to the Flex panel's card). // One card per configured amplifier (identical to the Flex panel's card).
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> }); for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
// Tuner Genius XL card (identical to the Flex panel's).
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; }; const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i)); const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id); const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0; const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
return ( return (
<div className="flex-1 min-h-0 overflow-auto p-4"> <div className="flex-1 min-h-0 overflow-auto p-4">
@@ -442,14 +466,20 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed {/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
column count caps the container width so cards wrap onto more lines. Each they pack tightly by height (no ragged gaps under short cards). "Auto"
card has a grip handle (left rail) as the drag initiator (the card body is fits as many ~430px columns as the window allows; a fixed count caps the
full of buttons, so dragging the whole card was unreliable). */} container width to that many columns. Each card has a grip handle (left
<div className="flex flex-wrap gap-4 items-start" rail) as the drag initiator (the body is full of buttons). */}
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}> <div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
{ordered.map((w) => ( {ordered.map((w) => (
<div key={w.id} className="flex items-stretch w-[430px]" // column-span:all lifts a wide card out of the columns and across the
// full container, while keeping it in document order — so drag-reorder
// still works between wide and normal cards. Capped so it stays a card
// and not a banner on an ultra-wide window.
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}> onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable <div draggable
+63 -6
View File
@@ -33,6 +33,10 @@ type Stats = {
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[]; by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[]; by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[]; by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Chronological series across the SELECTED period, one per bucket size; empty
// when that bucket would be too fine for the period (see the Go side).
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
// Period / contest metrics. // Period / contest metrics.
window_start: string; window_end: string; window_hours: number; window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number; avg_per_hour: number; avg_per_active: number;
@@ -509,19 +513,44 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
// own state, module-scoped so a parent re-render doesn't reset the choice. The // own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical // timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year). // distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
// panel — not a window of its own. It used to be four fixed rolling windows
// anchored on the newest QSO, which ignored the period filter entirely: picking
// a year up top left this chart showing the last 7 days.
const ACT_GRAN = [ const ACT_GRAN = [
{ key: 'timeline', tkey: 'stats.granTimeline' },
{ key: 'day', tkey: 'stats.granDay' }, { key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' }, { key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' }, { key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' }, { key: 'year', tkey: 'stats.granYear' },
] as const; ] as const;
type Gran = typeof ACT_GRAN[number]['key'];
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) { function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline'); const [g, setG] = useState<Gran | 'auto'>('auto');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : []; const seriesFor = (k: Gran): Bucket[] => (
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
);
// Auto picks the finest bucket that stays readable: the backend leaves a
// series empty when it would be too fine for the period, so "the first
// non-empty one" is exactly the right rule and needs no thresholds here.
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
// A bucket the user forces but that is too fine for the period falls back to
// the next coarser one rather than showing an empty chart.
let shown: Gran = g === 'auto' ? auto : g;
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
const series = seriesFor(shown);
const steppedUp = g !== 'auto' && shown !== g;
return ( return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)"> <Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1 items-center">
<button type="button" onClick={() => setG('auto')}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('stats.granAuto')}
</button>
{ACT_GRAN.map((o) => ( {ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)} <button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors', className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
@@ -529,14 +558,39 @@ function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: an
{t(o.tkey)} {t(o.tkey)}
</button> </button>
))} ))}
{steppedUp && (
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
)}
</div> </div>
{g === 'timeline' {/* Many buckets read better as a filled trend than as a forest of bars. */}
? <AreaTrend data={stats.by_month} empty={empty} /> {series.length > 60
? <AreaTrend data={series} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />} : <VBars data={series} empty={empty} showValues height={160} />}
</Card> </Card>
); );
} }
// RhythmCard — WHEN the operator is on the air over the selected period, which is
// a different question from "how much lately" and so gets its own card. The hour
// histogram used to cover a single day, too little to read anything from.
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<'hour' | 'weekday'>('hour');
return (
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
<div className="mb-2 flex flex-wrap gap-1">
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
<button key={k} type="button" onClick={() => setG(k)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(tk)}
</button>
))}
</div>
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
</Card>
);
}
// ── Table view (the WCAG-clean twin) ───────────────────────────────────────── // ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) { function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
@@ -603,6 +657,8 @@ export function StatsPanel() {
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent), by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month), top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12), by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
rate: arr(raw.rate), gaps: arr(raw.gaps), rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op), rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats); } as Stats);
@@ -818,6 +874,7 @@ export function StatsPanel() {
</Card> </Card>
<ActivityCard stats={stats} t={t} empty={empty} /> <ActivityCard stats={stats} t={t} empty={empty} />
<RhythmCard stats={stats} t={t} empty={empty} />
{/* EVERY operator, never a top-N: on a multi-op contest the point is who {/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls worked what, and a cap would quietly delete the 9th operator. Scrolls
+167
View File
@@ -0,0 +1,167 @@
import { useRef, useState } from 'react';
import { Gauge, Radio, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
// mirrors the native app's two channels (A / B) with their source, frequency and
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
// drives the backend directly (no local state) — the caller's ~1.5s poll
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
// so they're the exact same size as the Flex/amp meters.
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
// antenna, matching the two rows of the native 4O3A app.
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
onSelect: () => void; t: (k: string, v?: any) => string;
}) {
const cls = ptt
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]'
: active
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
: 'bg-card text-foreground/80 border-border hover:bg-muted';
const src = ch.mode_str || '—';
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
return (
<button type="button" onClick={onSelect}
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm">{letter}</span>
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
)}
</div>
</button>
);
}
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
// between syllables as often as on a peak, so the raw reading collapses to zero
// several times a second mid-transmission and reads as a dropped link. The
// amplifier looked steady next to it only because it already smoothed this way.
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
const connected = !!status.connected;
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a
// meaningless 1.00, which would wipe the figure the operator actually wants —
// the one measured while transmitting. So show the live value during TX, keep it
// briefly afterwards so it can be read, then let it go.
//
// That expiry is the point: the first version of this held the last TX value
// with no timeout at all, so the meter sat frozen on the previous transmission
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
// Same 2 s window as the power peak above, so the two meters clear together.
const swrHold = useRef<{ v: number; t: number } | null>(null);
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
const heldSwr = swrHold.current;
const vswr = rawFwdW >= 1
? rawVswr
: heldSwr && Date.now() - heldSwr.t < 2000
? heldSwr.v
: undefined;
const active = status.active ?? 1;
const a: TGChannel = status.a ?? {};
const b: TGChannel = status.b ?? {};
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
return (
<Card icon={Gauge} title={title} accent="#f59e0b">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{status.operate ? 'OPERATE' : 'STANDBY'}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusAutotune().catch(() => {})}
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
{t('tgp.bypass')}
</button>
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
</span>
<div className="flex-1" />
{status.message && (
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold"> {status.message}</span>
)}
</div>
{connected && (
<>
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
<div className="flex items-stretch gap-2">
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
</div>
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
3-wide because it shows three meters; copying it here left the tuner's
two meters in the first two of three columns, with a third of the card
empty to the right of SWR. */}
<div className="grid grid-cols-2 gap-2">
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
</div>
</>
)}
</Card>
);
}
@@ -0,0 +1,176 @@
import { Gauge, X, Power, Radio } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type TGChannel = {
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
freq_mhz?: number; bypass?: boolean; antenna?: number;
};
export type TGStatus = {
connected: boolean; host?: string; last_error?: string;
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
antenna?: number; three_way?: boolean; message?: string;
a?: TGChannel; b?: TGChannel;
relay_c1?: number; relay_l?: number; relay_c2?: number;
};
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
// red above (the usual "safe / caution / high" ATU thresholds).
function swrColour(vswr?: number): string {
if (!vswr || vswr <= 0) return 'text-muted-foreground';
if (vswr <= 1.5) return 'text-success';
if (vswr <= 2.0) return 'text-warning';
return 'text-danger';
}
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
// active channel (green) or TX (red), shows the source + frequency + antenna,
// and clicking it makes that channel active.
function ChanRow({ letter, ch, active, onSelect, t }: {
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
t: (k: string, v?: any) => string;
}) {
const ptt = !!ch.ptt;
const cls = ptt
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
: active
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
const src = ch.mode_str || '—';
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
return (
<button type="button" onClick={onSelect}
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
{ch.antenna != null && ch.antenna > 0 && (
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
)}
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
</button>
);
}
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
// the live SWR / forward power, the two RF channels (A / B, click to activate),
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
// panel and Station Control.
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
status: TGStatus;
onTune: () => void;
onBypass: (on: boolean) => void;
onOperate: (on: boolean) => void;
onActivate: (ch: number) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const active = status.active ?? 1;
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Gauge className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Tuner Genius</span>
<span className="flex-1" />
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
<X className="size-3.5" />
</button>
</div>
{!status.connected ? (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
{/* SWR + forward power readouts */}
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
</div>
</div>
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
</div>
</div>
</div>
{/* Channel A / B — click to activate */}
<div className="space-y-1">
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
</div>
{status.message && (
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
{status.message}
</div>
)}
{/* Tune */}
<button
type="button"
onClick={onTune}
disabled={status.tuning}
title={t('tgp.tuneHint')}
className={cn(
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
status.tuning
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
)}
>
<span className="inline-flex items-center justify-center gap-2">
<Radio className="size-4" />
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</span>
</button>
{/* Bypass + Operate toggles */}
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onBypass(!status.bypass)}
title={t('tgp.bypassHint')}
className={cn(
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
status.bypass
? 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
)}
>
{t('tgp.bypass')}
</button>
<button
type="button"
onClick={() => onOperate(!status.operate)}
title={t('tgp.operateHint')}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
status.operate
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
)}
>
<Power className="size-3.5" />
{status.operate ? t('tgp.operate') : t('tgp.standby')}
</button>
</div>
</div>
)}
</div>
);
}
+12 -5
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number; wpm: number;
macros: WKMacro[]; macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits 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 breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void; onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void; onSelectPort: (p: string) => void;
@@ -101,15 +101,17 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" /> <Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */} {/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0"> <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>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')} <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')} /> title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" /> <div className="flex-1" />
{source === 'icom' || source === 'flex' ? ( {source === 'icom' || source === 'flex' || source === 'yaesu' ? (
<span className="text-[11px] font-medium text-muted-foreground"> <span className="text-[11px] font-medium text-muted-foreground">
{source === 'flex' {source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline')) ? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: source === 'yaesu'
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))} : (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span> </span>
) : !connected ? ( ) : !connected ? (
@@ -230,9 +232,14 @@ export function WinkeyerPanel({
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>} {autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
</div> </div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} {/* Macro buttons F1 single-line (F-key + label) to keep the panel short.
Empty macros (no label AND no text) are hidden, like the voice keyer;
the F-number stays tied to the macro's real index so shortcuts match. */}
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => ( {macros
.map((m, i) => ({ m, i }))
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
.map(({ m, i }) => (
<button <button
key={i} key={i}
type="button" type="button"
+27 -7
View File
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types'; import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid'; import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu'; import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs'; import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -25,6 +25,8 @@ const hamlogTheme = hamlogGridTheme;
type WorkedEntry = QSOForm; // entries are now full QSO records type WorkedEntry = QSOForm; // entries are now full QSO records
type Props = { type Props = {
// Operator's CURRENT locator — fallback for the distance column (see catalog).
myGrid?: string;
wb: WorkedBeforeView | null; wb: WorkedBeforeView | null;
busy: boolean; busy: boolean;
currentCall: string; currentCall: string;
@@ -35,6 +37,10 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void; onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void; onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for). // One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
@@ -50,14 +56,14 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) { export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid). // Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
@@ -107,15 +113,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
sortable: true, resizable: true, filter: true, suppressMovable: false, sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []); }), []);
function onGridReady(e: GridReadyEvent) { // Restore AFTER the profile scope is known: this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint, so it
// used to miss the cache every time and then save under the scoped key.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => { const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote); seedLocal(COL_STATE_KEY, remote);
} }
});
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild if (restoringRef.current) return; // ignore events fired by a column rebuild
@@ -131,7 +140,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [awardCols]); // columnDefs itself, not the reasons it was rebuilt — see the same note in
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
}, [columnDefs]);
function isColVisible(colId: string): boolean { function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId); const col = gridRef.current?.api?.getColumn(colId);
@@ -247,6 +258,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div> </div>
</div> </div>
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
mean "the whole logbook under the active column filters", but this grid
is a per-callsign view rather than a filter over the log, so the entry
would quietly export everything not what it would appear to do here.
The selection-based exports and bulk edit apply unchanged. */}
<QSOContextMenu <QSOContextMenu
menu={menu} menu={menu}
onClose={() => setMenu(null)} onClose={() => setMenu(null)}
@@ -256,6 +272,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo} onSendTo={onSendTo}
onSendRecording={onSendRecording} onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL} onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportCabrilloSelected={onExportCabrilloSelected}
onDelete={onDelete} onDelete={onDelete}
/> />
+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>
);
}
+67 -3
View File
@@ -19,6 +19,26 @@ let lsScope = '';
// changes so each profile keeps its own column layout / widths. // changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void { export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`; lsScope = id == null || id === '' ? '' : `p${id}.`;
markReady();
}
// The active profile is only known after an async call, but the grids mount and
// read their state on the first paint. A grid that read before the scope was set
// looked up the UNSCOPED cache key (always a miss) and then saved under the
// scoped one — so its layout could never be restored, only rewritten. Grids now
// wait for this instead of racing it.
//
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
// still come up with whatever the unscoped cache holds rather than hang with no
// columns restored.
let markReady: () => void = () => {};
const gridPrefsReady = new Promise<void>((resolve) => {
markReady = resolve;
setTimeout(resolve, 5000);
});
export function whenGridPrefsReady(): Promise<void> {
return gridPrefsReady;
} }
// lsKey scopes ONLY the localStorage cache key. The DB key passed to // lsKey scopes ONLY the localStorage cache key. The DB key passed to
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
} }
// loadRemote pulls the portable copy from the DB (null if none / unset). // loadRemote pulls the portable copy from the DB (null if none / unset).
export async function loadRemote(key: string): Promise<any[] | null> { //
// GetUIPref returns an ERROR — not "" — while the settings store is still
// coming up and not yet scoped to the active profile. Treating that as "no
// preference" was the silent data-loss path: the grid rendered defaults and the
// first column event then wrote those defaults over the good saved copy. So
// retry for a few seconds instead, and only give up on a real absence.
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
for (let i = 0; i < attempts; i++) {
try { try {
const v = await GetUIPref(key); const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null; const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null; return Array.isArray(parsed) ? parsed : null;
} catch { } catch {
return null; // Not ready yet (or a parse failure on a corrupt value — one more read
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
await new Promise((r) => setTimeout(r, 300));
} }
}
return null;
} }
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only // saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend. // the cache key is profile-scoped; the DB key is scoped by the backend.
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
// frame, so dragging one border used to fire dozens of settings writes; the
// cache write stays synchronous so nothing is lost if the window closes.
const pendingDB = new Map<string, string>();
const dbTimers = new Map<string, number>();
export function saveState(key: string, state: any[]) { export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state); const json = JSON.stringify(state);
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ } try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ }); pendingDB.set(key, json);
const prev = dbTimers.get(key);
if (prev) clearTimeout(prev);
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
}
function flushOne(key: string) {
const json = pendingDB.get(key);
dbTimers.delete(key);
if (json == null) return;
pendingDB.delete(key);
SetUIPref(key, json).catch(() => {
// The store may not be scoped yet at startup. Keep the value and try once
// more shortly — dropping it here is how a fresh profile ended up with no
// portable copy at all.
pendingDB.set(key, json);
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
});
}
// flushGridPrefs writes any debounced state immediately. Call it when the app is
// about to close so a resize made in the last moments still reaches the DB.
export function flushGridPrefs() {
for (const key of Array.from(pendingDB.keys())) {
const t = dbTimers.get(key);
if (t) clearTimeout(t);
flushOne(key);
}
} }
// seedLocal writes a value into the cache without touching the DB (used after // seedLocal writes a value into the cache without touching the DB (used after
+124 -38
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -14,7 +14,11 @@ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
]; ];
export const LS_KEY = 'opslog.theme'; export const LS_KEY = 'opslog.theme';
const DEFAULT: ThemeChoice = 'light-warm'; // A fresh install starts DARK. A shack is usually a dim room and the screen is
// looked at for hours; every other logger defaults the same way. Graphite
// specifically, because that is what 'auto' already resolves to for a dark
// system — so the two paths agree instead of landing on different darks.
const DEFAULT: ThemeChoice = 'dark-graphite';
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES]; const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
function systemDark(): boolean { function systemDark(): boolean {
+34
View File
@@ -652,3 +652,37 @@
border: 2px solid var(--background); border: 2px solid var(--background);
} }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } ::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
/* Frameless window: drag region
The OS title bar is gone (main.go, Frameless), so the app header IS the title
bar and must be draggable. Everything interactive inside it opts OUT: a drag
region swallows clicks, and without this the menus and toolbar buttons would
move the window instead of doing their job. Opting out by ROLE rather than
listing each child keeps a future button working without anyone remembering
this rule. */
.app-dragregion {
--wails-draggable: drag;
}
.app-dragregion button,
.app-dragregion a,
.app-dragregion input,
.app-dragregion select,
.app-dragregion textarea,
.app-dragregion nav,
.app-dragregion [role='button'],
.app-dragregion [role='menu'],
.app-dragregion [data-no-drag] {
--wails-draggable: no-drag;
}
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
disappears on the dark themes. Invert it there the control itself is worth
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
help. */
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.6);
opacity: 0.75;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.12'; export const APP_VERSION = '0.22.0';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+89
View File
@@ -6,6 +6,7 @@ import {main} from '../models';
import {cat} from '../models'; import {cat} from '../models';
import {profile} from '../models'; import {profile} from '../models';
import {acom} from '../models'; import {acom} from '../models';
import {catemu} from '../models';
import {antgenius} from '../models'; import {antgenius} from '../models';
import {award} from '../models'; import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
@@ -14,6 +15,7 @@ import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {spe} from '../models'; import {spe} from '../models';
import {solar} from '../models'; import {solar} from '../models';
import {tunergenius} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
import {alerts} from '../models'; import {alerts} from '../models';
import {audio} from '../models'; import {audio} from '../models';
@@ -23,6 +25,7 @@ import {udp} from '../models';
import {lotwusers} from '../models'; import {lotwusers} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
import {netctl} from '../models'; import {netctl} from '../models';
import {scp} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>; export function ACOMSetOperate(arg1:boolean):Promise<void>;
@@ -172,6 +175,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
export function DownloadLoTWUsers():Promise<number>; export function DownloadLoTWUsers():Promise<number>;
export function DownloadScp():Promise<number>;
export function DownloadULSCounties():Promise<void>; export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
@@ -340,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>; export function GetAlertEmailTo():Promise<string>;
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>; export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>; export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
@@ -458,6 +465,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSPEStatus():Promise<spe.Status>; export function GetSPEStatus():Promise<spe.Status>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>; export function GetSlotStats():Promise<qso.SlotStats>;
@@ -474,6 +483,10 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>; export function GetTelemetryEnabled():Promise<boolean>;
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
export function GetUIPref(arg1:string):Promise<string>; export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>; export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -486,6 +499,8 @@ export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>; export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>; export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomRefresh():Promise<void>; export function IcomRefresh():Promise<void>;
@@ -624,6 +639,8 @@ export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>; export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>; export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
@@ -740,12 +757,16 @@ export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>; export function RefreshSolar():Promise<void>;
export function RefreshYaesuPanel():Promise<void>;
export function ReloadUDPIntegrations():Promise<Array<string>>; export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>; export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>; export function RenameDatabase(arg1:string):Promise<void>;
export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>; export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -764,6 +785,8 @@ export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>; export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>; export function RotatorPark():Promise<void>;
@@ -838,12 +861,16 @@ export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>; export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>; export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>; export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>; export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function ScpLookup(arg1:string):Promise<scp.Result>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>; export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>; export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
@@ -878,12 +905,54 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>; export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; 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 StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>; export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
@@ -894,6 +963,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>; export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>; export function TestEQSLUpload():Promise<string>;
@@ -918,6 +989,18 @@ export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationT
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>; 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>;
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
export function UILog(arg1:string):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>; export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>; export function UltrabeamRetract():Promise<void>;
@@ -951,3 +1034,9 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
export function WinkeyerStop():Promise<void>; export function WinkeyerStop():Promise<void>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>; export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
export function YaesuSendCW(arg1:string):Promise<void>;
export function YaesuStopCW():Promise<void>;
export function YaesuZeroIn():Promise<void>;
+172
View File
@@ -298,6 +298,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers'](); return window['go']['main']['App']['DownloadLoTWUsers']();
} }
export function DownloadScp() {
return window['go']['main']['App']['DownloadScp']();
}
export function DownloadULSCounties() { export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties'](); return window['go']['main']['App']['DownloadULSCounties']();
} }
@@ -634,6 +638,10 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo'](); return window['go']['main']['App']['GetAlertEmailTo']();
} }
export function GetAmpBandFollowStatus() {
return window['go']['main']['App']['GetAmpBandFollowStatus']();
}
export function GetAmpStatuses() { export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses'](); return window['go']['main']['App']['GetAmpStatuses']();
} }
@@ -870,6 +878,10 @@ export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus'](); return window['go']['main']['App']['GetSPEStatus']();
} }
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
export function GetSecretStatus() { export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
@@ -902,6 +914,14 @@ export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled'](); return window['go']['main']['App']['GetTelemetryEnabled']();
} }
export function GetTunerGeniusSettings() {
return window['go']['main']['App']['GetTunerGeniusSettings']();
}
export function GetTunerGeniusStatus() {
return window['go']['main']['App']['GetTunerGeniusStatus']();
}
export function GetUIPref(arg1) { export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1); return window['go']['main']['App']['GetUIPref'](arg1);
} }
@@ -926,6 +946,10 @@ export function GetWinkeyerStatus() {
return window['go']['main']['App']['GetWinkeyerStatus'](); return window['go']['main']['App']['GetWinkeyerStatus']();
} }
export function GetYaesuState() {
return window['go']['main']['App']['GetYaesuState']();
}
export function HasBuiltinReferences(arg1) { export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1); return window['go']['main']['App']['HasBuiltinReferences'](arg1);
} }
@@ -1202,6 +1226,10 @@ export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1); return window['go']['main']['App']['LookupCallsign'](arg1);
} }
export function LookupCallsignFresh(arg1) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
}
export function MotorReadElements() { export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements'](); return window['go']['main']['App']['MotorReadElements']();
} }
@@ -1434,6 +1462,10 @@ export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar'](); return window['go']['main']['App']['RefreshSolar']();
} }
export function RefreshYaesuPanel() {
return window['go']['main']['App']['RefreshYaesuPanel']();
}
export function ReloadUDPIntegrations() { export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations'](); return window['go']['main']['App']['ReloadUDPIntegrations']();
} }
@@ -1446,6 +1478,10 @@ export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1); return window['go']['main']['App']['RenameDatabase'](arg1);
} }
export function RenameLogbook(arg1) {
return window['go']['main']['App']['RenameLogbook'](arg1);
}
export function RenderEQSL(arg1, arg2) { export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2); return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
} }
@@ -1482,6 +1518,10 @@ export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync'](); return window['go']['main']['App']['RetryOfflineSync']();
} }
export function RevealDataFolder() {
return window['go']['main']['App']['RevealDataFolder']();
}
export function RotatorGoTo(arg1, arg2) { export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
} }
@@ -1630,6 +1670,10 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1); return window['go']['main']['App']['SaveStationSettings'](arg1);
} }
export function SaveTunerGeniusSettings(arg1) {
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
}
export function SaveUDPIntegration(arg1) { export function SaveUDPIntegration(arg1) {
return window['go']['main']['App']['SaveUDPIntegration'](arg1); return window['go']['main']['App']['SaveUDPIntegration'](arg1);
} }
@@ -1642,6 +1686,10 @@ export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1); return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
} }
export function ScpLookup(arg1) {
return window['go']['main']['App']['ScpLookup'](arg1);
}
export function SearchAwardReferences(arg1, arg2, arg3, arg4) { export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
} }
@@ -1710,6 +1758,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
export function SetTelemetryEnabled(arg1) { export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1); return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
} }
@@ -1722,6 +1774,86 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
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() { export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder'](); return window['go']['main']['App']['StartCWDecoder']();
} }
@@ -1742,6 +1874,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2); return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
} }
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
export function TestClublogUpload() { export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload'](); return window['go']['main']['App']['TestClublogUpload']();
} }
@@ -1790,6 +1926,30 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['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);
}
export function TunerGeniusAutotune() {
return window['go']['main']['App']['TunerGeniusAutotune']();
}
export function TunerGeniusSetBypass(arg1) {
return window['go']['main']['App']['TunerGeniusSetBypass'](arg1);
}
export function TunerGeniusSetOperate(arg1) {
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
}
export function UILog(arg1) {
return window['go']['main']['App']['UILog'](arg1);
}
export function ULSStatus() { export function ULSStatus() {
return window['go']['main']['App']['ULSStatus'](); return window['go']['main']['App']['ULSStatus']();
} }
@@ -1857,3 +2017,15 @@ export function WinkeyerStop() {
export function WorkedBefore(arg1, arg2) { export function WorkedBefore(arg1, arg2) {
return window['go']['main']['App']['WorkedBefore'](arg1, arg2); return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
} }
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']();
}
+266
View File
@@ -1074,6 +1074,70 @@ export namespace cat {
this.fixed = source["fixed"]; 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"];
}
}
} }
@@ -1184,6 +1248,8 @@ export namespace extsvc {
export class ServiceConfig { export class ServiceConfig {
api_key: string; api_key: string;
url: string;
station_id: string;
email: string; email: string;
username: string; username: string;
password: string; password: string;
@@ -1206,6 +1272,8 @@ export namespace extsvc {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"]; this.api_key = source["api_key"];
this.url = source["url"];
this.station_id = source["station_id"];
this.email = source["email"]; this.email = source["email"];
this.username = source["username"]; this.username = source["username"];
this.password = source["password"]; this.password = source["password"];
@@ -1228,6 +1296,7 @@ export namespace extsvc {
lotw: ServiceConfig; lotw: ServiceConfig;
hrdlog: ServiceConfig; hrdlog: ServiceConfig;
eqsl: ServiceConfig; eqsl: ServiceConfig;
cloudlog: ServiceConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ExternalServices(source); return new ExternalServices(source);
@@ -1240,6 +1309,7 @@ export namespace extsvc {
this.lotw = this.convertValues(source["lotw"], ServiceConfig); this.lotw = this.convertValues(source["lotw"], ServiceConfig);
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig); this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1436,6 +1506,10 @@ export namespace main {
port: number; port: number;
com_port: string; com_port: string;
baud: number; baud: number;
freq_out: boolean;
freq_com_port: string;
freq_baud: number;
freq_broadcast_ms: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AmpConfig(source); return new AmpConfig(source);
@@ -1452,6 +1526,10 @@ export namespace main {
this.port = source["port"]; this.port = source["port"];
this.com_port = source["com_port"]; this.com_port = source["com_port"];
this.baud = source["baud"]; this.baud = source["baud"];
this.freq_out = source["freq_out"];
this.freq_com_port = source["freq_com_port"];
this.freq_baud = source["freq_baud"];
this.freq_broadcast_ms = source["freq_broadcast_ms"];
} }
} }
export class AmpStatus { export class AmpStatus {
@@ -1523,6 +1601,7 @@ export namespace main {
format: string; format: string;
from_gain: number; from_gain: number;
mic_gain: number; mic_gain: number;
tx_gain: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AudioSettings(source); return new AudioSettings(source);
@@ -1542,6 +1621,7 @@ export namespace main {
this.format = source["format"]; this.format = source["format"];
this.from_gain = source["from_gain"]; this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
this.tx_gain = source["tx_gain"];
} }
} }
export class AutostartLaunchResult { export class AutostartLaunchResult {
@@ -1815,11 +1895,17 @@ export namespace main {
enabled: boolean; enabled: boolean;
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
omnirig_vfo: string;
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
flex_decode_spots: boolean; flex_decode_spots: boolean;
flex_decode_secs: number; flex_decode_secs: number;
xiegu_port: string;
xiegu_baud: number;
xiegu_addr: number;
yaesu_port: string;
yaesu_baud: number;
icom_port: string; icom_port: string;
icom_baud: number; icom_baud: number;
icom_addr: number; icom_addr: number;
@@ -1833,6 +1919,8 @@ export namespace main {
poll_ms: number; poll_ms: number;
delay_ms: number; delay_ms: number;
digital_default: string; digital_default: string;
share_enabled: boolean;
share_port: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new CATSettings(source); return new CATSettings(source);
@@ -1843,11 +1931,17 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"];
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
this.flex_decode_spots = source["flex_decode_spots"]; this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"]; 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.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"]; this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"]; this.icom_addr = source["icom_addr"];
@@ -1861,6 +1955,8 @@ export namespace main {
this.poll_ms = source["poll_ms"]; this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"]; this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
} }
} }
export class CabrilloResult { export class CabrilloResult {
@@ -2109,6 +2205,7 @@ export namespace main {
path: string; path: string;
default_path: string; default_path: string;
is_custom: boolean; is_custom: boolean;
logbook_default_path: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new DatabaseSettings(source); return new DatabaseSettings(source);
@@ -2119,6 +2216,7 @@ export namespace main {
this.path = source["path"]; this.path = source["path"];
this.default_path = source["default_path"]; this.default_path = source["default_path"];
this.is_custom = source["is_custom"]; this.is_custom = source["is_custom"];
this.logbook_default_path = source["logbook_default_path"];
} }
} }
export class DuplicateGroup { export class DuplicateGroup {
@@ -2315,6 +2413,7 @@ export namespace main {
user: string; user: string;
password: string; password: string;
database: string; database: string;
sqlite_path?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new MySQLSettings(source); return new MySQLSettings(source);
@@ -2328,6 +2427,7 @@ export namespace main {
this.user = source["user"]; this.user = source["user"];
this.password = source["password"]; this.password = source["password"];
this.database = source["database"]; this.database = source["database"];
this.sqlite_path = source["sqlite_path"];
} }
} }
export class OfflineStatus { export class OfflineStatus {
@@ -2716,6 +2816,22 @@ export namespace main {
this.com_port = source["com_port"]; this.com_port = source["com_port"];
} }
} }
export class ScpStatus {
enabled: boolean;
count: number;
updated?: string;
static createFrom(source: any = {}) {
return new ScpStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.count = source["count"];
this.updated = source["updated"];
}
}
export class SecretStatus { export class SecretStatus {
has_passphrase: boolean; has_passphrase: boolean;
unlocked: boolean; unlocked: boolean;
@@ -2935,6 +3051,22 @@ export namespace main {
this.error = source["error"]; this.error = source["error"];
} }
} }
export class TunerGeniusSettings {
enabled: boolean;
host: string;
password: string;
static createFrom(source: any = {}) {
return new TunerGeniusSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.password = source["password"];
}
}
export class ULSStatusResult { export class ULSStatusResult {
count: number; count: number;
updated_at: string; updated_at: string;
@@ -3065,6 +3197,7 @@ export namespace main {
engine: string; engine: string;
esc_clears_call: boolean; esc_clears_call: boolean;
send_on_type: boolean; send_on_type: boolean;
esm: boolean;
macros: WKMacro[]; macros: WKMacro[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -3094,6 +3227,7 @@ export namespace main {
this.engine = source["engine"]; this.engine = source["engine"];
this.esc_clears_call = source["esc_clears_call"]; this.esc_clears_call = source["esc_clears_call"];
this.send_on_type = source["send_on_type"]; this.send_on_type = source["send_on_type"];
this.esm = source["esm"];
this.macros = this.convertValues(source["macros"], WKMacro); this.macros = this.convertValues(source["macros"], WKMacro);
} }
@@ -3365,6 +3499,7 @@ export namespace profile {
user: string; user: string;
password: string; password: string;
database: string; database: string;
path?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ProfileDB(source); return new ProfileDB(source);
@@ -3378,6 +3513,7 @@ export namespace profile {
this.user = source["user"]; this.user = source["user"];
this.password = source["password"]; this.password = source["password"];
this.database = source["database"]; this.database = source["database"];
this.path = source["path"];
} }
} }
export class Profile { export class Profile {
@@ -4150,6 +4286,12 @@ export namespace qso {
by_day7: Bucket[]; by_day7: Bucket[];
by_day30: Bucket[]; by_day30: Bucket[];
by_month12: Bucket[]; by_month12: Bucket[];
by_day_win: Bucket[];
by_week_win: Bucket[];
by_month_win: Bucket[];
by_year_win: Bucket[];
by_hour_of_day: Bucket[];
by_weekday: Bucket[];
window_start: string; window_start: string;
window_end: string; window_end: string;
window_hours: number; window_hours: number;
@@ -4194,6 +4336,12 @@ export namespace qso {
this.by_day7 = this.convertValues(source["by_day7"], Bucket); this.by_day7 = this.convertValues(source["by_day7"], Bucket);
this.by_day30 = this.convertValues(source["by_day30"], Bucket); this.by_day30 = this.convertValues(source["by_day30"], Bucket);
this.by_month12 = this.convertValues(source["by_month12"], Bucket); this.by_month12 = this.convertValues(source["by_month12"], Bucket);
this.by_day_win = this.convertValues(source["by_day_win"], Bucket);
this.by_week_win = this.convertValues(source["by_week_win"], Bucket);
this.by_month_win = this.convertValues(source["by_month_win"], Bucket);
this.by_year_win = this.convertValues(source["by_year_win"], Bucket);
this.by_hour_of_day = this.convertValues(source["by_hour_of_day"], Bucket);
this.by_weekday = this.convertValues(source["by_weekday"], Bucket);
this.window_start = source["window_start"]; this.window_start = source["window_start"];
this.window_end = source["window_end"]; this.window_end = source["window_end"];
this.window_hours = source["window_hours"]; this.window_hours = source["window_hours"];
@@ -4299,6 +4447,25 @@ export namespace qso {
} }
export namespace scp {
export class Result {
partial: string[];
nplus1: string[];
static createFrom(source: any = {}) {
return new Result(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.partial = source["partial"];
this.nplus1 = source["nplus1"];
}
}
}
export namespace solar { export namespace solar {
export class Data { export class Data {
@@ -4406,6 +4573,105 @@ export namespace spe {
} }
export namespace tunergenius {
export class Channel {
ptt: boolean;
band: number;
mode: number;
mode_str: string;
flex: string;
freq_mhz: number;
bypass: boolean;
antenna: number;
static createFrom(source: any = {}) {
return new Channel(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ptt = source["ptt"];
this.band = source["band"];
this.mode = source["mode"];
this.mode_str = source["mode_str"];
this.flex = source["flex"];
this.freq_mhz = source["freq_mhz"];
this.bypass = source["bypass"];
this.antenna = source["antenna"];
}
}
export class Status {
connected: boolean;
host?: string;
last_error?: string;
fwd_dbm: number;
fwd_w: number;
swr_db: number;
vswr: number;
operate: boolean;
bypass: boolean;
tuning: boolean;
active: number;
three_way: boolean;
a: Channel;
b: Channel;
relay_c1: number;
relay_l: number;
relay_c2: number;
freq_mhz: number;
antenna: number;
message?: string;
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.host = source["host"];
this.last_error = source["last_error"];
this.fwd_dbm = source["fwd_dbm"];
this.fwd_w = source["fwd_w"];
this.swr_db = source["swr_db"];
this.vswr = source["vswr"];
this.operate = source["operate"];
this.bypass = source["bypass"];
this.tuning = source["tuning"];
this.active = source["active"];
this.three_way = source["three_way"];
this.a = this.convertValues(source["a"], Channel);
this.b = this.convertValues(source["b"], Channel);
this.relay_c1 = source["relay_c1"];
this.relay_l = source["relay_l"];
this.relay_c2 = source["relay_c2"];
this.freq_mhz = source["freq_mhz"];
this.antenna = source["antenna"];
this.message = source["message"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace udp { export namespace udp {
export class Config { export class Config {
+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)
}
}
}
+22 -2
View File
@@ -71,6 +71,7 @@ type Client struct {
antennas map[int]string // index → name (rebuilt into status.Antennas) antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers) antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged 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{} stop chan struct{}
running bool running bool
@@ -87,6 +88,7 @@ func New(host string, port int, password string) *Client {
stop: make(chan struct{}), stop: make(chan struct{}),
antennas: map[int]string{}, antennas: map[int]string{},
antBands: map[int]int{}, antBands: map[int]int{},
lastShown: map[int]int{},
status: Status{Host: host}, status: Status{Host: host},
} }
} }
@@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) {
} }
tx := kvInt(msg, "txant") tx := kvInt(msg, "txant")
rx := kvInt(msg, "rxant") 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 { 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 txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
c.setStatus(func(s *Status) { 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. // Play renders a WAV file to deviceID. Any current playback is stopped first.
// Returns immediately; playback runs in the background. // 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) pcm, rate, ch, bits, err := readWAV(path)
if err != nil { if err != nil {
return err 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() m.StopPlayback()
stop := make(chan struct{}) stop := make(chan struct{})
m.mu.Lock() m.mu.Lock()
@@ -265,3 +281,16 @@ func (m *Manager) TXAudioActive() bool {
defer m.mu.Unlock() defer m.mu.Unlock()
return m.txStop != nil 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)
}
+6 -1
View File
@@ -6,6 +6,7 @@
// QSO may yield several references (e.g. a Note holding "D74 D73"). // QSO may yield several references (e.g. a Note holding "D74 D73").
// //
// Examples: // Examples:
//
// DXCC : field "dxcc" (no pattern) → entity number // DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state // WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes // DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
return out return out
} }
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"} var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
// Microwave. A band missing from this order sorts to the end of an award's
// band columns rather than in frequency order — and 3 cm was missing.
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
func sortedBands(m map[string]int) []string { func sortedBands(m map[string]int) []string {
idx := map[string]int{} idx := map[string]int{}
+27 -1
View File
@@ -45,7 +45,7 @@
], ],
"total": 0, "total": 0,
"builtin": true, "builtin": true,
"version": 1 "version": 3
}, },
"references": [ "references": [
{ {
@@ -54,6 +54,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
"valid": true "valid": true
}, },
{ {
@@ -62,6 +63,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Appenzell|Oberegg)\\b",
"valid": true "valid": true
}, },
{ {
@@ -70,6 +72,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
"valid": true "valid": true
}, },
{ {
@@ -78,6 +81,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -86,6 +90,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
"valid": true "valid": true
}, },
{ {
@@ -94,6 +99,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -102,6 +108,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
"valid": true "valid": true
}, },
{ {
@@ -110,6 +117,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Genève|Geneve|Sezenove|Chene-Bourg|Chene Bourg|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
"valid": true "valid": true
}, },
{ {
@@ -118,6 +126,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
"valid": true "valid": true
}, },
{ {
@@ -126,6 +135,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
"valid": true "valid": true
}, },
{ {
@@ -134,6 +144,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
"valid": true "valid": true
}, },
{ {
@@ -142,6 +153,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
"valid": true "valid": true
}, },
{ {
@@ -150,6 +162,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
"valid": true "valid": true
}, },
{ {
@@ -158,6 +171,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -166,6 +180,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
"valid": true "valid": true
}, },
{ {
@@ -174,6 +189,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
"valid": true "valid": true
}, },
{ {
@@ -182,6 +198,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -190,6 +207,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
"valid": true "valid": true
}, },
{ {
@@ -198,6 +216,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
"valid": true "valid": true
}, },
{ {
@@ -206,6 +225,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -214,6 +234,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
"valid": true "valid": true
}, },
{ {
@@ -222,6 +243,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
"valid": true "valid": true
}, },
{ {
@@ -230,6 +252,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Lausanne|Chavornay|Blonay|Saint-Cergue|St-Cergue|St Cergue|Yverdon|Gingins|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
"valid": true "valid": true
}, },
{ {
@@ -238,6 +261,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Sion|Sitten|Martigny|Monthey|Sierre|Brig|Brigue|Naters|Visp|Viège|Conthey|Fully|Verbier|Zermatt|Crans-Montana|Saas-Fee|Savièse|Bagnes)\\b",
"valid": true "valid": true
}, },
{ {
@@ -246,6 +270,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
"valid": true "valid": true
}, },
{ {
@@ -254,6 +279,7 @@
"dxcc": 287, "dxcc": 287,
"group": "", "group": "",
"subgrp": "", "subgrp": "",
"pattern": "\\b(Zürich|Zurich|Winterthur|Uster|Dübendorf|Dietikon|Wetzikon|Kloten|Wädenswil|Horgen|Thalwil|Opfikon|Küsnacht|Meilen|Bülach|Regensdorf|Schlieren|Adliswil|Volketswil)\\b",
"valid": true "valid": true
} }
] ]
+70
View File
@@ -649,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
const reconnectEvery = 5 * time.Second const reconnectEvery = 5 * time.Second
connected := false connected := false
var lastAttempt time.Time var lastAttempt time.Time
var lastConnErr string // last connect failure logged, so the retry loop says it once
tryConnect := func() { tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery { if connected || time.Since(lastAttempt) < reconnectEvery {
return return
} }
lastAttempt = time.Now() lastAttempt = time.Now()
if err := b.Connect(); err != nil { if err := b.Connect(); err != nil {
// Log it — the message used to live only in RigState.Error, i.e. in a
// tooltip. The status pill condenses everything to "OmniRig not found",
// so a user reporting that had no way to tell us WHY: the COM HRESULT,
// the serial error, the refused TCP connect, all invisible. Logged once
// per distinct message so the retry loop doesn't flood the file.
if msg := err.Error(); msg != lastConnErr {
lastConnErr = msg
debugLog.Printf("%s connect failed: %s", b.Name(), msg)
}
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()}) m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return return
} }
if lastConnErr != "" {
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
lastConnErr = ""
}
connected = true connected = true
} }
tryConnect() tryConnect()
@@ -762,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
func BandFromHz(hz int64) string { func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000 mhz := float64(hz) / 1_000_000
switch { switch {
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
// which then cost them the band on the QSO and in every award count.
case mhz >= 0.1357 && mhz <= 0.1378:
return "2190m"
case mhz >= 0.472 && mhz <= 0.479:
return "630m"
case mhz >= 0.501 && mhz <= 0.504:
return "560m"
case mhz >= 1.8 && mhz <= 2.0: case mhz >= 1.8 && mhz <= 2.0:
return "160m" return "160m"
case mhz >= 3.5 && mhz <= 4.0: case mhz >= 3.5 && mhz <= 4.0:
@@ -796,6 +818,54 @@ func BandFromHz(hz int64) string {
return "33cm" return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0: case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm" return "23cm"
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
// actually work, and spot — resolved to an empty band: no band on the QSO, no
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
// which is what an export has to carry.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
} }
return "" 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)
})
}
+29 -6
View File
@@ -307,22 +307,45 @@ func ModeToADIF(m byte, data bool) string {
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable // ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
// model. Unknown addresses fall back to a hex label. // model. Unknown addresses fall back to a hex label.
//
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
// 20 dB on the small ones. An address missing here therefore shows the WRONG
// attenuator buttons, and the rig NAKs them.
//
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
//
// Addresses cross-checked against the TR4W CI-V table and an independent
// published list; both agree on every value below.
func ModelName(addr byte) string { func ModelName(addr byte) string {
switch addr { switch addr {
case 0x6A:
return "IC-7800"
case 0x74:
return "IC-7700"
case 0x7A:
return "IC-7600"
case 0x7C:
return "IC-9100"
case 0x80:
return "IC-7410"
case 0x88:
return "IC-7100"
case 0x8E:
return "IC-7851" // shared with the IC-7850
case 0x94: case 0x94:
return "IC-7300" return "IC-7300"
case 0x98: case 0x98:
return "IC-7610" return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2: case 0xA2:
return "IC-9700" return "IC-9700"
case 0xA4: case 0xA4:
return "IC-705" return "IC-705"
case 0x88: case 0xB6:
return "IC-7700" return "IC-7300MKII"
case 0x80:
return "IC-7800"
} }
return fmt.Sprintf("Icom (0x%02X)", addr) return fmt.Sprintf("Icom (0x%02X)", addr)
} }
+33
View File
@@ -130,3 +130,36 @@ func TestModelName(t *testing.T) {
t.Errorf("ModelName(0x12) = %q, want fallback", got) t.Errorf("ModelName(0x12) = %q, want fallback", got)
} }
} }
// CI-V addresses are hardware constants: a wrong one means the console shows the
// wrong model, and with it the wrong attenuator steps (6/12/18 dB on the big
// rigs, a single 20 dB on the small ones) — buttons the rig then NAKs.
//
// Two entries here were previously wrong in a way that pointed at each other:
// 0x80 was labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the
// IC-7100), while the real IC-7800 (0x6A) and IC-7700 (0x74) were missing — so an
// IC-7800 came up as "Icom (0x6A)" with a 20 dB attenuator it does not have.
func TestModelNameAddresses(t *testing.T) {
for addr, want := range map[byte]string{
0x6A: "IC-7800",
0x74: "IC-7700",
0x7A: "IC-7600",
0x7C: "IC-9100",
0x80: "IC-7410",
0x88: "IC-7100",
0x8E: "IC-7851",
0x94: "IC-7300",
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)
}
}
// An unknown address must stay identifiable rather than masquerade as a model.
if got := ModelName(0x42); got != "Icom (0x42)" {
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
}
}
+15 -12
View File
@@ -50,7 +50,6 @@ type Flex struct {
meterMeta map[int]meterInfo meterMeta map[int]meterInfo
meterVal map[int]float64 meterVal map[int]float64
meterSub map[int]bool // ids we've already sent "sub meter <id>" for meterSub map[int]bool // ids we've already sent "sub meter <id>" for
meterLogAt time.Time // throttle for value logging
vitaSeen int // count of UDP datagrams (first few logged for diag) vitaSeen int // count of UDP datagrams (first few logged for diag)
meterRawLogged bool // log the first raw meter-definition status once meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit) txRawLogged bool // log the first raw transmit status once (field-name audit)
@@ -518,7 +517,14 @@ func (f *Flex) handleStatus(payload string) {
alreadyBound := f.boundClientID != "" alreadyBound := f.boundClientID != ""
f.mu.Unlock() f.mu.Unlock()
lp := strings.ToLower(program) lp := strings.ToLower(program)
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro") // The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
// name, so exclude them — and require an explicit GUI name (dropping the
// old program=="" fallback that could match CAT before its program field
// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
// SmartSDR CAT (connect/disconnect loop) when a logger did this.
isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
if !disconnected && clientID != "" && !alreadyBound && isGUI { if !disconnected && clientID != "" && !alreadyBound && isGUI {
f.mu.Lock() f.mu.Lock()
f.boundClientID = clientID f.boundClientID = clientID
@@ -761,11 +767,17 @@ func (f *Flex) handleStatus(payload string) {
f.meterMeta[num] = old f.meterMeta[num] = old
} }
f.mu.Unlock() f.mu.Unlock()
// One line for the whole batch, not one per meter: a Flex announces
// dozens at connect and that was a wall of text every time.
var names []string
for _, id := range newIDs { for _, id := range newIDs {
mi := f.meterMeta[id] mi := f.meterMeta[id]
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi) names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
f.subscribeMeter(id) f.subscribeMeter(id)
} }
if len(names) > 0 {
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
}
} }
// Spot status: "spot <index> …". Track the index so we can clear the // Spot status: "spot <index> …". Track the index so we can clear the
// panadapter, and log it verbatim — a click on a panadapter spot pushes a // panadapter, and log it verbatim — a click on a panadapter spot pushes a
@@ -2147,15 +2159,6 @@ func (f *Flex) parseVita(p []byte, seen int) {
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4])) raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit) f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
} }
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
f.meterLogAt = time.Now()
var b strings.Builder
for id, v := range f.meterVal {
mi := f.meterMeta[id]
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
}
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
}
f.mu.Unlock() f.mu.Unlock()
} }
+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)
}
}
+109 -2
View File
@@ -37,6 +37,19 @@ type aliveTransport interface {
Alive() bool 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 // scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
// (0x27) frames on a SEPARATE channel from control replies. The network transport // (0x27) frames on a SEPARATE channel from control replies. The network transport
// implements it so the continuous panadapter stream can't crowd control replies // implements it so the continuous panadapter stream can't crowd control replies
@@ -95,6 +108,10 @@ type IcomSerial struct {
splitOn bool // last read split state (refreshed every few cycles) splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance) 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 dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers) // the panel's set-once controls once the rig actually answers)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
@@ -267,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// rig is switched on) rather than tearing the whole UDP session down and // 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. // flapping every few seconds. The panel stays up so the ON button works.
if at, ok := b.port.(aliveTransport); ok { 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 b.readFails = 0
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
if b.curModeByte != 0 { if b.curModeByte != 0 {
@@ -286,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock() b.dspMu.Unlock()
return s, nil return s, nil
} }
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).") 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 }
// 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 // USB (no liveness signal): the rig briefly stops answering CI-V while it
// switches band/VFO. Tolerate a few consecutive misses as transient — keep // switches band/VFO. Tolerate a few consecutive misses as transient — keep
@@ -308,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
return RigState{}, err return RigState{}, err
} }
b.readFails = 0 b.readFails = 0
b.lastGoodAt = time.Now()
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
s.FreqHz = hz s.FreqHz = hz
b.curFreq = hz b.curFreq = hz
@@ -374,10 +437,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
if !b.dspLoaded { if !b.dspLoaded {
b.readDSP() b.readDSP()
b.dspLoaded = true b.dspLoaded = true
} else {
b.refreshFrontPanel()
} }
return s, nil return s, nil
} }
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
// the rig itself, so the console follows the radio instead of only driving it.
//
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
// the link was plainly fine — only this direction was missing.
//
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
// including the operator's own Set* commands, which is far worse than a stale
// label. The rotation completes in 8 cycles; anything not covered here is still a
// connect-time or ↻ Refresh read.
func (b *IcomSerial) refreshFrontPanel() {
switch b.pollN % 8 {
case 1:
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
b.dspMu.Lock()
b.dsp.AGC = agcName(v)
b.dspMu.Unlock()
}
case 3:
if v, ok := b.readAtt(); ok {
b.dspMu.Lock()
b.dsp.Att = v
b.dspMu.Unlock()
}
case 5:
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
b.dspMu.Lock()
b.dsp.Preamp = int(v)
b.dspMu.Unlock()
}
case 7:
if _, f, ok := b.readModeFilter(); ok {
b.dspMu.Lock()
b.dsp.Filter = int(f)
b.dspMu.Unlock()
}
}
}
func (b *IcomSerial) SetFrequency(hz int64) error { func (b *IcomSerial) SetFrequency(hz int64) error {
if hz <= 0 { if hz <= 0 {
return fmt.Errorf("invalid frequency") return fmt.Errorf("invalid frequency")
+19 -4
View File
@@ -4,6 +4,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sync"
) )
// LogSink, when set by the host app at startup, receives every CAT debug // LogSink, when set by the host app at startup, receives every CAT debug
@@ -15,13 +16,21 @@ var LogSink func(format string, args ...any)
// catLogger forwards Printf either to the host LogSink (preferred) or to a // catLogger forwards Printf either to the host LogSink (preferred) or to a
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...)) // local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
// unchanged. // unchanged.
type catLogger struct{ fallback *log.Logger } type catLogger struct {
once sync.Once
fallback *log.Logger
}
func (c *catLogger) Printf(format string, args ...any) { func (c *catLogger) Printf(format string, args ...any) {
if LogSink != nil { if LogSink != nil {
LogSink("cat: "+format, args...) LogSink("cat: "+format, args...)
return return
} }
// Only now, on a line that genuinely has nowhere else to go, is the fallback
// file opened. It used to be created at package init, so every installation
// grew an %APPDATA%\OpsLog\cat.log that nothing ever wrote to once the app
// wired LogSink — a decoy for anyone told to "check the CAT log".
c.once.Do(func() { c.fallback = openFallbackLog() })
if c.fallback != nil { if c.fallback != nil {
c.fallback.Printf(format, args...) c.fallback.Printf(format, args...)
} }
@@ -30,7 +39,7 @@ func (c *catLogger) Printf(format string, args ...any) {
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches // debugLog writes CAT debug events so users can diagnose mode/freq mismatches
// without rebuilding with a console. Once LogSink is set, lines flow into the // without rebuilding with a console. Once LogSink is set, lines flow into the
// main opslog.log. // main opslog.log.
var debugLog = &catLogger{fallback: openFallbackLog()} var debugLog = &catLogger{}
func openFallbackLog() *log.Logger { func openFallbackLog() *log.Logger {
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
@@ -49,9 +58,15 @@ func openFallbackLog() *log.Logger {
return log.New(f, "", log.LstdFlags|log.Lmicroseconds) return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
} }
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the // DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead. // going to the app log instead — which is the normal case, and the answer callers
// actually need. It previously returned the path unconditionally, so the one place
// that displayed it sent operators to an empty file in %APPDATA% while their CAT
// diagnostics sat in data\opslog.log.
func DebugLogPath() string { func DebugLogPath() string {
if LogSink != nil {
return "" // lines go to the unified app log; there is no separate cat.log
}
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
return "" return ""
+311 -68
View File
@@ -29,11 +29,24 @@ const (
type OmniRig struct { type OmniRig struct {
RigNum int // 1 (Rig1) or 2 (Rig2) RigNum int // 1 (Rig1) or 2 (Rig2)
// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
// not. OmniRig's VFO report is only as good as the .ini behind it, and a
// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
// on the main VFO) and read B — the frequency "never followed the knob",
// while it was following the other one all along.
ForceVFO string
omnirig *ole.IDispatch omnirig *ole.IDispatch
rig *ole.IDispatch rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change lastSig string // last logged Split/VFO signature — only log on change
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610" rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
// connLogged holds the connect failure already written to the log, so the
// 5-second reconnect loop reports a persistent problem once instead of
// forever. Cleared on success.
connLogged string
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency. // lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's // SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two // async Freq property, which still reports the OLD band for a poll or two
@@ -41,20 +54,90 @@ type OmniRig struct {
// the sideband (freq moved, but mode read the old band → wrong sideband). // the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64 lastSetFreq int64
lastSetFreqAt time.Time lastSetFreqAt time.Time
// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
// on consecutive polls, so the flag has to be latched to be usable.
lastSplitOnAt time.Time
// splitFlaky records that THIS rig's .ini flips the split flag on its own,
// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
// count the flips inside a rolling window to detect it.
lastSplitFlag bool
splitFlaky bool
splitFlips int
splitFlipWindow time.Time
// pendingReadback schedules one delayed frequency log after a Set — see
// SetFrequency. Zero when nothing is pending.
pendingReadback time.Time
} }
// NewOmniRig creates a non-connected backend. Call Connect before use. // NewOmniRig creates a non-connected backend. Call Connect before use.
func NewOmniRig(rigNum int) *OmniRig { // NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
// reports, or "A"/"B" to override it — see the ForceVFO field.
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
if rigNum < 1 || rigNum > 2 { if rigNum < 1 || rigNum > 2 {
rigNum = 1 rigNum = 1
} }
return &OmniRig{RigNum: rigNum} v := strings.ToUpper(strings.TrimSpace(forceVFO))
if v != "A" && v != "B" {
v = ""
}
return &OmniRig{RigNum: rigNum, ForceVFO: v}
} }
func (o *OmniRig) Name() string { return "omnirig" } func (o *OmniRig) Name() string { return "omnirig" }
// elevationHint recognises the COM refusal that happens when OmniRig runs
// elevated (as administrator) and OpsLog does not — or the reverse. Windows keeps
// the two integrity levels apart, so the client cannot bind to the running
// server's object and COM falls back to launching a fresh one, which then needs
// elevation the client cannot grant.
//
// It is worth naming explicitly: the operator SEES OmniRig running, with its
// settings window open, so "OmniRig not found" reads as nonsense and sends them
// hunting for a driver or COM-port problem that does not exist. The fix is thirty
// seconds of work once you know what to look for.
func elevationHint(err error) string {
if err == nil {
return ""
}
msg := strings.ToLower(err.Error())
// Matched on the HRESULT text in whatever language Windows is running in, so
// the code is checked too: 0x800702E4 = ERROR_ELEVATION_REQUIRED.
if strings.Contains(msg, "elevation") || strings.Contains(msg, "élévation") ||
strings.Contains(msg, "0x800702e4") || strings.Contains(msg, "access denied") ||
strings.Contains(msg, "accès refusé") {
return "OmniRig and OpsLog are running at different privilege levels — Windows keeps them apart, " +
"so OpsLog cannot reach OmniRig even though it is running. Start BOTH the same way: either " +
"un-tick \"Run as administrator\" on the OmniRig shortcut (and its Compatibility tab), or run " +
"OpsLog as administrator too"
}
return ""
}
// logConnFailure writes a connect failure once per distinct cause. The reconnect
// loop retries every 5 seconds forever, and a station whose OmniRig was simply
// elevated had this filling its log at roughly 1500 lines an hour — which buries
// the very diagnostics someone would go looking for.
func (o *OmniRig) logConnFailure(msg string) {
if o.connLogged == msg {
return
}
o.connLogged = msg
debugLog.Printf("OmniRig Rig%d: %s", o.RigNum, msg)
}
func (o *OmniRig) Connect() error { func (o *OmniRig) Connect() error {
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath()) // This used to announce DebugLogPath() on every attempt — the path of the
// FALLBACK cat.log, which nothing writes to once the app has wired LogSink and
// everything goes to data\opslog.log. It pointed operators at an empty file in
// %APPDATA% while the lines they wanted were somewhere else entirely. Dropped;
// and logged once per failure run rather than every 5-second retry.
if o.connLogged == "" {
debugLog.Printf("OmniRig.Connect Rig%d", o.RigNum)
}
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil { if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
// 0x1 = S_FALSE → COM already initialised on this thread, fine. // 0x1 = S_FALSE → COM already initialised on this thread, fine.
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 { if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
@@ -62,15 +145,43 @@ func (o *OmniRig) Connect() error {
} }
} }
unk, err := oleutil.CreateObject("Omnirig.OmnirigX") const progID = "Omnirig.OmnirigX"
if err != nil { var omnirig *ole.IDispatch
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err) unk, err := oleutil.CreateObject(progID)
} if err == nil {
omnirig, err := unk.QueryInterface(ole.IID_IDispatch) omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
unk.Release() unk.Release()
if err != nil { if err != nil {
return fmt.Errorf("query interface: %w", err) return fmt.Errorf("query interface: %w", err)
} }
} else {
// A privilege mismatch is final — retrying, or trying the 32-bit server,
// cannot cross an integrity boundary. Say what to do instead of dressing it
// up as "not installed", which is what sends operators looking in the wrong
// place entirely.
if hint := elevationHint(err); hint != "" {
o.logConnFailure(hint)
return fmt.Errorf("%s (Windows said: %v)", hint, err)
}
// Otherwise it may be a partial registration; try activating the 32-bit
// server explicitly before giving up (see omnirig_activate32.go).
disp, err32 := createOmniRig32(progID)
if err32 != nil {
o.logConnFailure(fmt.Sprintf("CreateObject(%s) failed: %v; 32-bit activation also failed: %v", progID, err, err32))
// Name the version requirement. HB9RYZ's OmniRig v2.1 is a different
// product that its own author states is not compatible with v1, and it
// does not provide v1's IOmniRigX interface — so an operator who has
// only v2 installed sees OmniRig running and OpsLog failing, with
// nothing to connect the two facts.
return fmt.Errorf("OmniRig (v1) not reachable: %w — OpsLog needs OmniRig v1.19/v1.20 "+
"(VE3NEA/Alex), the interface every logger uses. HB9RYZ's OmniRig v2.1 is a separate, "+
"incompatible product and cannot serve OpsLog; the two may be installed side by side, "+
"but v1 must be present and running", err)
}
debugLog.Printf("OmniRig: reached via explicit 32-bit activation after CreateObject failed (%v)", err)
omnirig = disp
}
o.connLogged = "" // connected: re-arm the one-shot failure logging
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum)) rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
if err != nil { if err != nil {
@@ -80,10 +191,26 @@ func (o *OmniRig) Connect() error {
o.omnirig = omnirig o.omnirig = omnirig
o.rig = rigVar.ToIDispatch() o.rig = rigVar.ToIDispatch()
// Log WHICH OmniRig answered. There are two incompatible products called
// OmniRig: v1.19/1.20 (Alex, VE3NEA), whose IOmniRigX interface every logger
// including OpsLog uses, and HB9RYZ's v2.1, which its own author states is "not
// compatible" with v1 and works only with programs written for it. They can
// coexist, and OmniRig's own window looks much the same either way — so an
// operator running only v2 sees OmniRig on screen, sees OpsLog fail, and has no
// way to know the two were never going to talk. These two version numbers
// settle it in one line of a bug report.
var iv, sv int64 = -1, -1
if v, err := oleutil.GetProperty(o.omnirig, "InterfaceVersion"); err == nil {
iv = v.Val
}
if v, err := oleutil.GetProperty(o.omnirig, "SoftwareVersion"); err == nil {
sv = v.Val
}
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil { if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
o.rigType = rt.ToString() o.rigType = rt.ToString()
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
} }
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
o.RigNum, o.rigType, iv, sv)
return nil return nil
} }
@@ -155,66 +282,165 @@ func (o *OmniRig) ReadState() (RigState, error) {
splitRaw = v.Val splitRaw = v.Val
} }
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so // FTDX101D field capture: OmniRig alternates between two contradictory
// normal operation stays quiet but toggling split on the radio is captured — // readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
// needed to pin down this rig's PM_SPLITON value. // "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
// .ini evidently has two status commands that each write these params. A
// sample-by-sample test therefore reports split for half the polls and no
// split for the other half, which the UI shows as no split at all. Latch the
// ON flag briefly so one truthful sample survives the contradicting one; the
// latch expires on its own once the rig stops reporting ON, so cancelling
// split on the radio still clears within a few seconds.
//
// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
// read as separate frames, confirmed on the air 2026-07-26) never flips
// unprompted, and there the latch would be a pure delay on a reading that was
// already right.
//
// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
// split three times while testing, which then imposed the 6 s delay on a rig
// that did not need it. A misreading .ini flips every 1.53 s without being
// touched — a dozen in the same window — so the gap is wide. The arming also
// expires after 30 s without a flip, so a rig that behaves is never stuck with
// the delay because of one burst.
const flipWindow, flipsToArm = 30 * time.Second, 8
now := time.Now()
flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
if flagOn {
o.lastSplitOnAt = now
}
if flagOn != o.lastSplitFlag {
o.lastSplitFlag = flagOn
if now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
}
o.splitFlips++
if o.splitFlips >= flipsToArm {
o.splitFlaky = true
}
} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
}
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
// A forced VFO replaces whatever the rig file reported, BEFORE anything is
// derived from it. Nothing downstream then has to know about the override.
vfo := s.Vfo
if o.ForceVFO != "" {
vfo = o.ForceVFO
}
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)
// Delayed readback after a Set (see SetFrequency): logged from HERE because
// this runs on the COM-owning goroutine. One line, once per set.
if !o.pendingReadback.IsZero() && time.Now().After(o.pendingReadback) {
o.pendingReadback = time.Time{}
debugLog.Printf("OmniRig.SetFrequency: readback +1.5s FreqA=%d FreqB=%d Freq=%d → shown %d",
freqA, freqB, freqMain, s.FreqHz)
}
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
// operation stays quiet but toggling split or SUB VFO on the radio is
// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
// user's log showed what OmniRig said but not what OpsLog concluded, which is
// the half that was wrong.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig { if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d", debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 { o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil { s.FreqHz, s.RxFreqHz, s.Split)
return v.Val
}
return -1
}())
}
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
// RX = the active VFO, TX = the other one. This is far more reliable than
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
// report inverted — the split then showed TX/RX swapped.
s.Split = true
switch {
case freqMain != 0 && freqMain == freqA:
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
case s.Vfo == "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
default:
s.FreqHz, s.RxFreqHz = freqB, freqA
}
} else {
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false
s.RxFreqHz = 0
switch {
case freqA != 0:
s.FreqHz = freqA
case freqMain != 0:
s.FreqHz = freqMain
default:
s.FreqHz = freqB
}
} }
return s, nil return s, nil
} }
// resolveOmniRigVFOs turns OmniRig's four readings into the ADIF pair
// (FreqHz = TX, RxFreqHz = RX) plus a split flag.
//
// Pure and separate from ReadState because it encodes rig-specific rules that
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
// to change it safely is with every known rig's behaviour pinned in a test. COM
// cannot be exercised from a test; this can.
func resolveOmniRigVFOs(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (txHz, rxHz int64, split bool) {
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
// nothing else, and silently reports "no split" for any rig reporting the bit
// alongside another. Requiring ON set and OFF clear keeps the two states apart
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
// tolerating extra bits.
splitFlagged := (splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0) || splitRecentOn
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
// band test kills the common false positive where VFO B merely holds a
// leftover from another band (a "28 MHz / 7 MHz split" is nonsensical), which
// on the FT-710 / TS-570 otherwise froze the TX freq on the wrong VFO.
if splitFlagged && freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB) {
// RX is the VFO being listened on — identified from the generic Freq rather
// than from the Vfo AB/BA enum, which several rigs (Yaesu FTDX10) report
// inverted, showing TX and RX swapped.
switch {
case freqMain != 0 && freqMain == freqA:
return freqB, freqA, true // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
return freqA, freqB, true // listening on B → TX on A
case vfo == "BA":
return freqA, freqB, true // fall back to the Vfo enum
default:
return freqB, freqA, true
}
}
// Simplex. The VFO the rig says is ACTIVE comes first: preferring freqA
// unconditionally (as this did) meant the displayed frequency never left VFO
// A — press SUB VFO on an FTDX101D and the radio receives on B while OpsLog
// went on showing A, taking the band and the logged frequency with it.
//
// Only a VFO OmniRig explicitly names is honoured, so a rig that does not
// report the enum keeps exactly the previous fallback order. That matters for
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
// the generic Freq.
// The PAIR enums name BOTH VFOs at once — first letter = the one being
// listened on, second = the one that transmits. Only the single-letter forms
// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
// family) always fell through to freqA and never followed the operator to SUB.
// Log4OM reads that first letter and logs the right frequency on the same
// rigs, which is what showed this was readable data and not a dead end.
//
// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
// rig speaking, the fallback is only an inference.
switch {
case (vfo == "BA" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "AA" || vfo == "AB") && freqA != 0:
return freqA, 0, false
}
// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
// There the generic Freq is the last clue — matching FreqB and not FreqA means
// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
// the generic Freq as VFO B permanently, where this would name the wrong VFO —
// that case is pinned in the test table.
if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
return freqB, 0, false
}
switch {
case (vfo == "B" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "A" || vfo == "AA") && freqA != 0:
return freqA, 0, false
case freqA != 0:
return freqA, 0, false
case freqMain != 0:
return freqMain, 0, false
default:
return freqB, 0, false
}
}
func (o *OmniRig) SetFrequency(hz int64) error { func (o *OmniRig) SetFrequency(hz int64) error {
if o.rig == nil { if o.rig == nil {
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz) debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
@@ -303,10 +529,17 @@ func (o *OmniRig) SetFrequency(hz int64) error {
} }
} }
// Read back all three immediately. OmniRig is async (the CAT command is // Read back all three, then AGAIN a moment later. OmniRig is async the CAT
// queued + sent over serial), so these may still show the OLD value for // command is queued and sent over serial so an immediate read always shows
// one poll cycle — but if they NEVER change in the next poll, the rig // the old value and proves nothing. The delayed one is the useful half: it
// isn't honouring the write (wrong .ini WRITE command for this model). // separates "the rig ignored the write" from "the rig moved but its .ini
// never re-reads the frequency", which look identical to an operator whose
// display stays on the old band until they nudge the VFO.
//
// Off the CAT goroutine so the poll loop is not held for a second. COM is
// thread-affine, so the delayed read goes through the manager's own command
// channel rather than touching o.rig from here.
logFreqs := func(when string) {
fa, fb, fg := int64(-1), int64(-1), int64(-1) fa, fb, fg := int64(-1), int64(-1), int64(-1)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil { if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
fa = v.Val fa = v.Val
@@ -317,7 +550,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
fg = v.Val fg = v.Val
} }
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz) debugLog.Printf("OmniRig.SetFrequency: readback %s FreqA=%d FreqB=%d Freq=%d (target %d)", when, fa, fb, fg, hz)
}
logFreqs("now")
o.pendingReadback = time.Now().Add(1500 * time.Millisecond)
return nil return nil
} }
@@ -486,6 +722,13 @@ func omniRigMode(m int64) string {
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the // omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants. // documented PM_VFO* constants.
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
func isYaesuRig(rigType string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
}
func omniRigVfo(v int64) string { func omniRigVfo(v int64) string {
switch { switch {
case v&0x40 != 0: // PM_VFOAA case v&0x40 != 0: // PM_VFOAA
+83
View File
@@ -0,0 +1,83 @@
package cat
import (
"fmt"
"syscall"
"unsafe"
ole "github.com/go-ole/go-ole"
"golang.org/x/sys/windows/registry"
)
// Last-resort activation path for OmniRig, used only when the normal
// CreateObject fails.
//
// OmniRig is a 32-bit program and its installer splits its COM identity across
// registry views — on a working machine the ProgID sits in the 64-bit view while
// the CLSID and its LocalServer32 exist only under WOW6432Node. That LOOKS like
// it should break a 64-bit client, and it was my first theory for "OmniRig not
// found"; measuring it on a machine with exactly that layout disproved it. COM
// resolves an out-of-process server across views by itself, and the plain
// CreateObject succeeds.
//
// What this still covers is the case where the ProgID is not visible to us at all
// (a partial or 32-bit-only registration), where CreateObject has nothing to
// resolve. Here we read the CLSID from BOTH views ourselves and activate the
// 32-bit local server explicitly. CLSCTX_ACTIVATE_32_BIT_SERVER is the documented
// flag for that; go-ole hard-codes CLSCTX_SERVER and keeps CoCreateInstance
// unexported, hence the direct call.
//
// It costs nothing when the normal path works, and the reason it ran at all is
// logged — so if it ever rescues a real installation we will see it.
const clsctxActivate32BitServer = 0x40000
var (
modole32 = syscall.NewLazyDLL("ole32.dll")
procCoCreateInst32 = modole32.NewProc("CoCreateInstance")
)
// omnirigCLSIDFromRegistry reads OmniRig's CLSID from the ProgID key, looking in
// the 64-bit view first and then the 32-bit one. Returned as a GUID ready for
// CoCreateInstance.
func omnirigCLSIDFromRegistry(progID string) (*ole.GUID, error) {
for _, access := range []uint32{registry.QUERY_VALUE, registry.QUERY_VALUE | registry.WOW64_32KEY} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Classes\`+progID+`\CLSID`, access)
if err != nil {
continue
}
s, _, err := k.GetStringValue("")
k.Close()
if err != nil || s == "" {
continue
}
if g := ole.NewGUID(s); g != nil {
return g, nil
}
}
return nil, fmt.Errorf("no CLSID registered for %s in either registry view", progID)
}
// createOmniRig32 activates OmniRig's 32-bit out-of-process server explicitly,
// bypassing the 64-bit registry lookup that CoCreateInstance would otherwise do.
func createOmniRig32(progID string) (*ole.IDispatch, error) {
clsid, err := omnirigCLSIDFromRegistry(progID)
if err != nil {
return nil, err
}
var unk *ole.IUnknown
hr, _, _ := procCoCreateInst32.Call(
uintptr(unsafe.Pointer(clsid)),
0, // no aggregation
uintptr(ole.CLSCTX_LOCAL_SERVER|clsctxActivate32BitServer),
uintptr(unsafe.Pointer(ole.IID_IUnknown)),
uintptr(unsafe.Pointer(&unk)))
if hr != 0 {
return nil, fmt.Errorf("CoCreateInstance(32-bit local server) failed: %w", ole.NewError(hr))
}
disp, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return nil, fmt.Errorf("query IDispatch: %w", err)
}
return disp, nil
}
+73
View File
@@ -0,0 +1,73 @@
package cat
import (
"errors"
"testing"
)
// The COM refusal when OmniRig runs elevated and OpsLog does not (or the reverse)
// must be recognised and named. It reached a user as "OmniRig not found" while
// OmniRig sat visibly on screen with its settings window open — so the report was
// a driver hunt that could never succeed.
//
// Windows returns this text localised, so the match cannot rely on English alone;
// the HRESULT (0x800702E4 = ERROR_ELEVATION_REQUIRED) is accepted too.
func TestElevationHint(t *testing.T) {
recognised := []string{
"Lopération demandée nécessite une élévation.", // as logged, fr-FR
"The requested operation requires elevation.",
"Access denied",
"Accès refusé",
"CoCreateInstance failed: 0x800702E4",
}
for _, msg := range recognised {
if elevationHint(errors.New(msg)) == "" {
t.Errorf("not recognised as a privilege problem: %q", msg)
}
}
// Unrelated failures must NOT be blamed on elevation, or the advice sends the
// operator to the wrong place just as surely.
for _, msg := range []string{
"Classe non enregistrée",
"REGDB_E_CLASSNOTREG",
"no CLSID registered for Omnirig.OmnirigX in either registry view",
"open COM3: Access is den", // truncated word must not match "access denied"
} {
if h := elevationHint(errors.New(msg)); h != "" {
t.Errorf("%q wrongly reported as a privilege problem: %s", msg, h)
}
}
if elevationHint(nil) != "" {
t.Error("nil error must yield no hint")
}
}
// The reconnect loop runs every 5 s forever; a persistent failure must be logged
// once, not 1500 times an hour.
func TestLogConnFailureOnlyOncePerCause(t *testing.T) {
var lines []string
prev := LogSink
LogSink = func(format string, args ...any) { lines = append(lines, format) }
defer func() { LogSink = prev }()
o := &OmniRig{RigNum: 1}
for i := 0; i < 20; i++ {
o.logConnFailure("requires elevation")
}
if len(lines) != 1 {
t.Errorf("logged %d times, want 1", len(lines))
}
// A DIFFERENT cause must still be reported.
o.logConnFailure("class not registered")
if len(lines) != 2 {
t.Errorf("a new cause was not logged: %d lines", len(lines))
}
// And after reconnecting, the same cause may be reported again.
o.connLogged = ""
o.logConnFailure("requires elevation")
if len(lines) != 3 {
t.Errorf("after a reconnect the cause should log again: %d lines", len(lines))
}
}
+85
View File
@@ -0,0 +1,85 @@
package cat
import "testing"
// Every known rig's OmniRig behaviour, pinned. These rules genuinely contradict
// each other between models, so a change that fixes one rig must be shown not to
// break another — that is what this table is for.
func TestResolveOmniRigVFOs(t *testing.T) {
const (
a14200 = 14200000
b14205 = 14205000
b21000 = 21000000
)
cases := []struct {
name string
rig string
main, fa, fb int64
vfo string
split int64
sticky bool
wantTX, wantRX int64
wantSplit bool
}{
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
// freqA meant the display never followed the operator to B.
{"FTDX101D on SUB VFO", "FTDX101D", b14205, a14200, b14205, "B", pmSplitOff, false, b14205, 0, false},
{"FTDX101D on MAIN VFO", "FTDX101D", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
// Yaesu fallback for a rig file that names NO VFO at all (the stock FTDX10
// one, confirmed 2026-07-26): the generic Freq matching FreqB is then the
// only sign that the operator is on SUB. It applies ONLY when the enum is
// silent — when the enum names a VFO, the enum wins (pair cases below).
{"FTDX10, no enum, generic Freq is B", "FTDX10", b14205, a14200, b14205, "", pmSplitOff, false, b14205, 0, false},
// Same alternating ini: one poll says OFF while the rig IS in split. The
// latched ON flag has to survive the contradicting sample.
{"FTDX101D split, contradicting OFF sample", "FTDX101D", a14200, a14200, b14205, "AB", pmSplitOff, true, b14205, a14200, true},
// The latch must not manufacture a split out of a stale cross-band VFO B.
{"FTDX101D latch, VFOs on different bands", "FTDX101D", a14200, a14200, b21000, "AB", pmSplitOff, true, a14200, 0, false},
// Pair enums: first letter is the VFO being listened on. Reported by the
// whole Yaesu family; ignoring them pinned the display to VFO A (F4NBZ,
// 2026-07-26 — Log4OM follows SUB on the same rig through OmniRig).
{"pair enum BA, simplex → listening on B", "", b14205, a14200, b14205, "BA", pmSplitOff, false, b14205, 0, false},
{"pair enum BB, simplex → listening on B", "", b14205, a14200, b14205, "BB", pmSplitOff, false, b14205, 0, false},
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
// F4?? IC-7610 report, 2026-07-27: the operator's custom rig file declares
// VFO B permanently while they work on the MAIN VFO. OpsLog then wrote to A
// (SetSimplexMode acts on main) and read B — "the frequency never follows
// the knob". Honouring the enum is right in general, which is why the cure
// is an explicit override in the settings, applied before this function.
{"IC-7610 file wrongly says B — enum honoured, hence the override", "IC-7610", 0, 7150000, 14295000, "B", pmSplitOff, false, 14295000, 0, false},
{"same rig with the override forcing A", "IC-7610", 0, 7150000, 14295000, "A", pmSplitOff, false, 7150000, 0, false},
// Non-regression: a rig that does not report the VFO enum keeps the old
// order — freqA, then the generic Freq, then freqB.
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
{"no VFO enum, only generic Freq (IC-9100)", "IC-9100", a14200, 0, 0, "", pmSplitOff, false, a14200, 0, false},
{"IC-7610: generic Freq reports B, enum says A", "IC-7610", b14205, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
// split" for any rig that sets the flag alongside another bit.
{"split, ON flag alone", "", a14200, a14200, b14205, "AB", pmSplitOn, false, b14205, a14200, true},
{"split, ON flag with extra bits set", "", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, false, b14205, a14200, true},
{"listening on B → TX on A", "", b14205, a14200, b14205, "BA", pmSplitOn, false, a14200, b14205, true},
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
// left on another band (the FT-710 / TS-570 false positive).
{"OFF flag, two distinct VFOs", "", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
{"ON flag but VFOs on different bands", "", a14200, a14200, b21000, "AB", pmSplitOn, false, a14200, 0, false},
{"ON flag but both VFOs identical", "", a14200, a14200, a14200, "AB", pmSplitOn, false, a14200, 0, false},
{"ON and OFF both set — ambiguous, treat as no split", "", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, false, a14200, 0, false},
}
for _, c := range cases {
tx, rx, split := resolveOmniRigVFOs(c.rig, c.main, c.fa, c.fb, c.vfo, c.split, c.sticky)
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
}
}
}
+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 := civ.BCDToFreq(d.Data)
if 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
}
}
+83
View File
@@ -0,0 +1,83 @@
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))
}
if got := civ.BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
}
}
}
+467
View File
@@ -0,0 +1,467 @@
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; → MD02; operating mode of the main receiver
// VS; → VS0; which VFO is selected (0=A, 1=B)
// 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
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")
}
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;")
if id, err := y.ask("ID;"); err == nil {
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.
for _, c := range []string{"ST", "FT"} {
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
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.")
}
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. Unlike OmniRig there is no
// interpretation to do: VS answers 0 or 1.
vfo := "A"
if r, err := y.ask("VS;"); err == nil && len(r) >= 3 && r[2] == '1' {
vfo = "B"
}
y.curVFO = vfo
split := false
if y.splitCmd != "" {
if r, err := y.ask(y.splitCmd + ";"); err == nil {
split = yaesuSplitOn(r, y.splitCmd)
}
}
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("MD0;"); 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("MD0%c;", d))
}
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
}
// yaesuSplitOn reads the split reply for whichever command the rig answers.
//
// ST is a split flag: ST1 means split. FT names the TX VFO: FT1 means transmit
// on VFO B, which IS split when the operator is listening on A. The two are not
// the same statement, which is why the command in use is remembered rather than
// both being tried and merged.
func yaesuSplitOn(reply, cmd string) bool {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, cmd) || len(r) < len(cmd)+1 {
return false
}
return r[len(cmd)] == '1'
}
// 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
}
}
+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)
}
}
+810
View File
@@ -0,0 +1,810 @@
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 := y.splitCmd
y.mu.Unlock()
if cmd == "" {
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
}
return y.setAndRefresh(fmt.Sprintf("%s0;", cmd))
}
// 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)
}
return y.setAndRefresh(fmt.Sprintf("MD0%c;", 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(fmt.Sprintf("%s1;", y.splitCmd)); 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
}
+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)
}
}
+132
View File
@@ -0,0 +1,132 @@
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 must not be read the same way — see the
// comment on yaesuSplitOn.
func TestYaesuSplitReply(t *testing.T) {
cases := []struct {
reply, cmd string
want bool
}{
{"ST1;", "ST", true},
{"ST0;", "ST", false},
{"FT1;", "FT", true},
{"FT0;", "FT", false},
{"ST1;", "FT", false}, // reply for the other command — not accepted
{"ST", "ST", false}, // truncated
{"", "ST", false},
}
for _, c := range cases {
if got := yaesuSplitOn(c.reply, c.cmd); got != c.want {
t.Errorf("yaesuSplitOn(%q,%q) = %v, want %v", c.reply, c.cmd, 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)
}
}
}
+376
View File
@@ -0,0 +1,376 @@
// Package catemu emulates a transceiver on a serial port so a device that
// POLLS a radio for its frequency can follow OpsLog instead.
//
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
// the master — it polls the transceiver every few hundred milliseconds and
// changes band only when it gets a valid reply. There is no way to push a
// frequency to it, so following OpsLog means answering its polls.
//
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
// the simplest of the five sets by a wide margin, which is why the SDC utility
// uses it to steer an ACOM with no physical radio attached.
//
// Only the handful of commands an amp actually asks for are implemented:
//
// FA; → FA00014025000; TX frequency, 11 digits, Hz
// FB; → same (sub VFO — amps poll it on some firmware)
// IF; → the 38-character TS-2000 status frame
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
//
// Anything else is ignored rather than answered: a wrong-length reply is worse
// than none, because it desynchronises the amp's parser for the next poll.
package catemu
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
)
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
// a SECOND port, independent of the one used for the amp's own remote/metering
// protocol — both run at the same time on an ACOM.
type Config struct {
ComPort string
Baud int
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
// hear us, since answering polls means speaking only when spoken to.
// 0 = answer polls only.
BroadcastMs int
}
// Status is what the settings panel shows about the link.
type Status struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Port string `json:"port"`
Polls int64 `json:"polls"` // replies sent since start
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
FreqHz int64 `json:"freq_hz"` // what we are currently answering
Error string `json:"error"` // last open/IO failure
}
// Server answers a polling amplifier on one serial port.
type Server struct {
cfg Config
mu sync.Mutex
port serial.Port
status Status
freqHz atomic.Int64
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
stop chan struct{}
done chan struct{}
logf func(string, ...any)
}
// New builds a server. Nothing is opened until Start.
func New(cfg Config, logf func(string, ...any)) *Server {
if cfg.Baud <= 0 {
cfg.Baud = 9600
}
s := &Server{cfg: cfg, logf: logf}
s.mode.Store("")
s.status.Port = cfg.ComPort
return s
}
func (s *Server) log(format string, args ...any) {
if s.logf != nil {
s.logf(format, args...)
}
}
// SetFrequency updates the frequency reported to the amplifier. Called from the
// CAT state callback; safe from any goroutine and never blocks — the serve loop
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
// about the frequency, but a coherent frame avoids odd firmware behaviour.
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
// Start opens the port and serves polls until Stop. It returns immediately;
// a port that is missing or busy is retried every 5 s, because the amplifier is
// often powered on after the software.
func (s *Server) Start() {
s.stop = make(chan struct{})
s.done = make(chan struct{})
go s.run()
}
// Stop closes the port and waits for the loop to end.
func (s *Server) Stop() {
if s.stop == nil {
return
}
close(s.stop)
s.mu.Lock()
if s.port != nil {
_ = s.port.Close()
s.port = nil
}
s.mu.Unlock()
<-s.done
s.stop = nil
}
// GetStatus returns a snapshot for the UI.
func (s *Server) GetStatus() Status {
s.mu.Lock()
defer s.mu.Unlock()
st := s.status
st.Enabled = true
st.FreqHz = s.freqHz.Load()
return st
}
func (s *Server) setErr(msg string) {
s.mu.Lock()
s.status.Error = msg
s.status.Connected = false
s.mu.Unlock()
}
func (s *Server) run() {
defer close(s.done)
for {
select {
case <-s.stop:
return
default:
}
if err := s.open(); err != nil {
s.setErr(err.Error())
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
select {
case <-s.stop:
return
case <-time.After(5 * time.Second):
}
continue
}
s.serve()
}
}
func (s *Server) open() error {
if strings.TrimSpace(s.cfg.ComPort) == "" {
return fmt.Errorf("no COM port configured")
}
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
BaudRate: s.cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
if err != nil {
return err
}
// A short read timeout keeps the loop responsive to Stop while idle: the amp
// may poll only every few hundred ms, and a blocking read would hold the
// port open past shutdown.
_ = p.SetReadTimeout(200 * time.Millisecond)
s.mu.Lock()
s.port = p
s.status.Connected = true
s.status.Error = ""
s.mu.Unlock()
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
return nil
}
// broadcast sends an unsolicited FA frame at the configured interval, for an
// amplifier that listens to the CAT line rather than polling it. It stops when
// the port is closed or Stop is called.
func (s *Server) broadcast(stopServe <-chan struct{}) {
if s.cfg.BroadcastMs <= 0 {
return
}
// Below ~100 ms this is pure noise on the wire; the band only ever changes
// at human speed.
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
if every < 100*time.Millisecond {
every = 100 * time.Millisecond
}
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-stopServe:
return
case <-t.C:
hz := s.freqHz.Load()
if hz <= 0 {
continue // nothing known yet — say nothing rather than "0 Hz"
}
s.mu.Lock()
p := s.port
s.mu.Unlock()
if p == nil {
return
}
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
s.setErr(err.Error())
return
}
}
}
}
// serve reads commands until the port fails or Stop is called.
func (s *Server) serve() {
// The broadcaster shares this port and must die with it, or it would write
// into a closed handle after a reopen.
stopServe := make(chan struct{})
defer close(stopServe)
go s.broadcast(stopServe)
buf := make([]byte, 64)
var acc []byte
for {
select {
case <-s.stop:
return
default:
}
s.mu.Lock()
p := s.port
s.mu.Unlock()
if p == nil {
return
}
n, err := p.Read(buf)
if err != nil {
s.setErr(err.Error())
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
s.mu.Lock()
if s.port != nil {
_ = s.port.Close()
s.port = nil
}
s.mu.Unlock()
return
}
if n == 0 {
continue
}
acc = append(acc, buf[:n]...)
// Commands are ';'-terminated; handle every complete one in the buffer.
for {
i := indexByte(acc, ';')
if i < 0 {
break
}
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
acc = acc[i+1:]
s.handle(p, cmd)
}
// A runaway buffer means we are seeing something that is not this
// protocol (wrong baud, or the amp's other port); drop it rather than
// grow without bound.
if len(acc) > 512 {
acc = acc[:0]
}
}
}
func indexByte(b []byte, c byte) int {
for i := range b {
if b[i] == c {
return i
}
}
return -1
}
// handle answers one command. cmd has no trailing ';'.
func (s *Server) handle(p serial.Port, cmd string) {
hz := s.freqHz.Load()
var reply string
switch {
case cmd == "FA" || cmd == "FB":
reply = fmt.Sprintf("%s%011d;", cmd, hz)
case cmd == "IF":
reply = s.ifFrame(hz)
case cmd == "ID":
reply = "ID019;" // TS-2000
default:
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
// never sets our frequency, and answering the wrong length would break
// its parser for the following poll.
return
}
if _, err := p.Write([]byte(reply)); err != nil {
s.setErr(err.Error())
return
}
s.mu.Lock()
s.status.Polls++
s.status.LastCmd = cmd + ";"
s.status.LastAt = time.Now().Format(time.RFC3339)
s.mu.Unlock()
}
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
func (s *Server) modeDigit() byte {
m, _ := s.mode.Load().(string)
switch {
case strings.HasPrefix(m, "CW"):
return '3'
case strings.HasPrefix(m, "LSB"):
return '1'
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
return '2'
case strings.HasPrefix(m, "FM"):
return '4'
case strings.HasPrefix(m, "AM"):
return '5'
case m == "RTTY", strings.HasPrefix(m, "FSK"):
return '6'
case m == "":
return '2'
default:
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
return '2'
}
}
// ifFrame builds the 38-character TS-2000 IF status frame:
//
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
// FR | scan | split | tone | tone#(2) | shift | ;
//
// Only the frequency and mode carry meaning here; the rest is a valid, inert
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
func (s *Server) ifFrame(hz int64) string {
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
hz, // P1 frequency, Hz
0, // P2 frequency step
0, // P3 RIT/XIT offset, signed 5 digits
0, // P4-P6 RIT off, XIT off, channel-bank
0, // P7 memory channel
0, // P8 0 = RX
s.modeDigit(), // P9 mode
0, // P10 VFO A
0, // P11 scan off
0, // P12 split off
0, // P13 tone off
0, // P14 tone number
0, // P15 shift
)
}
+45
View File
@@ -0,0 +1,45 @@
package catemu
import "testing"
// The reply LENGTHS are the contract: an amplifier parses these frames by
// fixed offsets, so a frame one character short desynchronises its parser for
// every following poll. Pin them.
func TestReplyShapes(t *testing.T) {
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
s.SetFrequency(14025000)
s.SetMode("CW")
if got := s.ifFrame(14025000); len(got) != 38 {
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
}
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
t.Errorf("IF frame frequency field = %q", got[:13])
}
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
t.Errorf("IF frame not terminated by ';': %q", got)
}
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
if got := s.ifFrame(14025000)[29]; got != '3' {
t.Errorf("CW mode digit = %q, want '3'", got)
}
s.SetMode("FT8") // data rides on SSB as far as an amp cares
if got := s.ifFrame(14074000)[29]; got != '2' {
t.Errorf("FT8 mode digit = %q, want '2'", got)
}
}
func TestModeDigit(t *testing.T) {
cases := map[string]byte{
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
}
for mode, want := range cases {
s := New(Config{}, nil)
s.SetMode(mode)
if got := s.modeDigit(); got != want {
t.Errorf("mode %q → %q, want %q", mode, got, want)
}
}
}
+22
View File
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
return "33cm" return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0: case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm" return "23cm"
// Microwave — a 10 GHz spot used to land with no band at all, so it could
// not be filtered, matched against the log, or shown on a band map.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
} }
return "" return ""
} }
+89 -3
View File
@@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"embed" "embed"
"fmt" "fmt"
"os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -143,7 +145,6 @@ func SetDialect(d string) {
// same INSERT/UPDATE works on both backends. // same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") } func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path, // Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations. // enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) { func Open(path string) (*sql.DB, error) {
@@ -161,18 +162,81 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err) return nil, fmt.Errorf("ping sqlite: %w", err)
} }
Dialect = "sqlite" Dialect = "sqlite"
if err := migrate(conn, nil); err != nil { if err := migrate(conn, nil, path, filepath.Base(path)); err != nil {
_ = conn.Close() _ = conn.Close()
return nil, err return nil, err
} }
return conn, nil return conn, nil
} }
// LogSink receives this package's diagnostic lines. The app points it at
// applog.Printf at startup (same pattern as cat / audio / extsvc); left nil in
// tests and in the CLI tools under cmd/, where it is simply discarded.
var LogSink func(format string, args ...any)
// logMigration records a migration that has just been applied, and how long it
// took — the only trace an operator has that a data-rewriting migration ran.
func logMigration(label, name string, start time.Time) {
logf("db[%s]: migration %s applied in %s", label, name, time.Since(start).Round(time.Millisecond))
}
func logf(format string, args ...any) {
if LogSink != nil {
LogSink(format, args...)
}
}
// dataRewriteMarker flags a migration that rewrites existing user rows rather
// than only altering the schema. Put it on its own line in the .sql file, and a
// safety copy of the logbook is taken before it runs.
const dataRewriteMarker = "-- opslog:rewrites-data"
// backupBeforeRewrite takes a one-off copy of the logbook before a migration
// that rewrites user rows.
//
// It exists because the auto-updater gives the operator no say: the new build
// relaunches and migrates before the changelog explaining it is ever shown, so
// "back up first" is advice nobody can act on. The app takes the copy instead.
//
// Skipped when there is nothing to protect — a shared MySQL (no file to copy;
// that server is the admin's to back up), the settings database, and a
// freshly-created empty logbook all have no QSOs at stake.
func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
if dbPath == "" {
return
}
var n int
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n == 0 {
return
}
dest := dbPath + ".pre-" + strings.TrimSuffix(migration, ".sql") + ".bak"
if _, err := os.Stat(dest); err == nil {
return // a copy from an earlier attempt is already there — never overwrite it
}
// VACUUM INTO rather than copying the file: it writes a consistent,
// self-contained snapshot even with WAL pages still outstanding, which a
// plain file copy would silently miss. It cannot run inside a transaction,
// so it happens here, before the migration opens one. The destination is
// spliced (VACUUM INTO takes no bound parameter), with quotes doubled.
start := time.Now()
if _, err := conn.Exec(`VACUUM INTO '` + strings.ReplaceAll(dest, "'", "''") + `'`); err != nil {
// Not fatal: the migration itself is a single atomic transaction, so
// failing to take a belt-and-braces copy is no reason to block the update.
logf("db: could not back up before %s: %v — continuing (the migration is atomic)", migration, err)
return
}
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
}
// migrate applies all embedded *.sql migrations in alphabetical order, // migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system // skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement // (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is. // for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error { // label names the database being migrated, for the log. Without it three
// interleaved migration runs in one log were indistinguishable — an operator
// reported "migrations are very slow" and the lines gave no way to tell one
// database migrated three times from three databases migrated once.
func migrate(conn *sql.DB, translate func(string) string, dbPath, label string) error {
// A non-nil translator means this is the MySQL connection (use the // A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is // per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the // determined by the caller's argument, NOT the global Dialect, so the
@@ -215,16 +279,36 @@ func migrate(conn *sql.DB, translate func(string) string) error {
return fmt.Errorf("read applied migrations: %w", err) return fmt.Errorf("read applied migrations: %w", err)
} }
pending := 0
for _, name := range names {
if !applied[name] {
pending++
}
}
if pending > 0 {
logf("db[%s]: %d migration(s) to apply", label, pending)
}
for _, name := range names { for _, name := range names {
if applied[name] { if applied[name] {
continue // already applied continue // already applied
} }
// Timed, and logged only once it has actually succeeded (below). Most
// migrations are instant DDL, but some rewrite user rows — 0024 upper-cases
// every callsign — and on a large logbook that is exactly what an operator
// wants confirmed afterwards: that it ran, once, and what it cost.
start := time.Now()
content, err := migrationsFS.ReadFile("migrations/" + name) content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil { if err != nil {
return fmt.Errorf("read migration %s: %w", name, err) return fmt.Errorf("read migration %s: %w", name, err)
} }
sqlText := translate(string(content)) sqlText := translate(string(content))
// A migration that rewrites user rows gets a safety copy taken first.
if strings.Contains(string(content), dataRewriteMarker) {
backupBeforeRewrite(conn, dbPath, name)
}
// MySQL implicitly commits each DDL statement, so a wrapping transaction // MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables // gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on // behind, unrecorded, and every restart would re-run and choke on
@@ -238,6 +322,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil { if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err) return fmt.Errorf("record migration %s: %w", name, err)
} }
logMigration(label, name, start)
continue continue
} }
@@ -257,6 +342,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err) return fmt.Errorf("commit migration %s: %w", name, err)
} }
logMigration(label, name, start)
} }
return nil return nil
} }
+161
View File
@@ -0,0 +1,161 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
const mig0024 = "0024_normalise_callsign.sql"
// openWithUnappliedRewrite builds a logbook that already holds QSOs and has not
// yet had the callsign-normalising migration applied — i.e. exactly what an
// existing installation looks like the moment the auto-updater relaunches it.
func openWithUnappliedRewrite(t *testing.T, rows [][2]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "logbook.db")
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if _, err := conn.Exec(
`INSERT INTO qso (callsign, qso_date, band, mode) VALUES (?, ?, '20m', 'SSB')`, r[0], r[1]); err != nil {
t.Fatal(err)
}
}
// Rewind so the migration runs against real data on the next Open.
if _, err := conn.Exec(`DELETE FROM schema_migrations WHERE name = ?`, mig0024); err != nil {
t.Fatal(err)
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
return p
}
func callsigns(t *testing.T, path string) []string {
t.Helper()
conn, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
rows, err := conn.Query(`SELECT callsign FROM qso ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
out = append(out, c)
}
return out
}
// The auto-updater migrates before the operator ever sees the changelog, so the
// app has to take the safety copy itself. The copy must hold the data as it was
// BEFORE the rewrite — a copy of the already-migrated rows would be worthless.
func TestRewriteMigrationBacksUpOriginalData(t *testing.T) {
p := openWithUnappliedRewrite(t, [][2]string{
{"f5lit", "2026-07-01"},
{" Pa3Eyf ", "2026-07-02"},
{"F4BPO", "2026-07-03"},
})
var logged []string
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
defer func() { LogSink = nil }()
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
// The live logbook is normalised.
if got, want := callsigns(t, p), []string{"F5LIT", "PA3EYF", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("logbook callsigns = %v, want %v", got, want)
}
// The backup exists, next to the logbook, named after the migration.
backup := p + ".pre-0024_normalise_callsign.bak"
if _, err := os.Stat(backup); err != nil {
t.Fatalf("no safety copy at %s: %v\nlogged:\n%s", backup, err, strings.Join(logged, "\n"))
}
// …and it holds the ORIGINAL rows, untouched.
if got, want := callsigns(t, backup), []string{"f5lit", " Pa3Eyf ", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("backup callsigns = %v, want the pre-migration values %v", got, want)
}
var sawBackup bool
for _, l := range logged {
if strings.Contains(l, "backed up 3 QSO(s)") {
sawBackup = true
}
}
if !sawBackup {
t.Errorf("the backup was not logged; got:\n%s", strings.Join(logged, "\n"))
}
}
// No QSOs, nothing to protect: the settings database and a freshly created
// logbook must not litter the folder with pointless copies.
func TestRewriteMigrationSkipsBackupWhenNoQSOs(t *testing.T) {
p := openWithUnappliedRewrite(t, nil)
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
if _, err := os.Stat(p + ".pre-0024_normalise_callsign.bak"); err == nil {
t.Error("an empty database should not be backed up")
}
}
// schema_migrations is what makes "runs once" a guarantee rather than a promise:
// a second launch must neither re-run the rewrite nor take a second copy.
func TestRewriteMigrationRunsOnce(t *testing.T) {
p := openWithUnappliedRewrite(t, [][2]string{{"f5lit", "2026-07-01"}})
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
backup := p + ".pre-0024_normalise_callsign.bak"
first, err := os.Stat(backup)
if err != nil {
t.Fatal(err)
}
var logged []string
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
defer func() { LogSink = nil }()
conn2, err := Open(p) // second launch
if err != nil {
t.Fatal(err)
}
conn2.Close()
for _, l := range logged {
if strings.Contains(l, mig0024) {
t.Errorf("migration ran again on the second launch: %s", l)
}
}
second, err := os.Stat(backup)
if err != nil {
t.Fatal(err)
}
if !first.ModTime().Equal(second.ModTime()) {
t.Error("the existing safety copy was overwritten on the second launch")
}
}
@@ -0,0 +1,22 @@
-- opslog:rewrites-data
-- Normalise stored callsigns so lookups can use idx_qso_callsign.
--
-- WorkedBefore matched rows with `upper(trim(callsign)) = ?`. Wrapping the
-- column in functions makes the predicate non-sargable: SQLite cannot use the
-- index and falls back to scanning. Measured on a 190 000-row logbook, the
-- COUNT went from 0.3 ms (SEARCH ... USING INDEX) to 20.8 ms (SCAN), and the
-- entries query — which needs every column, so not even a covering index helps
-- — scans the whole table. That runs on every keystroke of a callsign, and it
-- made the entry strip's history arrive too late to auto-fill the name and
-- locator from the previous QSO. Small logbooks never showed it.
--
-- Add, bulk insert and Update have always upper-cased and trimmed the callsign,
-- so this only rewrites rows left by older versions or foreign imports, and the
-- queries can then compare the column directly.
--
-- SQLite compares case-sensitively, so the WHERE finds exactly the rows that
-- need it. MySQL's default collation is case- and trailing-space-insensitive:
-- there the UPDATE is largely a no-op and equally unnecessary, because `=`
-- already matches those rows through the index.
UPDATE qso SET callsign = upper(trim(callsign))
WHERE callsign <> upper(trim(callsign));
+7 -2
View File
@@ -139,6 +139,7 @@ func translateTextColumns(s string) string {
// app-lifetime ctx with no per-query timeout) hold a connection for the whole // app-lifetime ctx with no per-query timeout) hold a connection for the whole
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained // chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
// the pool and "after a while nothing updated" — grid, chat, live_status froze. // the pool and "after a while nothing updated" — grid, chat, live_status froze.
//
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 → // 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE // 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
// pool, so keep max_connections >= pool*ops + headroom. // pool, so keep max_connections >= pool*ops + headroom.
@@ -200,7 +201,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
err = applyMySQLBaseline(conn) err = applyMySQLBaseline(conn)
} else { } else {
// Existing database: apply only the migrations it's missing. // Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL) err = migrate(conn, mysqlDDL, "", "mysql:"+name)
} }
if err != nil { if err != nil {
_ = conn.Close() _ = conn.Close()
@@ -287,7 +288,11 @@ func applyMySQLBaseline(conn *sql.DB) error {
return fmt.Errorf("open baseline sqlite: %w", err) return fmt.Errorf("open baseline sqlite: %w", err)
} }
defer mem.Close() defer mem.Close()
if err := migrate(mem, nil); err != nil { // In-memory SQLite, used only to derive the final schema for a FRESH MySQL
// database. Labelled so its (fast) migration lines are not mistaken for a
// real database being migrated — in one operator's log this pass sat between
// two slow MySQL runs and looked like a third database.
if err := migrate(mem, nil, "", "baseline:memory"); err != nil {
return fmt.Errorf("build baseline schema: %w", err) return fmt.Errorf("build baseline schema: %w", err)
} }
+195
View File
@@ -0,0 +1,195 @@
package extsvc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
// fixed endpoint: the user gives the base URL of their own instance and we
// append the API path. Both expose the SAME contract — an ADIF record wrapped
// in JSON — which is why one uploader serves both.
//
// POST <base>/index.php/api/qso
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
//
// The station profile id is NOT optional: Cloudlog files the QSO under one of
// the account's station locations, and a wrong id silently lands the contact in
// someone else's log slot.
const cloudlogAPIPath = "index.php/api/qso"
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
// paste the dashboard URL, the API URL, with or without a trailing slash or
// index.php, so normalise all of it rather than make them guess the exact form.
func cloudlogEndpoint(base string) (string, error) {
u := strings.TrimSpace(base)
if u == "" {
return "", fmt.Errorf("cloudlog: URL not set")
}
// A bare host or IP is almost always meant as http:// on a LAN instance;
// requiring the scheme just produces a confusing "unsupported protocol".
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
u = "http://" + u
}
u = strings.TrimRight(u, "/")
// Trim anything the user copied past the site root, so both
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
if strings.HasSuffix(strings.ToLower(u), suffix) {
u = u[:len(u)-len(suffix)]
u = strings.TrimRight(u, "/")
}
}
return u + "/" + cloudlogAPIPath, nil
}
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
type cloudlogRequest struct {
Key string `json:"key"`
StationID string `json:"station_profile_id"`
Type string `json:"type"`
String string `json:"string"`
}
// cloudlogReply covers the documented failure shape
// ({"status":"failed","reason":"missing api key"}); success replies vary
// between versions, so success is judged on the HTTP status plus the ABSENCE
// of a failure marker rather than on a field that may not be there.
type cloudlogReply struct {
Status string `json:"status"`
Reason string `json:"reason"`
Type string `json:"type"`
String string `json:"string"`
}
// cloudlogPost sends one JSON body and returns the trimmed response.
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
buf, err := json.Marshal(body)
if err != nil {
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
if err != nil {
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
}
// Content-Type is what most "wrong JSON" reports come down to: without it
// Cloudlog falls back to form-decoding and never sees the fields.
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
}
// cloudlogReason turns a reply into a human-readable failure reason, or ""
// when the reply looks like a success.
func cloudlogReason(body string, status int) string {
var r cloudlogReply
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
if r.Reason != "" {
return r.Reason
}
return "rejected"
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
// The documented 401 body is {"status":"failed","reason":"missing api key"},
// but a reverse proxy in front of the instance can swallow it.
return "API key refused"
case status == http.StatusNotFound:
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
case status >= 400:
msg := body
if len(msg) > 200 {
msg = msg[:200]
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", status)
}
return msg
}
return ""
}
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
//
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
// retry of an already-accepted QSO is harmless — the upload stays idempotent
// without OpsLog having to track it.
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
endpoint, err := cloudlogEndpoint(cfg.URL)
if err != nil {
return UploadResult{}, err
}
key := strings.TrimSpace(cfg.APIKey)
station := strings.TrimSpace(cfg.StationID)
if key == "" {
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
}
if station == "" {
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
}
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
Key: key, StationID: station, Type: "adif", String: adifRecord,
})
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
// The endpoint is echoed in both outcomes: a self-hosted instance means the
// URL itself is a prime suspect, and a log line naming what was actually
// called settles it without the operator having to guess how we normalised
// what they typed.
if reason := cloudlogReason(body, status); reason != "" {
return UploadResult{OK: false, Message: reason},
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
}
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
}
// TestCloudlog validates URL, API key and station ID with a REAL request.
//
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
// checked by the server before the record is parsed, so a bad key or id fails
// exactly as it would for a real QSO, while nothing is inserted.
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
endpoint, err := cloudlogEndpoint(cfg.URL)
if err != nil {
return "", err
}
key := strings.TrimSpace(cfg.APIKey)
station := strings.TrimSpace(cfg.StationID)
if key == "" {
return "", fmt.Errorf("cloudlog: API key not set")
}
if station == "" {
return "", fmt.Errorf("cloudlog: station ID not set")
}
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
Key: key, StationID: station, Type: "adif", String: "",
})
if err != nil {
return "", err
}
if reason := cloudlogReason(body, status); reason != "" {
return "", fmt.Errorf("cloudlog: %s", reason)
}
return fmt.Sprintf("Connected — station profile %s", station), nil
}
+8
View File
@@ -33,6 +33,9 @@ const (
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL) ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
// only the instance URL differs, so one service handles both.
ServiceCloudlog Service = "cloudlog"
) )
// UploadMode selects when an auto-upload fires after a QSO is saved. // UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -63,6 +66,8 @@ const (
// user can run e.g. Club Log immediate and QRZ delayed). // user can run e.g. Club Log immediate and QRZ delayed).
type ServiceConfig struct { type ServiceConfig struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
Email string `json:"email"` // Club Log account email Email string `json:"email"` // Club Log account email
Username string `json:"username"` // LoTW website login (for confirmation download) Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password Password string `json:"password"` // Club Log account / LoTW website password
@@ -83,6 +88,8 @@ type ServiceConfig struct {
// mode (defaults to immediate). // mode (defaults to immediate).
func (c ServiceConfig) normalised() ServiceConfig { func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey) c.APIKey = strings.TrimSpace(c.APIKey)
c.URL = strings.TrimSpace(c.URL)
c.StationID = strings.TrimSpace(c.StationID)
c.Email = strings.TrimSpace(c.Email) c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign)) c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.Code = strings.TrimSpace(c.Code) c.Code = strings.TrimSpace(c.Code)
@@ -120,6 +127,7 @@ type ExternalServices struct {
LoTW ServiceConfig `json:"lotw"` LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"` HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"` EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"`
} }
// UploadResult is the outcome of a single upload attempt. // UploadResult is the outcome of a single upload attempt.
+26 -5
View File
@@ -65,11 +65,32 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("lotw: http %d", resp.StatusCode) return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
} }
// LoTW returns a plain-text error (not ADIF) on bad login. // Not ADIF. Two very different failures land here, and telling them apart is
// the difference between a fixable message and a wall of markup.
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") { if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
msg := strings.TrimSpace(text) trimmed := strings.TrimSpace(text)
// Keep the whole thing in the log — that is where a real diagnosis happens,
// and a 200-character excerpt of an HTML page tells nobody anything.
snippet := trimmed
if len(snippet) > 2000 {
snippet = snippet[:2000]
}
LogSink("lotw: expected ADIF, got %d bytes of non-ADIF; first 2000: %s", len(text), snippet)
// LoTW answers a REJECTED LOGIN with its ordinary web page rather than an
// error string, so an HTML body here means the credentials were not
// accepted — not that the download is broken.
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.Contains(lower, "logbook of the world</title>") {
return "", fmt.Errorf("LoTW returned its web page instead of a log, which is how it answers a login it did not accept. " +
"Check the username and password in Settings → External services: LoTW wants your lotw.arrl.org WEBSITE login, " +
"not your callsign certificate or your ARRL member number")
}
// Anything else: a plain-text complaint from LoTW, or a maintenance notice.
msg := trimmed
if len(msg) > 200 { if len(msg) > 200 {
msg = msg[:200] msg = msg[:200] + "…"
} }
return "", fmt.Errorf("lotw: unexpected response: %s", msg) return "", fmt.Errorf("lotw: unexpected response: %s", msg)
} }
@@ -220,8 +241,8 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows // AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
// refuses to launch it. Actionable message instead of the raw error. // refuses to launch it. Actionable message instead of the raw error.
return UploadResult{}, fmt.Errorf( return UploadResult{}, fmt.Errorf(
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " + "lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". "+
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " + "Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). "+
"Or run OpsLog itself as administrator.", tqsl) "Or run OpsLog itself as administrator.", tqsl)
} else { } else {
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr) return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
+69 -1
View File
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.LoTW = cfg.LoTW.normalised() cfg.LoTW = cfg.LoTW.normalised()
cfg.HRDLog = cfg.HRDLog.normalised() cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised() cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
m.cfg = cfg m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
// It answers "is the service even switched on for this profile?" — the
// settings are per-profile, so a service configured under another profile
// looks enabled in the UI of the one and silent in the other.
var on []string
for _, s := range []struct {
name string
cfg ServiceConfig
}{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
}
}
if len(on) == 0 {
m.logf("extsvc: auto-upload disabled for every service")
} else {
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
}
} }
// Config returns the current snapshot. // Config returns the current snapshot.
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" { if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
m.route(ServiceEQSL, id, e) m.route(ServiceEQSL, id, e)
} }
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
if c := cfg.Cloudlog; c.AutoUpload {
// Say WHY nothing happens when the toggle is on but a field is missing.
// Without this the whole path was silent — the operator saw no upload and
// no log line, with no way to tell "disabled" from "broken".
var missing []string
if c.URL == "" {
missing = append(missing, "URL")
}
if c.APIKey == "" {
missing = append(missing, "API key")
}
if c.StationID == "" {
missing = append(missing, "station ID")
}
if len(missing) > 0 {
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
strings.Join(missing, ", "), id)
} else {
m.route(ServiceCloudlog, id, c)
}
}
} }
// route sends a logged QSO down the configured timing path: queue it for the // route sends a logged QSO down the configured timing path: queue it for the
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" { if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
out = append(out, ServiceEQSL) out = append(out, ServiceEQSL)
} }
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog)
}
return out return out
} }
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
uploaded++ uploaded++
} }
} }
case ServiceCloudlog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
uploaded++
}
}
} }
} }
return uploaded return uploaded
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
} }
} }
// One line per attempt, BEFORE the request: a failure that never returns
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
// all, and "did it even try?" is the first question when an upload is missing.
m.logf("extsvc: %s uploading QSO %d…", svc, id)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false return false, false
} }
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record) res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
case ServiceCloudlog:
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
// so the ADIF keeps the QSO's own station call (no override).
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
default: default:
return false, false return false, false
} }
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, true // transient (rate-limit / network) → worth a retry return false, true // transient (rate-limit / network) → worth a retry
} }
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID) m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
if m.deps.MarkUploaded != nil { if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID) m.deps.MarkUploaded(svc, id, res.LogID)
} }
+41
View File
@@ -19,3 +19,44 @@ func TestHomeCall(t *testing.T) {
} }
} }
} }
// Mobile and maritime-mobile suffixes: /M is the case that surfaced in the field
// (F4LYI/M resolved only to cty.dat while F4LYI resolved on QRZ), so pin the
// whole suffix family — the home call is what the provider record is filed under.
func TestHomeCallSuffixes(t *testing.T) {
for call, want := range map[string]string{
"F4LYI/M": "F4LYI",
"F4LYI/MM": "F4LYI",
"F4LYI/AM": "F4LYI",
"F4LYI/QRP": "F4LYI",
"F4LYI/A": "F4LYI",
"F4LYI/B": "F4LYI",
} {
if got := homeCall(call); got != want {
t.Errorf("homeCall(%q) = %q, want %q", call, got, want)
}
}
}
// Operational suffixes must resolve to the bare call WITHOUT a provider query on
// the slashed form; entity- and area-changing forms must not be stripped.
func TestStripOpSuffix(t *testing.T) {
strip := map[string]string{
"F4LYI/M": "F4LYI", "F4LYI/MM": "F4LYI", "F4LYI/AM": "F4LYI",
"F4LYI/P": "F4LYI", "F4LYI/QRP": "F4LYI", "F4LYI/p": "F4LYI",
"F4BPO/M/P": "F4BPO",
}
for call, want := range strip {
got, ok := stripOpSuffix(call)
if !ok || got != want {
t.Errorf("stripOpSuffix(%q) = %q,%v — want %q,true", call, got, ok, want)
}
}
// These change the entity or the call area: they are real, separately
// registered forms and must be queried exactly as entered.
for _, call := range []string{"JW/OR1A", "VP8/F4BPO", "F4BPO/8", "DL/F4NIE", "OH2BH", "F4BPO/W6"} {
if got, ok := stripOpSuffix(call); ok {
t.Errorf("stripOpSuffix(%q) stripped to %q — it changes entity/area and must be kept", call, got)
}
}
}
+104 -7
View File
@@ -86,6 +86,22 @@ func (m *Manager) SetProviders(p ...Provider) {
// Lookup returns a Result for the callsign. Falls back through providers // Lookup returns a Result for the callsign. Falls back through providers
// when one returns ErrNotFound or fails. // when one returns ErrNotFound or fails.
// forceKey marks a context as a FORCED (operator-requested) lookup, which
// bypasses the cache on the way in and refreshes it on the way out. Carried on
// the context rather than as a parameter so every existing caller — and the
// Provider interface — stays untouched.
type forceKey struct{}
// WithForce returns a context that makes Lookup skip the cache.
func WithForce(ctx context.Context) context.Context {
return context.WithValue(ctx, forceKey{}, true)
}
func isForced(ctx context.Context) bool {
v, _ := ctx.Value(forceKey{}).(bool)
return v
}
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
call := strings.ToUpper(strings.TrimSpace(callsign)) call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" { if call == "" {
@@ -97,6 +113,13 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
dxcc := m.dxcc dxcc := m.dxcc
m.mu.RUnlock() m.mu.RUnlock()
// A FORCED lookup skips the cache. The cache is right for the automatic
// lookup that fires as you type, but it also freezes a wrong answer for the
// whole TTL: an operator who upgraded their QRZ subscription kept getting the
// thin free-account record for a month, and clearing the cache by hand was
// the only way out. A lookup the operator asked for by clicking is a
// deliberate act and must reach the provider.
if !isForced(ctx) {
if r, ok := m.cache.Get(ctx, call); ok { if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache" r.Source = "cache"
// Re-assert the authoritative DXCC fields (country/zones/continent) // Re-assert the authoritative DXCC fields (country/zones/continent)
@@ -107,8 +130,16 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
normalizeNames(&r) normalizeNames(&r)
return r, nil return r, nil
} }
}
var lastErr error var lastErr error
// An operational suffix (/M, /P, …) is never registered as such: skip the
// futile query on the slashed form and let the home-call pass below do the one
// request that can actually answer.
_, opOnly := stripOpSuffix(call)
if opOnly {
LogSink("lookup: %s carries only an operational suffix — querying the bare call", call)
} else {
for _, p := range providers { for _, p := range providers {
r, err := p.Lookup(ctx, call) r, err := p.Lookup(ctx, call)
if err == nil { if err == nil {
@@ -126,6 +157,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
} }
lastErr = fmt.Errorf("%s: %w", p.Name(), err) lastErr = fmt.Errorf("%s: %w", p.Name(), err)
} }
}
// Portable / slashed call not found under its full form: the operator's // Portable / slashed call not found under its full form: the operator's
// record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look // record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look
@@ -135,6 +167,10 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
for _, p := range providers { for _, p := range providers {
r, err := p.Lookup(ctx, home) r, err := p.Lookup(ctx, home)
if err != nil { if err != nil {
// Logged, because this is where a portable lookup silently dies: the
// error is swallowed to try the next provider, and the operator only
// ever sees the cty.dat fallback with no clue why.
LogSink("lookup: %s → home call %s failed on %s: %v", call, home, p.Name(), err)
continue continue
} }
r.Callsign = call r.Callsign = call
@@ -177,6 +213,46 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
return Result{}, lastErr return Result{}, lastErr
} }
// LogSink receives this package's diagnostic lines (which call was actually
// queried, and why a lookup fell back). Set to applog.Printf by the app.
var LogSink = func(string, ...any) {}
// opSuffixes are OPERATIONAL suffixes: they describe how the operator is working
// — mobile, maritime, aeronautical, portable, low power — not who they are or
// where. No provider has a record filed under "F4LYI/M", so querying that form
// is a round trip that cannot succeed.
//
// It was worse than merely wasted: it spent the lookup's time budget, so the
// home-call retry that followed ran out of time and the entry fell back to
// cty.dat for every /M and /P call, even though the operator was on QRZ. These
// go straight to the bare callsign instead.
//
// Everything else after a slash is NOT this: JW/, VP8/ change the DXCC entity,
// and /8 or /W6 change the call area. Those forms can be registered in their own
// right and must be looked up exactly as entered.
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
// stripOpSuffix returns the bare callsign when call carries nothing but
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
// part isn't callsign-shaped.
func stripOpSuffix(call string) (string, bool) {
if !strings.ContainsRune(call, '/') {
return call, false
}
parts := strings.Split(call, "/")
base := strings.TrimSpace(parts[0])
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
return call, false // "JW/OR1A": the first part is a prefix, not the callsign
}
for _, p := range parts[1:] {
if !opSuffixes[strings.ToUpper(strings.TrimSpace(p))] {
return call, false
}
}
return base, true
}
// homeCall extracts the operator's home callsign from a slashed/portable call // homeCall extracts the operator's home callsign from a slashed/portable call
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't // so its provider record (name/QTH/QSL) can be fetched when the full form isn't
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO → // registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
@@ -266,12 +342,30 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false return false
} }
filled := false filled := false
if country != "" { r.Country = country; filled = true } if country != "" {
if cont != "" { r.Continent = cont; filled = true } r.Country = country
if cqz != 0 { r.CQZ = cqz; filled = true } filled = true
if ituz != 0 { r.ITUZ = ituz; filled = true } }
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true } if cont != "" {
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true } r.Continent = cont
filled = true
}
if cqz != 0 {
r.CQZ = cqz
filled = true
}
if ituz != 0 {
r.ITUZ = ituz
filled = true
}
if lat != 0 && r.Lat == 0 {
r.Lat = lat
filled = true
}
if lon != 0 && r.Lon == 0 {
r.Lon = lon
filled = true
}
// cty.dat is authoritative for the *operating* entity: it strips benign // cty.dat is authoritative for the *operating* entity: it strips benign
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE). // suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
// Use its DXCC# when known — this overrides the provider's home-call // Use its DXCC# when known — this overrides the provider's home-call
@@ -279,7 +373,10 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// France's 227). Only when cty.dat can't map a slashed call do we drop // France's 227). Only when cty.dat can't map a slashed call do we drop
// the provider's number rather than mislabel. // the provider's number rather than mislabel.
if dxccNum != 0 { if dxccNum != 0 {
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true } if r.DXCC != dxccNum {
r.DXCC = dxccNum
filled = true
}
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 { } else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
r.DXCC = 0 r.DXCC = 0
filled = true filled = true
+7
View File
@@ -26,6 +26,13 @@ type ProfileDB struct {
User string `json:"user"` User string `json:"user"`
Password string `json:"password"` Password string `json:"password"`
Database string `json:"database"` Database string `json:"database"`
// Path is a per-profile SQLite logbook FILE, distinct from the shared app
// database (opslog.db, which always holds settings + profiles). Empty =
// the shared database is used as the logbook (the historical default). Set =
// this profile's QSOs live in their own .db, so a visiting operator's contacts
// don't mix into yours and switching it never touches your config. Only used
// when Backend != "mysql".
Path string `json:"path,omitempty"`
} }
// Profile is one operating configuration. A user typically keeps a few: // Profile is one operating configuration. A user typically keeps a few:
+114
View File
@@ -0,0 +1,114 @@
package qso
import (
"context"
"database/sql"
"encoding/json"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// openBulkTestDB builds the minimum of the qso table this needs. It does not go
// through db.Open (that would pull the whole migration set and an import cycle);
// the columns BulkSetExtra touches are extras_json and updated_at.
func openBulkTestDB(t *testing.T) *sql.DB {
t.Helper()
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if _, err := conn.Exec(`CREATE TABLE qso (
id INTEGER PRIMARY KEY AUTOINCREMENT,
callsign TEXT NOT NULL,
extras_json TEXT,
updated_at TEXT
)`); err != nil {
t.Fatal(err)
}
return conn
}
func extras(t *testing.T, conn *sql.DB, id int64) map[string]any {
t.Helper()
var raw sql.NullString
if err := conn.QueryRow(`SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&raw); err != nil {
t.Fatal(err)
}
if !raw.Valid || raw.String == "" {
return map[string]any{}
}
var m map[string]any
if err := json.Unmarshal([]byte(raw.String), &m); err != nil {
t.Fatalf("extras_json is not valid JSON (%q): %v", raw.String, err)
}
return m
}
// OWNER_CALLSIGN has no promoted column, so bulk-editing it means merging a key
// into extras_json. The thing that must not happen is collateral damage: the
// other ADIF extras on the same QSO have to survive.
func TestBulkSetExtraPreservesOtherExtras(t *testing.T) {
conn := openBulkTestDB(t)
r := &Repo{db: conn}
ctx := context.Background()
// Two QSOs with existing extras, one with none at all (NULL column).
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"SILENT_KEY":"Y","ANT_PATH":"S"}')`)
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('PA3EYF', '{"ANT_PATH":"L"}')`)
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F4BPO', NULL)`)
n, err := r.BulkSetExtra(ctx, []int64{1, 2, 3}, "OWNER_CALLSIGN", "TM2Q")
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("updated %d rows, want 3", n)
}
e1 := extras(t, conn, 1)
if e1["OWNER_CALLSIGN"] != "TM2Q" {
t.Errorf("row 1 OWNER_CALLSIGN = %v, want TM2Q", e1["OWNER_CALLSIGN"])
}
if e1["SILENT_KEY"] != "Y" || e1["ANT_PATH"] != "S" {
t.Errorf("row 1 lost its other extras: %v", e1)
}
// A NULL extras_json must become a valid object, not stay null or hold "null".
if e3 := extras(t, conn, 3); e3["OWNER_CALLSIGN"] != "TM2Q" {
t.Errorf("row 3 (extras_json was NULL) = %v, want OWNER_CALLSIGN=TM2Q", e3)
}
}
// Clearing the field must REMOVE the key: a blank extra would otherwise be
// carried into every ADIF export from then on.
func TestBulkSetExtraEmptyRemovesKey(t *testing.T) {
conn := openBulkTestDB(t)
r := &Repo{db: conn}
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"OWNER_CALLSIGN":"TM2Q","ANT_PATH":"S"}')`)
if _, err := r.BulkSetExtra(context.Background(), []int64{1}, "OWNER_CALLSIGN", ""); err != nil {
t.Fatal(err)
}
e := extras(t, conn, 1)
if _, present := e["OWNER_CALLSIGN"]; present {
t.Errorf("OWNER_CALLSIGN should be gone, got %v", e)
}
if e["ANT_PATH"] != "S" {
t.Errorf("clearing one extra removed another: %v", e)
}
}
// The frontend field id must resolve to the ADIF key, and nothing else must slip
// through — this map is the whitelist guarding a spliced JSON path.
func TestBulkExtraKeyWhitelist(t *testing.T) {
if got := BulkExtraKey("owner_callsign"); got != "OWNER_CALLSIGN" {
t.Errorf(`BulkExtraKey("owner_callsign") = %q, want "OWNER_CALLSIGN"`, got)
}
for _, bad := range []string{"", "callsign", "notes", "OWNER_CALLSIGN", "owner_callsign'"} {
if got := BulkExtraKey(bad); got != "" {
t.Errorf("BulkExtraKey(%q) = %q, want empty", bad, got)
}
}
}
+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")
}
}
+107 -7
View File
@@ -751,8 +751,20 @@ var bulkEditableCols = map[string]bool{
"qsl_rcvd": true, "qsl_rcvd": true,
"qsl_via": true, "qsl_via": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so plain TEXT like the rest.
"qsl_sent_date": true,
"qsl_rcvd_date": true,
"lotw_sent_date": true,
"lotw_rcvd_date": true,
"eqsl_sent_date": true,
"eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true,
"qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true,
"hrdlog_qso_upload_date": true,
// My station / operator // My station / operator
"station_callsign": true, "station_callsign": true,
"operator": true, "operator": true,
@@ -775,6 +787,19 @@ var bulkEditableCols = map[string]bool{
"my_arrl_sect": true, "my_arrl_sect": true,
"my_darc_dok": true, "my_darc_dok": true,
"my_vucc_grids": true, "my_vucc_grids": true,
// Contacted station: the LOCATION fields only. State and county are the ones
// an import loses and a whole run shares (a park activation, a county line
// operation), and the dialog has always offered them — but they were missing
// 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. // Contest — the exchange/label fields that are constant across a run.
// (srx/stx serial numbers stay excluded: they are per-QSO.) // (srx/stx serial numbers stay excluded: they are per-QSO.)
"contest_id": true, "contest_id": true,
@@ -828,6 +853,56 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil return n, nil
} }
// bulkEditableExtras whitelists ADIF fields that are bulk-editable but live in
// extras_json rather than in a promoted column. Key = the frontend's field id,
// value = the uppercase ADIF key inside the JSON object.
//
// OWNER_CALLSIGN is the case that prompted this: it was already filterable (see
// filterableExtras) but could not be bulk-edited, because BulkSetField writes a
// column and there is no owner_callsign column.
var bulkEditableExtras = map[string]string{
"owner_callsign": "OWNER_CALLSIGN",
}
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
// leaving the other extras untouched. An empty value REMOVES the key rather than
// storing a blank — an empty extra would otherwise be carried into every export.
//
// json_set / json_remove exist under those names in both SQLite and MySQL and
// take the same '$.KEY' path syntax, so one statement serves both backends.
func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value string) (int64, error) {
if adifKey == "" {
return 0, fmt.Errorf("empty extras key")
}
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
if value == "" {
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
} else {
args = append(args, value)
}
args = append(args, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
}
n, _ := res.RowsAffected()
return n, nil
}
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept // BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
// separate from BulkSetField because frequency is numeric and must keep the band // separate from BulkSetField because frequency is numeric and must keep the band
// consistent — the main use is fixing a batch that was logged on a stale/default // consistent — the main use is fixing a batch that was logged on a stale/default
@@ -1123,7 +1198,15 @@ var filterableColumns = map[string]bool{
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true, "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, "qrzcom_qso_upload_status": true, "qrzcom_qso_download_status": true,
"clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
// Confirmation DATES. ADIF YYYYMMDD strings, so a plain string comparison is
// also chronological — "before 20240101" works with no date parsing.
"qsl_sent_date": true, "qsl_rcvd_date": true,
"lotw_sent_date": true, "lotw_rcvd_date": true,
"eqsl_sent_date": true, "eqsl_rcvd_date": true,
"qrzcom_qso_upload_date": true, "qrzcom_qso_download_date": true,
"clublog_qso_upload_date": true, "hrdlog_qso_upload_date": true,
"contest_id": true, "srx": true, "stx": true, "contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true, "prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true, "station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
@@ -1478,14 +1561,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// ---- Per-callsign stats ---- // ---- Per-callsign stats ----
if err := r.db.QueryRowContext(ctx, if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil { `SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
return wb, fmt.Errorf("count worked: %w", err) return wb, fmt.Errorf("count worked: %w", err)
} }
if wb.Count > 0 { if wb.Count > 0 {
// Pull the full QSO records (same columns as the Recent QSOs list) so // Pull the full QSO records (same columns as the Recent QSOs list) so
// the Worked-before grid can offer the same rich column picker. // the Worked-before grid can offer the same rich column picker.
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+` rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
FROM qso WHERE upper(trim(callsign)) = ? FROM qso WHERE callsign = ?
ORDER BY qso_date DESC, id DESC ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries) LIMIT ?`, wb.Callsign, maxWorkedEntries)
if err != nil { if err != nil {
@@ -1520,7 +1603,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
if wb.Count > maxWorkedEntries { if wb.Count > maxWorkedEntries {
var firstStr sql.NullString var firstStr sql.NullString
_ = r.db.QueryRowContext(ctx, _ = r.db.QueryRowContext(ctx,
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr) `SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
if firstStr.Valid { if firstStr.Valid {
wb.First = parseTimeLoose(firstStr.String) wb.First = parseTimeLoose(firstStr.String)
} }
@@ -1545,7 +1628,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
var d sql.NullInt64 var d sql.NullInt64
_ = r.db.QueryRowContext(ctx, ` _ = r.db.QueryRowContext(ctx, `
SELECT dxcc FROM qso SELECT dxcc FROM qso
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL WHERE callsign = ? AND dxcc IS NOT NULL
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d) ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
if d.Valid { if d.Valid {
dxcc = int(d.Int64) dxcc = int(d.Int64)
@@ -1614,8 +1697,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// WorkedBefore call, blanking the matrix in the UI. // WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, ` statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode, SELECT band, mode,
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END), MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN upper(trim(callsign)) = ? MAX(CASE WHEN callsign = ?
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y') AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
THEN 1 ELSE 0 END), THEN 1 ELSE 0 END),
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y' MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
@@ -2750,3 +2833,20 @@ func parseTimeLoose(s string) time.Time {
} }
return time.Time{} return time.Time{}
} }
// IsBulkEditable reports whether a column may be written by a bulk edit. Used by
// the app layer's own test to prove that every field the UI offers is actually
// accepted here — the two lists live in different packages and drifted apart
// once already: the new confirmation columns were added to the QSL Manager's
// filter whitelist instead of this one, so the UI offered fields that the
// repository then refused with "field … is not bulk-editable".
func IsBulkEditable(column string) bool { return bulkEditableCols[column] }
// IsFilterable reports whether a column may appear in a query filter. Same
// reason as IsBulkEditable.
func IsFilterable(column string) bool { return filterableColumns[column] }
// IsBulkEditableExtra / IsFilterableExtra cover the ADIF fields that have no
// column of their own and live in extras_json.
func IsBulkEditableExtra(field string) bool { _, ok := bulkEditableExtras[field]; return ok }
func IsFilterableExtra(field string) bool { _, ok := filterableExtras[field]; return ok }
+116 -3
View File
@@ -171,6 +171,27 @@ type Stats struct {
ByDay30 []Bucket `json:"by_day30"` // the last 30 days ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
// Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket
// size. These are what the activity chart's day/week/month/year selector
// drives: the rolling windows above ignored the period filter, so choosing a
// year in the panel left the chart showing the last 7 days regardless — two
// controls contradicting each other on screen.
//
// A series is EMPTY when it would exceed maxActivityBuckets (per-day over a
// seventeen-year log is ~6000 bars: unreadable, and pointless to transport).
// The UI treats an empty series as "too fine for this period" and steps up.
ByDayWin []Bucket `json:"by_day_win"`
ByWeekWin []Bucket `json:"by_week_win"`
ByMonthWin []Bucket `json:"by_month_win"`
ByYearWin []Bucket `json:"by_year_win"`
// Rhythm: WHEN the operator is on the air, over the selected period. Distinct
// question from the above ("how much, lately"), so it gets its own card. The
// hour histogram used to cover a single day, which is too little to read
// anything from.
ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC
ByWeekday []Bucket `json:"by_weekday"` // Mon..Sun
// ── Period / contest metrics ── // ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says // Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
// nothing, but across a contest weekend it is the score. The window is the // nothing, but across a contest weekend it is the score. The window is the
@@ -492,11 +513,95 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
ref = time.Now() ref = time.Now()
} }
s.recentSeries(times, ref) s.recentSeries(times, ref)
s.windowSeries(times, first, last, from, to)
s.ensureNonNil() s.ensureNonNil()
return s, nil return s, nil
} }
// maxActivityBuckets caps a chronological series. Beyond this the chart is a
// grey smear and the payload grows for nothing; the series is dropped and the UI
// steps up to a coarser bucket.
const maxActivityBuckets = 750
// windowSeries builds the chronological activity series ACROSS THE SELECTED
// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm.
//
// The period is [from,to] when the caller filtered, else the span of the log —
// the same window the period metrics use, so every card on the panel answers for
// the same slice of time.
func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) {
start, end := from, to
if start.IsZero() {
start = first
}
if end.IsZero() {
end = last
}
if start.IsZero() || end.IsZero() || end.Before(start) {
return
}
start, end = start.UTC(), end.UTC()
day := map[string]int{}
week := map[string]int{}
month := map[string]int{}
year := map[string]int{}
hour := make([]int, 24)
weekday := make([]int, 7)
for _, e := range times {
t := e.t.UTC()
if t.Before(start) || t.After(end) {
continue
}
day[t.Format("2006-01-02")]++
// ISO week, so a week is Monday→Sunday everywhere and the key sorts.
wy, wn := t.ISOWeek()
week[fmt.Sprintf("%04d-W%02d", wy, wn)]++
month[t.Format("2006-01")]++
year[t.Format("2006")]++
hour[t.Hour()]++
weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday
}
// Every bucket in the range is emitted, including the empty ones: a gap in
// the log is real information and must show as zero, not be closed up.
d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets {
for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) {
s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]})
}
}
// Weeks: start on the Monday of the first week and step by 7 days.
w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7))
if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets {
for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) {
wy, wn := w.ISOWeek()
key := fmt.Sprintf("%04d-W%02d", wy, wn)
s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]})
}
}
m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets {
for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) {
s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]})
}
}
for y := start.Year(); y <= end.Year(); y++ {
k := fmt.Sprintf("%04d", y)
s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]})
}
for h := 0; h < 24; h++ {
s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
}
for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]})
}
}
// recentSeries builds the rolling chronological activity windows for the chart's // recentSeries builds the rolling chronological activity windows for the chart's
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when // day/week/month/year selector, in UTC and anchored to `ref` (the period end when
// filtered, else now). Real dates in order — today and the days before it — so the // filtered, else now). Real dates in order — today and the days before it — so the
@@ -569,6 +674,14 @@ func (s *Stats) ensureNonNil() {
if s.ByMonth == nil { if s.ByMonth == nil {
s.ByMonth = []Bucket{} s.ByMonth = []Bucket{}
} }
// A window series stays EMPTY on purpose when the bucket would be too fine
// for the period — but it must be [] and not null, so the UI can tell
// "too fine" from "not computed".
for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} {
if *p == nil {
*p = []Bucket{}
}
}
if s.Rate == nil { if s.Rate == nil {
s.Rate = []Bucket{} s.Rate = []Bucket{}
} }
@@ -588,9 +701,10 @@ func (s *Stats) ensureNonNil() {
// log itself. // log itself.
// //
// Two rates are reported on purpose, because a single one always flatters: // Two rates are reported on purpose, because a single one always flatters:
// AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number. // - AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you // - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio. // how fast you go when you ARE at the radio.
//
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score. // Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) { func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 { if len(times) == 0 {
@@ -790,7 +904,6 @@ func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
return out return out
} }
// fillMonths emits EVERY month between the first and last QSO — zeros included — // fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have // so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish. // data". A quiet decade must read as a decade at zero, not vanish.
+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)
}
}
}
+270
View File
@@ -0,0 +1,270 @@
// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
//
// - Partial (SCP): every master call that CONTAINS what you've typed, so a
// mistyped or half-copied call surfaces the real ones.
// - N+1: master calls exactly one edit away (one char substituted, added or
// removed) from the full call you typed — the classic "did I bust it?" check.
//
// The list is downloaded once and cached on disk so it survives restarts.
package scp
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// masterURL is the community Super Check Partial master file: one callsign per
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
const cacheFile = "MASTER.SCP"
// Result is the two suggestion lists for a typed fragment.
type Result struct {
Partial []string `json:"partial"` // master calls containing the fragment
NPlus1 []string `json:"nplus1"` // master calls one edit away from the full call
}
// Manager holds the parsed call list + cache location.
type Manager struct {
mu sync.RWMutex
calls []string // UPPER, de-duplicated, sorted
updated time.Time // when the cache was last refreshed
dir string
client *http.Client
}
// NewManager loads any on-disk cache and returns a ready manager.
func NewManager(dataDir string) *Manager {
m := &Manager{
dir: dataDir,
client: &http.Client{Timeout: 60 * time.Second},
}
m.loadCache()
return m
}
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) loadCache() {
data, err := os.ReadFile(m.path())
if err != nil {
return
}
m.parse(data)
if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock()
m.updated = fi.ModTime()
m.mu.Unlock()
}
}
// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
// list. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return 0, fmt.Errorf("scp: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return 0, fmt.Errorf("scp: read: %w", err)
}
n := m.parse(body)
if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
}
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
m.mu.Lock()
m.updated = time.Now()
m.mu.Unlock()
return n, nil
}
// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(data []byte) int {
seen := make(map[string]struct{}, 1<<17)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the master file is one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
}
if !plausibleCall(line) {
continue
}
seen[line] = struct{}{}
}
if len(seen) == 0 {
return 0
}
calls := make([]string, 0, len(seen))
for c := range seen {
calls = append(calls, c)
}
sort.Strings(calls)
m.mu.Lock()
m.calls = calls
m.mu.Unlock()
return len(calls)
}
// plausibleCall keeps a token that looks like a callsign: length 312, at least
// one digit and one letter, only AZ/09//.
func plausibleCall(s string) bool {
if len(s) < 3 || len(s) > 12 {
return false
}
hasDigit, hasAlpha := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
hasDigit = true
case c >= 'A' && c <= 'Z':
hasAlpha = true
case c == '/':
default:
return false
}
}
return hasDigit && hasAlpha
}
// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
// (a plausible whole call) and is skipped otherwise.
func (m *Manager) Lookup(fragment string, limit int) Result {
q := strings.ToUpper(strings.TrimSpace(fragment))
if len(q) < 2 {
return Result{}
}
if limit <= 0 {
limit = 50
}
m.mu.RLock()
calls := m.calls
m.mu.RUnlock()
wantN1 := len(q) >= 3
type pm struct {
call string
prefix bool
}
var partial []pm
var nplus1 []string
for _, c := range calls {
if c == q {
partial = append(partial, pm{c, true}) // exact = strongest match
continue
}
if idx := strings.Index(c, q); idx >= 0 {
partial = append(partial, pm{c, idx == 0})
}
if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
nplus1 = append(nplus1, c)
}
}
// Prefix matches first, then the rest (both already alphabetical since `calls`
// is sorted and we scanned in order).
sort.SliceStable(partial, func(i, j int) bool {
if partial[i].prefix != partial[j].prefix {
return partial[i].prefix
}
return false
})
out := Result{Partial: []string{}, NPlus1: []string{}}
for _, p := range partial {
if len(out.Partial) >= limit {
break
}
out.Partial = append(out.Partial, p.call)
}
if len(nplus1) > limit {
nplus1 = nplus1[:limit]
}
out.NPlus1 = append(out.NPlus1, nplus1...)
return out
}
// editDistanceOne reports whether a and b are exactly one edit apart (one
// substitution, insertion or deletion) — never equal (distance 0 returns false).
func editDistanceOne(a, b string) bool {
la, lb := len(a), len(b)
if la == lb {
diff := 0
for i := 0; i < la; i++ {
if a[i] != b[i] {
diff++
if diff > 1 {
return false
}
}
}
return diff == 1
}
// Ensure a is the shorter one; lengths must differ by exactly 1.
if la > lb {
a, b = b, a
la, lb = lb, la
}
if lb-la != 1 {
return false
}
// b is a with one extra char: walk both, allowing a single skip in b.
i, j, skipped := 0, 0, false
for i < la && j < lb {
if a[i] == b[j] {
i++
j++
continue
}
if skipped {
return false
}
skipped = true
j++ // skip the extra char in the longer string
}
return true
}
// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.calls)
}
// Updated returns when the list was last refreshed (zero if never).
func (m *Manager) Updated() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.updated
}
+109 -8
View File
@@ -25,6 +25,7 @@ package steppir
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -35,6 +36,10 @@ import (
"go.bug.st/serial" "go.bug.st/serial"
) )
// errBadFrame marks a reply that isn't a well-formed status frame. It means
// "ignore this poll", not "the link is down".
var errBadFrame = errors.New("steppir: malformed status frame")
// Direction values, matching the app-wide convention (also used by Ultrabeam): // Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional. // 0 normal, 1 reverse (180°), 2 bidirectional.
const ( const (
@@ -50,6 +55,15 @@ const (
wireBi = 0x80 wireBi = 0x80
) )
// pendingDirTTL is how long a commanded direction is trusted over the
// controller's own report. The elements physically re-tune to swap director and
// reflector, and the SDA only reports the new pattern once it starts that move,
// so a few seconds is not enough — 4 s (the original value) had the UI snapping
// back to "normal" while the antenna was on its way to 180°. Long enough to
// cover a real move, short enough that a command the controller never received
// self-corrects instead of lying forever.
const pendingDirTTL = 45 * time.Second
// Transport says how to reach the controller. // Transport says how to reach the controller.
type Transport struct { type Transport struct {
Mode string // "tcp" | "serial" Mode string // "tcp" | "serial"
@@ -94,6 +108,11 @@ type Client struct {
// A just-commanded direction is held until the controller's poll reports it — // A just-commanded direction is held until the controller's poll reports it —
// the motors take a second or two, and a stale poll would otherwise snap the // the motors take a second or two, and a stale poll would otherwise snap the
// UI back. Same trick as the Ultrabeam client. // UI back. Same trick as the Ultrabeam client.
//
// The hold is deliberately long (pendingDirTTL). It is not just a UI nicety:
// the follow loop re-tunes with the direction it reads back from this status,
// so a single stale poll reading "normal" would make OpsLog command the
// antenna out of 180° all by itself.
pendingDir int pendingDir int
pendingDirAt time.Time pendingDirAt time.Time
pendingDirSet bool pendingDirSet bool
@@ -189,6 +208,12 @@ func (c *Client) pollLoop() {
c.connMu.Unlock() c.connMu.Unlock()
st, err := c.queryStatus() st, err := c.queryStatus()
if errors.Is(err, errBadFrame) {
// Framing glitch, not a dead link: skip this tick and keep the
// previous status. Dropping the connection here would blink the
// UI to "disconnected" over one garbled reply.
continue
}
if err != nil { if err != nil {
log.Printf("steppir: status query failed, reconnecting: %v", err) log.Printf("steppir: status query failed, reconnecting: %v", err)
c.closeConn() c.closeConn()
@@ -197,19 +222,32 @@ func (c *Client) pollLoop() {
} }
st.Connected = true st.Connected = true
c.statusMu.Lock() c.statusMu.Lock()
if c.pendingDirSet { c.applyPendingDir(st)
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
st.Direction = c.pendingDir
}
}
c.lastStatus = st c.lastStatus = st
c.statusMu.Unlock() c.statusMu.Unlock()
} }
} }
} }
// applyPendingDir replaces a freshly polled direction with the one the operator
// last commanded, until the controller confirms it (or the hold expires). The
// caller holds statusMu.
func (c *Client) applyPendingDir(st *Status) {
if !c.pendingDirSet {
return
}
switch {
case st.Direction == c.pendingDir:
c.pendingDirSet = false // confirmed — trust the controller's reports again
case time.Since(c.pendingDirAt) > pendingDirTTL:
c.pendingDirSet = false
log.Printf("steppir: controller never confirmed direction %d (still reports %d) — dropping the hold",
c.pendingDir, st.Direction)
default:
st.Direction = c.pendingDir
}
}
func (c *Client) setDisconnected() { func (c *Client) setDisconnected() {
c.statusMu.Lock() c.statusMu.Lock()
c.lastStatus = &Status{Connected: false} c.lastStatus = &Status{Connected: false}
@@ -232,6 +270,56 @@ func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
} }
} }
// setReadTimeout bounds a single read on either transport, so a drain can tell
// "nothing more queued" from "still arriving" without blocking.
func setReadTimeout(conn io.ReadWriteCloser, d time.Duration) {
switch t := conn.(type) {
case net.Conn:
_ = t.SetReadDeadline(time.Now().Add(d))
case serial.Port:
_ = t.SetReadTimeout(d)
}
}
// restoreTimeouts puts the normal exchange timeouts back after a drain shortened
// them.
func restoreTimeouts(conn io.ReadWriteCloser) {
setDeadline(conn, 3*time.Second) // TCP: read + write
setReadTimeout(conn, 2*time.Second)
}
// drain throws away everything already sitting in the input buffer and returns
// how many bytes it discarded.
//
// This is the fix for the antenna's state appearing tens of seconds out of date.
// The SDA controller does not only answer "?A" — it also pushes status frames on
// its own (front-panel changes, autotrack moves, each command it processes). We
// consume exactly one frame per poll, so every unsolicited frame adds one to a
// backlog that only ever grows: reading 11 bytes then returns a frame from
// minutes ago. The field log showed it plainly — two consecutive polls 4 s apart
// reporting 28280 kHz then 14200 kHz, a frequency last used hours earlier, and a
// 180° command not showing up in the status for ~40 s (long after the UI had
// given up waiting and snapped the button back to "normal"). Emptying the buffer
// immediately before each query means the frame we then read is the answer to
// THIS query.
func drain(conn io.ReadWriteCloser) int {
buf := make([]byte, 512)
total := 0
// Bounded so a controller that streams continuously can't hold the poll
// goroutine here forever. 32 × 512 B is ~1500 frames — far more backlog than
// any real link builds up, and it only costs one 30 ms timeout when the
// buffer is already empty (reads return immediately while data is queued).
for i := 0; i < 32; i++ {
setReadTimeout(conn, 30*time.Millisecond)
n, err := conn.Read(buf)
total += n
if err != nil || n == 0 { // timeout / nothing left
break
}
}
return total
}
func (c *Client) queryStatus() (*Status, error) { func (c *Client) queryStatus() (*Status, error) {
c.connMu.Lock() c.connMu.Lock()
conn := c.conn conn := c.conn
@@ -241,7 +329,12 @@ func (c *Client) queryStatus() (*Status, error) {
} }
c.ioMu.Lock() c.ioMu.Lock()
defer c.ioMu.Unlock() defer c.ioMu.Unlock()
setDeadline(conn, 3*time.Second) // Discard any frame the controller pushed on its own since the last poll, so
// what we read below is this query's answer and not a stale backlog entry.
if n := drain(conn); n > 0 {
log.Printf("steppir: discarded %d stale byte(s) queued by the controller before polling", n)
}
restoreTimeouts(conn)
if _, err := conn.Write([]byte("?A\r")); err != nil { if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err) return nil, fmt.Errorf("write status cmd: %w", err)
} }
@@ -249,6 +342,14 @@ func (c *Client) queryStatus() (*Status, error) {
if _, err := io.ReadFull(conn, buf); err != nil { if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err) return nil, fmt.Errorf("read status: %w", err)
} }
// Reject anything that isn't a framed reply rather than decoding garbage into
// a frequency and a direction the app would then act on.
if buf[0] != '@' || buf[1] != 'A' || buf[10] != 0x0D {
log.Printf("steppir: ignoring malformed status frame % X", buf)
drain(conn) // resync: drop the rest of whatever we landed mid-way through
restoreTimeouts(conn)
return nil, errBadFrame
}
st, err := parseStatus(buf) st, err := parseStatus(buf)
// Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is // Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is
// what decides st.MotorsMoving, and that in turn drives the app's "block TX // what decides st.MotorsMoving, and that in turn drives the app's "block TX
+121
View File
@@ -1,8 +1,12 @@
package steppir package steppir
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"errors"
"sync"
"testing" "testing"
"time"
) )
// The exact bytes are the correctness checksum. If buildSet ever drifts from the // The exact bytes are the correctness checksum. If buildSet ever drifts from the
@@ -104,3 +108,120 @@ func TestParseStatus(t *testing.T) {
t.Error("active-motors 0xFF (command received) must not read as moving") t.Error("active-motors 0xFF (command received) must not read as moving")
} }
} }
// fakeConn stands in for the controller link. rx holds bytes the "controller"
// has already sent (what the client will read), tx collects what the client
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
//
// Reads never block: an empty rx returns (0, nil), which is exactly how
// go.bug.st/serial reports a read timeout, so drain() sees the same
// nothing-left signal it gets from real hardware.
type fakeConn struct {
mu sync.Mutex
rx bytes.Buffer
tx bytes.Buffer
reply []byte
}
func (f *fakeConn) Read(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.rx.Len() == 0 {
return 0, nil
}
return f.rx.Read(p)
}
func (f *fakeConn) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.tx.Write(p)
if bytes.Contains(p, []byte("?A")) {
f.rx.Write(f.reply)
}
return len(p), nil
}
func (f *fakeConn) Close() error { return nil }
var (
// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
// F4BPO's friend's log long after the rig had left the band.
frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
// 14.250 MHz, 180° — what the controller actually reports right now.
frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
)
// The controller pushes status frames unsolicited, so they pile up between polls.
// Reading one frame per poll then returns state from minutes ago — which is how a
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
fc := &fakeConn{reply: frame20m180}
fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
fc.rx.Write(frame6mNormal)
c := &Client{conn: fc}
st, err := c.queryStatus()
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14250 {
t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
}
// A reply we land on mid-frame must be rejected, not decoded into a bogus
// frequency and direction the follow loop would then act on — and it must not
// look like a dead link either (errBadFrame keeps the connection).
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
c := &Client{conn: &fakeConn{reply: shifted}}
if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
t.Fatalf("err = %v, want errBadFrame", err)
}
}
// The direction the operator just commanded is shown until the controller
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
// to Normal while the elements were still swapping over to 180°.
func TestApplyPendingDirHold(t *testing.T) {
c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}
// Controller still reports the old pattern: keep showing what was commanded.
st := &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
}
if !c.pendingDirSet {
t.Fatal("hold released before the controller confirmed")
}
// Still holding well past the old 4 s window — a SteppIR takes longer than
// that to report a pattern change.
c.pendingDirAt = time.Now().Add(-10 * time.Second)
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
}
// Controller confirms: release the hold and trust its reports again.
st = &Status{Direction: Dir180}
c.applyPendingDir(st)
if c.pendingDirSet {
t.Fatal("hold should be released once the controller reports the commanded direction")
}
// A command the controller never acted on must not lie forever.
c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != DirNormal || c.pendingDirSet {
t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
}
}
+100
View File
@@ -0,0 +1,100 @@
package tunergenius
import (
"bufio"
"net"
"os"
"strings"
"testing"
"time"
)
// captureLog redirects the stream applog.Printf always writes to, and returns a
// stop function yielding what was logged. Reading only after stop keeps the
// collector goroutine and the test off the same slice.
func captureLog() func() []string {
orig := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
var lines []string
done := make(chan struct{})
go func() {
sc := bufio.NewScanner(r)
for sc.Scan() {
lines = append(lines, sc.Text())
}
close(done)
}()
return func() []string {
os.Stderr = orig
w.Close()
<-done
r.Close()
return lines
}
}
// A tuner that answers, then vanishes mid-session. A drop used to leave no trace
// at all, so "did the tuner disconnect, or is the meter just reading a gap
// between syllables?" was unanswerable from the log. It must now log — and log
// ONCE, not on every 400 ms retry.
func TestLinkDownLoggedOnceNotPerRetry(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := ln.Addr().(*net.TCPAddr).Port
die := make(chan struct{})
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
conn.Write([]byte("V1.2.11\n"))
buf := make([]byte, 256)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
id := strings.TrimPrefix(strings.SplitN(string(buf[:n]), "|", 2)[0], "C")
conn.Write([]byte("R" + id + "|0|status state=1 fwd=60.7 swr=-25 active=1\n"))
select {
case <-die:
conn.Close()
ln.Close()
return
default:
}
}
}()
stop := captureLog()
c := New("127.0.0.1", port, "")
if err := c.Start(); err != nil {
stop()
t.Fatal(err)
}
time.Sleep(1200 * time.Millisecond)
connected := c.GetStatus().Connected
close(die)
time.Sleep(2 * time.Second) // ~5 poll cycles with the tuner gone
c.Stop()
lines := stop()
if !connected {
t.Fatal("the client never reached the fake tuner — the rest of the test is meaningless")
}
down := 0
for _, l := range lines {
if strings.Contains(l, "link DOWN") {
down++
}
}
t.Logf("logged:\n%s", strings.Join(lines, "\n"))
if down != 1 {
t.Errorf("got %d 'link DOWN' lines, want exactly 1 — the poll retries many times in a 3 s outage and must not repeat itself", down)
}
}
+518
View File
@@ -0,0 +1,518 @@
// Package tunergenius drives a 4O3A Tuner Genius XL over its TCP text API
// (fixed port 9010 — the same port the device also uses for UDP discovery
// broadcasts). It's the same "Genius Series" line protocol as the PowerGenius
// XL / Antenna Genius: on connect the device sends a banner ("V1.1.8" or
// "V1.1.8 AUTH" when reached from outside the LAN); commands are
// "C<seq>|<command>\n" and replies are "R<seq>|<code>|<message>" (code 0 = OK).
// The status reply is pushed back as "S<seq>|status <k=v …>", and unsolicited
// "M|<message>" info/warning lines can arrive at any time.
//
// Protocol reference: 4O3A "TUNER GENIUS XL — PROTOCOL DESCRIPTION".
package tunergenius
import (
"bufio"
"fmt"
"math"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/applog"
)
const (
// DefaultPort is fixed on the device (both the TCP control channel and the
// UDP discovery broadcast use 9010).
DefaultPort = 9010
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
// Poll fast so the meters track TX like the amplifier does (the amp's numbers
// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
// a slow interval made its SWR/power lag noticeably behind).
pollEvery = 400 * time.Millisecond
)
// Channel is the live state of one of the tuner's two RF channels (A / B). The
// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own
// source, band, frequency and antenna — mirroring the two rows the native 4O3A
// app shows.
type Channel struct {
PTT bool `json:"ptt"` // this channel is keyed
Band int `json:"band"` // band as reported by the device (0 = unknown)
Mode int `json:"mode"` // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD
ModeStr string `json:"mode_str"` // human-readable mode/source
Flex string `json:"flex"` // bound Flex radio nickname (FLEX mode)
FreqMHz float64 `json:"freq_mhz"` // current frequency
Bypass bool `json:"bypass"` // this channel bypassed
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG; 0 = n/a)
}
// Status is the snapshot the UI renders. Power/SWR come from the device's
// "status" reply; the booleans mirror the tuner's operating state.
type Status struct {
Connected bool `json:"connected"`
Host string `json:"host,omitempty"`
LastError string `json:"last_error,omitempty"`
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
Operate bool `json:"operate"` // state == OPERATE (1) vs STANDBY (0)
Bypass bool `json:"bypass"` // device global bypass engaged
Tuning bool `json:"tuning"` // autotune in progress
Active int `json:"active"` // active channel (1 = A, 2 = B)
ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant
A Channel `json:"a"` // channel A
B Channel `json:"b"` // channel B
RelayC1 int `json:"relay_c1"` // tuner network position (0255)
RelayL int `json:"relay_l"`
RelayC2 int `json:"relay_c2"`
// FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked
// widget that shows a single readout.
FreqMHz float64 `json:"freq_mhz"`
Antenna int `json:"antenna"`
Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
}
// modeName maps the device's numeric mode/source to a label.
func modeName(m int) string {
switch m {
case 0:
return "RF Sense"
case 1:
return "Flex"
case 2:
return "CAT"
case 3:
return "P2B"
case 4:
return "BCD"
default:
return ""
}
}
type Client struct {
host string
port int
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
mu sync.Mutex // serialises command send/recv on the connection
conn net.Conn
reader *bufio.Reader
statusMu sync.RWMutex
status Status
lastRaw string // last raw status payload — logged on change to map fields against real hardware
cmdID atomic.Int64
stop chan struct{}
running bool
}
func New(host string, port int, password string) *Client {
if port <= 0 || port > 65535 {
port = DefaultPort
}
return &Client{
host: host,
port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}),
status: Status{Host: host},
}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setStatus(fn func(*Status)) {
c.statusMu.Lock()
fn(&c.status)
c.statusMu.Unlock()
}
// SetOperate puts the tuner in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error {
if _, err := c.command("operate set=" + boolNum(on)); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Operate = on }) // optimistic; the poll confirms
return nil
}
// SetBypass engages (1) or clears (0) the device's global bypass (antenna
// connected straight through, tuner out of line).
func (c *Client) SetBypass(on bool) error {
if _, err := c.command("bypass set=" + boolNum(on)); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Bypass = on })
return nil
}
// Autotune starts an automatic tuning cycle on the active channel. The rig must
// be keyed into a carrier for the tuner to measure SWR — the device asserts its
// own PTT OUT if "tune PTT" is enabled in its setup.
func (c *Client) Autotune() error {
if _, err := c.command("autotune"); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Tuning = true }) // optimistic until the poll clears it
return nil
}
// Activate selects the active channel. On SO2R hardware ch is 1 (A) or 2 (B);
// on the 3-way variant it selects the antenna (1/2/3).
func (c *Client) Activate(ch int) error {
if ch < 1 {
return fmt.Errorf("tunergenius: invalid channel %d", ch)
}
key := "ch"
if c.GetStatus().ThreeWay {
key = "ant"
}
_, err := c.command(fmt.Sprintf("activate %s=%d", key, ch))
return err
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
// Outage bookkeeping. A dropped link used to be entirely silent: the poll just
// set Connected=false and retried, so "did the tuner disconnect, or is the
// meter simply reading a gap between syllables?" could not be answered from
// the log. Now an outage logs once when it starts and once when it ends, with
// how long it lasted.
//
// Once, not every retry: the poll comes round every 400 ms, and a tuner that
// is switched off would otherwise bury the log. These are local to the one
// goroutine that polls — ensureConnected has no other caller — so they need no
// locking.
var downSince time.Time
for {
select {
case <-c.stop:
return
case <-t.C:
fresh := false
if c.needConnect() {
if err := c.ensureConnected(); err != nil {
if downSince.IsZero() {
downSince = time.Now()
applog.Printf("tunergenius: link DOWN — cannot reach %s:%d: %v (retrying)", c.host, c.port, err)
}
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
continue
}
fresh = true
if !downSince.IsZero() {
applog.Printf("tunergenius: link RESTORED after %s down", time.Since(downSince).Round(time.Second))
downSince = time.Time{}
}
}
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
if fresh {
_, _ = c.command("info")
}
if _, err := c.command("status"); err != nil {
if downSince.IsZero() {
downSince = time.Now()
applog.Printf("tunergenius: link DOWN — poll failed: %v", err)
}
c.dropConn()
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
}
}
}
}
// needConnect reports whether the TCP link is currently down (so the poll loop
// knows a fresh connect + one-shot info query is needed).
func (c *Client) needConnect() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn == nil
}
func (c *Client) ensureConnected() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
if err != nil {
return err
}
c.conn = conn
c.reader = bufio.NewReader(conn)
// Banner: "V1.1.8" (LAN) or "V1.1.8 AUTH" (remote → authentication required).
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
banner, _ := c.reader.ReadString('\n')
banner = strings.TrimSpace(banner)
applog.Printf("tunergenius: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
if strings.Contains(banner, "AUTH") {
if c.password == "" {
applog.Printf("tunergenius: device requires AUTH but no remote code set (Settings → Tuner Genius)")
} else if err := c.authLocked(); err != nil {
c.conn.Close()
c.conn, c.reader = nil, nil
return err
}
}
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
return nil
}
// authLocked sends "auth <code>" and checks the reply. Must be called with c.mu
// held (during ensureConnected). Note the device replies R<seq>|0|... for BOTH
// success ("auth OK") and failure ("Unauthorized"), so the message text — not
// the response code — decides.
func (c *Client) authLocked() error {
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|auth %s\n", id, c.password); err != nil {
return err
}
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return err
}
line = strings.TrimSpace(line)
if strings.Contains(strings.ToLower(line), "unauthorized") {
return fmt.Errorf("tunergenius: authentication failed — check the remote code")
}
applog.Printf("tunergenius: authenticated")
return nil
}
// command sends "C<id>|<cmd>\n" and returns the matching reply line, updating
// the status snapshot from whatever status/message lines arrive. Unsolicited
// "M|" info lines that precede the reply are consumed (they update Message).
func (c *Client) command(cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil || c.reader == nil {
return "", fmt.Errorf("tunergenius: not connected")
}
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
return "", err
}
// Read until the command's reply (R…/S…); consume async M| lines along the way.
for i := 0; i < 8; i++ {
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
c.parse(line)
if line[0] == 'R' || line[0] == 'S' {
return line, nil
}
}
return "", fmt.Errorf("tunergenius: no reply to %q", cmd)
}
func (c *Client) dropConn() {
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
// parse handles the three line shapes: "R<id>|<code>|<msg>", "S<id>|status …"
// and "M|<message>".
func (c *Client) parse(resp string) {
// Async info/warning: "M|<message>" (empty message = cleared).
if strings.HasPrefix(resp, "M|") {
msg := strings.TrimSpace(strings.TrimPrefix(resp, "M|"))
c.setStatus(func(s *Status) { s.Message = msg })
return
}
var data string
switch {
case strings.HasPrefix(resp, "R"):
p := strings.SplitN(resp, "|", 3)
if len(p) < 3 {
return
}
data = p[2]
case strings.HasPrefix(resp, "S"):
p := strings.SplitN(resp, "|", 2)
if len(p) < 2 {
return
}
data = p[1]
default:
return
}
// "info …" carries the hardware variant (3way=1 on the 3-way model, absent on
// SO2R) — parsed once so the UI knows whether channels are A/B or antennas.
if strings.HasPrefix(data, "info") {
tw := strings.Contains(data, "3way=1")
c.statusMu.Lock()
c.status.ThreeWay = tw
c.statusMu.Unlock()
return
}
// Only the "status …" payload carries the live state we render.
if !strings.HasPrefix(data, "status") {
return
}
if data != c.lastRaw {
c.lastRaw = data
applog.Printf("tunergenius: status raw=%q", data)
}
c.applyStatus(data)
}
// applyStatus maps the "status k=v …" fields onto the snapshot, filling both
// channels (A/B) plus the global power/SWR and operating state.
func (c *Client) applyStatus(data string) {
kv := map[string]string{}
for _, tok := range strings.Fields(data) {
if p := strings.SplitN(tok, "=", 2); len(p) == 2 {
kv[p[0]] = p[1]
}
}
active := atoiDefault(kv["active"], 1)
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.Active = active
c.status.Operate = kv["state"] == "1"
c.status.Bypass = kv["bypass"] == "1"
c.status.Tuning = kv["tuning"] == "1"
c.status.RelayC1 = atoiDefault(kv["relayC1"], 0)
c.status.RelayL = atoiDefault(kv["relayL"], 0)
c.status.RelayC2 = atoiDefault(kv["relayC2"], 0)
if v, ok := parseFloat(kv["fwd"]); ok {
c.status.FwdDbm = v
c.status.FwdW = dbmToWatts(v)
}
if v, ok := parseFloat(kv["swr"]); ok {
c.status.SwrDb = v
c.status.Vswr = returnLossToVswr(v)
}
c.status.A = channelFrom(kv, "A")
c.status.B = channelFrom(kv, "B")
// Mirror the active channel into the flat fields the compact widget uses.
act := c.status.A
if active == 2 {
act = c.status.B
}
c.status.FreqMHz = act.FreqMHz
c.status.Antenna = act.Antenna
}
// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed
// status map.
func channelFrom(kv map[string]string, suffix string) Channel {
mode := atoiDefault(kv["mode"+suffix], 0)
freq, _ := parseFloat(kv["freq"+suffix])
return Channel{
PTT: kv["ptt"+suffix] == "1",
Band: atoiDefault(kv["band"+suffix], 0),
Mode: mode,
ModeStr: modeName(mode),
Flex: strings.TrimSpace(kv["flex"+suffix]),
FreqMHz: freq,
Bypass: kv["bypass"+suffix] == "1",
Antenna: atoiDefault(kv["ant"+suffix], 0),
}
}
func boolNum(on bool) string {
if on {
return "1"
}
return "0"
}
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return def
}
func parseFloat(s string) (float64, bool) {
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v, err == nil
}
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW).
func dbmToWatts(dbm float64) float64 {
return math.Pow(10, (dbm-30)/10)
}
// returnLossToVswr converts the device's "swr" field — a return loss in dB,
// reported as a negative number (e.g. -60 = an excellent 60 dB match) — into a
// conventional VSWR ratio. A near-zero return loss (bad match) yields a large
// VSWR; a large negative one yields ~1.0.
func returnLossToVswr(swrDb float64) float64 {
rl := math.Abs(swrDb)
rho := math.Pow(10, -rl/20) // reflection coefficient magnitude
if rho >= 1 {
return 99.9
}
vswr := (1 + rho) / (1 - rho)
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
return 99.9
}
return vswr
}
+145 -2
View File
@@ -27,10 +27,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"math" "math"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -46,6 +49,14 @@ var errFCCMaintenance = errors.New("fcc uls under maintenance")
const ( const (
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip" fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
geoNamesURL = "https://download.geonames.org/export/zip/US.zip" geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
// fccULSDir is the parent listing the weekly files live under. It is browsed
// to recover from the FCC moving the file — see resolveFCCAmateurURL.
fccULSDir = "https://data.fcc.gov/download/pub/uls/"
// fccAmateurFile is the weekly full-database filename, constant across the
// FCC's directory reshuffles.
fccAmateurFile = "l_amat.zip"
) )
// Location is a resolved callsign's home county + grid. // Location is a resolved callsign's home county + grid.
@@ -170,10 +181,19 @@ func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error
return fmt.Errorf("GeoNames crosswalk is empty") return fmt.Errorf("GeoNames crosswalk is empty")
} }
// 2) FCC ULS full amateur database (large). // 2) FCC ULS full amateur database (large). The address is resolved rather
// than assumed — the FCC moves this file between directories.
prog("Locating FCC ULS database", 0)
amatURL, err := resolveFCCAmateurURL(ctx)
if err != nil {
return err // already a user-facing explanation
}
if amatURL != fccAmateurURL {
log.Printf("uls: FCC weekly file not at its usual address, using %s", amatURL)
}
prog("Downloading FCC ULS database", 0) prog("Downloading FCC ULS database", 0)
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip") amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil { if err := download(ctx, amatURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
return fmt.Errorf("download FCC ULS: %w", err) return fmt.Errorf("download FCC ULS: %w", err)
} }
defer os.Remove(amatPath) defer os.Remove(amatPath)
@@ -323,6 +343,129 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
return out, sc.Err() return out, sc.Err()
} }
// resolveFCCAmateurURL returns a URL that actually serves the weekly amateur
// database, working around the FCC relocating it.
//
// The canonical path is .../uls/complete/l_amat.zip and it is tried first. On
// 2026-07-24 the FCC renamed that whole directory to "complete.07242026" and
// left an empty "complete" behind, so every weekly file for every radio service
// (not just amateur) started redirecting to a generic fcc.gov help page — the
// county database became un-downloadable for everyone. The file itself was
// intact the whole time, one directory across.
//
// Rather than hard-code that dated directory — it looks like a pre-migration
// snapshot, and pinning it would break again the moment the FCC restores or
// re-snapshots — we browse the parent listing and pick the most recent
// "complete*" directory that actually holds the file. That survives the
// canonical path coming back (tried first, so it wins), a differently-dated
// snapshot next time, and anything else short of the file being withdrawn.
func resolveFCCAmateurURL(ctx context.Context) (string, error) {
if ok, _ := servesFile(ctx, fccAmateurURL); ok {
return fccAmateurURL, nil
}
dirs, err := listFCCCompleteDirs(ctx)
if err != nil {
return "", fmt.Errorf("the FCC weekly download moved and the directory listing could not be read (%w) — try again later, or check %s", err, fccULSDir)
}
// Newest first: the listing is alphabetical, and the dated names sort in an
// arbitrary order (MMDDYYYY), so try them all rather than trusting the order.
for _, d := range dirs {
u := fccULSDir + d + fccAmateurFile
if ok, _ := servesFile(ctx, u); ok {
return u, nil
}
}
return "", fmt.Errorf("the FCC no longer serves %s at its usual address, and no alternate directory under %s has it either — the FCC has changed its downloads; please report this", fccAmateurFile, fccULSDir)
}
// servesFile reports whether url returns a real file rather than a redirect to
// an HTML error page. The FCC answers a missing file with 302 → a help page, so
// a plain status check on the final response is not enough: we must refuse to
// follow the bounce and insist on a non-HTML body.
func servesFile(ctx context.Context, url string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(strings.ToLower(ct), "html") {
return false, fmt.Errorf("served HTML, not a file")
}
return true, nil
}
// listFCCCompleteDirs returns the "complete*/" subdirectory names in the ULS
// download area (e.g. "complete/", "complete.07242026/"), newest-looking last.
func listFCCCompleteDirs(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fccULSDir, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
return parseCompleteDirs(string(body)), nil
}
// completeDirRe matches an Apache-style listing link to a "complete…" directory.
var completeDirRe = regexp.MustCompile(`(?i)href="(complete[^"/]*/)"`)
// parseCompleteDirs pulls the candidate directory names out of a listing page.
// Split out from the fetch so it can be tested against a captured listing.
func parseCompleteDirs(html string) []string {
seen := map[string]bool{}
var out []string
for _, m := range completeDirRe.FindAllStringSubmatch(html, -1) {
d := m[1]
if d == "complete/" || seen[d] { // canonical path was already tried
continue
}
seen[d] = true
out = append(out, d)
}
// Dated snapshots (complete.MMDDYYYY) — prefer the most recent by date, so a
// stale older snapshot is never picked over a fresh one.
sort.Slice(out, func(i, j int) bool { return snapshotDate(out[i]).After(snapshotDate(out[j])) })
return out
}
var snapshotDateRe = regexp.MustCompile(`(\d{8})`)
// snapshotDate extracts the MMDDYYYY stamp from "complete.07242026/"; a name
// without one sorts as the zero time (tried last).
func snapshotDate(dir string) time.Time {
m := snapshotDateRe.FindStringSubmatch(dir)
if m == nil {
return time.Time{}
}
t, err := time.Parse("01022006", m[1])
if err != nil {
return time.Time{}
}
return t
}
// download streams url to dest, reporting percent when the content length is // download streams url to dest, reporting percent when the content length is
// known and prog is non-nil. // known and prog is non-nil.
func download(ctx context.Context, url, dest string, prog func(pct int)) error { func download(ctx context.Context, url, dest string, prog func(pct int)) error {
+55 -1
View File
@@ -12,7 +12,7 @@ func TestGrid6(t *testing.T) {
}{ }{
{38.90, -77.03, "FM18lw"}, // Washington DC {38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York {40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"},// Los Angeles {34.05, -118.24, "DM04vd"}, // Los Angeles
} }
for _, c := range cases { for _, c := range cases {
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] { if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
@@ -40,3 +40,57 @@ func TestParseGeoNames(t *testing.T) {
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok) t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
} }
} }
// The real listing captured from data.fcc.gov on 2026-07-25, the day after the
// FCC renamed complete/ to complete.07242026/ and left an empty complete/
// behind — which broke the county-database download for every user.
const fccListing2026 = `<html><head><title>Index of /download/pub/uls</title></head><body>
<h1>Index of /download/pub/uls</h1>
<table><tr><th>Name</th><th>Last modified</th><th>Size</th></tr>
<tr><td><a href="/download/pub/">Parent Directory</a></td><td>&nbsp;</td><td>-</td></tr>
<tr><td><a href="UAT/">UAT/</a></td><td>2025-04-08 19:30</td><td>-</td></tr>
<tr><td><a href="complete.07242026/">complete.07242026/</a></td><td>2026-07-24 20:15</td><td>-</td></tr>
<tr><td><a href="complete/">complete/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
<tr><td><a href="daily/">daily/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
</table></body></html>`
func TestParseCompleteDirs(t *testing.T) {
got := parseCompleteDirs(fccListing2026)
if len(got) != 1 || got[0] != "complete.07242026/" {
t.Fatalf("got %v, want [complete.07242026/]", got)
}
// "complete/" is deliberately absent: it is the canonical URL, already tried
// before the listing is consulted, and on this date it was empty.
for _, d := range got {
if d == "complete/" {
t.Error("canonical complete/ should not be offered as a fallback")
}
}
}
// Several snapshots must be tried newest-first, so a stale one is never
// preferred over a fresh one.
func TestParseCompleteDirsPrefersNewestSnapshot(t *testing.T) {
html := `<a href="complete/">x</a><a href="complete.01052026/">x</a>` +
`<a href="complete.07242026/">x</a><a href="complete.11302025/">x</a>`
got := parseCompleteDirs(html)
want := []string{"complete.07242026/", "complete.01052026/", "complete.11302025/"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}
func TestSnapshotDate(t *testing.T) {
if d := snapshotDate("complete.07242026/"); d.Format("2006-01-02") != "2026-07-24" {
t.Errorf("complete.07242026/ → %s, want 2026-07-24", d.Format("2006-01-02"))
}
// An undated name must sort last rather than crash.
if !snapshotDate("complete.backup/").IsZero() {
t.Error("an undated directory should yield the zero time")
}
}
+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)
}
}
}
+129 -5
View File
@@ -16,6 +16,7 @@ import (
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"go.bug.st/serial" "go.bug.st/serial"
@@ -179,7 +180,7 @@ func (m *Manager) Connect(cfg Config) error {
stop, done := m.stopRead, m.doneRead stop, done := m.stopRead, m.doneRead
m.mu.Unlock() m.mu.Unlock()
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver) applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver))
go m.readLoop(p, stop, done) go m.readLoop(p, stop, done)
if err := m.applyConfig(cfg); err != nil { if err := m.applyConfig(cfg); err != nil {
@@ -242,21 +243,39 @@ func (m *Manager) connectSerial(cfg Config) error {
return nil return nil
} }
// applyConfig pushes the keying parameters to the device. // configCommands is the init sequence for a config — separated from the sending
func (m *Manager) applyConfig(c Config) error { // 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{ cmds := [][]byte{
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…) {0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
{0x02, byte(c.WPM)}, // set speed (WPM) {0x02, byte(c.WPM)}, // set speed (WPM)
{0x03, byte(c.Weight)}, // set weighting {0x03, byte(c.Weight)}, // set weighting
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units) {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. // Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)}) cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
if c.Farnsworth > 0 { if c.Farnsworth > 0 {
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM 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 { if err := m.write(cmd); err != nil {
return err return err
} }
@@ -398,10 +417,114 @@ func (m *Manager) write(b []byte) error {
if p == nil { if p == nil {
return fmt.Errorf("winkeyer: not connected") return fmt.Errorf("winkeyer: not connected")
} }
traceTX(b)
_, err := p.Write(b) _, err := p.Write(b)
return err return err
} }
// ── Protocol trace ─────────────────────────────────────────────────────
//
// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures
// it produces are silent: the keyer accepts a byte meant for another command
// and keys something odd. Guessing at that from a description ("it sends one
// element then pauses ten seconds") is how a fix for one firmware breaks the
// others, so the wire is made visible instead.
//
// Off by default — 1200 baud is slow but a long message would still fill the
// log. Enabled per session from the CW settings panel.
var traceOn atomic.Bool
// SetTrace turns the byte-level protocol trace on or off.
func SetTrace(on bool) {
traceOn.Store(on)
if on {
applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged")
}
}
func hexBytes(b []byte) string {
var sb strings.Builder
for i, c := range b {
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02X", c)
if c >= 0x20 && c < 0x7F {
fmt.Fprintf(&sb, "(%c)", c)
}
}
return sb.String()
}
func traceTX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b))
}
func traceRX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: RX %s", hexBytes(b))
}
// cmdName names the command a TX frame carries, so the trace can be read
// without the datasheet open beside it.
func cmdName(b []byte) string {
if len(b) == 0 || b[0] >= 0x20 {
return "(text)"
}
switch b[0] {
case 0x00:
return "admin"
case 0x01:
return "sidetone"
case 0x02:
return "set wpm"
case 0x03:
return "set weight"
case 0x04:
return "ptt lead/tail"
case 0x05:
return "setup speed pot"
case 0x06:
return "pause"
case 0x0A:
return "clear buffer"
case 0x0D:
return "farnsworth wpm"
case 0x17:
return "set dit/dah ratio"
case 0x0E:
return "set mode register"
case 0x11:
return "set key compensation (ms)"
case 0x15:
return "request status"
default:
return fmt.Sprintf("cmd 0x%02X", b[0])
}
}
// firmwareFamily names the keyer generation from the byte returned by Host
// Open. The version is what decides which command set applies, so it is spelled
// out in the log rather than left as a bare number.
func firmwareFamily(ver int) string {
switch {
case ver == 0:
return "no reply — the keyer did not answer Host Open"
case ver < 20:
return fmt.Sprintf("WK1 (v%d)", ver)
case ver < 30:
return fmt.Sprintf("WK2 (v%d)", ver)
default:
return fmt.Sprintf("WK3 (v%d)", ver)
}
}
// Disconnect sends Host Close and releases the port. // Disconnect sends Host Close and releases the port.
func (m *Manager) Disconnect() { func (m *Manager) Disconnect() {
m.mu.Lock() m.mu.Lock()
@@ -473,6 +596,7 @@ func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
} }
return return
} }
traceRX(buf[:n])
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
b := buf[i] b := buf[i]
switch { switch {
+10 -1
View File
@@ -112,6 +112,12 @@ func main() {
MinWidth: 1100, MinWidth: 1100,
MinHeight: 700, MinHeight: 700,
WindowStartState: startState, WindowStartState: startState,
// No OS title bar: it was a dead 32-pixel band above a window that already
// has its own title strip. The app header takes over — it carries the drag
// region, the double-click-to-maximise, and the minimise/maximise/close
// buttons. Windows still draws the resize borders and honours Aero Snap,
// which is why the frame is dropped rather than the whole chrome.
Frameless: true,
// Start hidden and reveal only once the saved position has been applied and // Start hidden and reveal only once the saved position has been applied and
// the DOM has painted (OnDomReady → domReady) — so the window appears // the DOM has painted (OnDomReady → domReady) — so the window appears
// already at its final size and position, with no post-launch jump. // already at its final size and position, with no post-launch jump.
@@ -119,7 +125,10 @@ func main() {
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1}, // The colour the window is painted before the WebView draws. Matches the
// graphite theme's background (#16181d), which is the default on a fresh
// install — otherwise every launch flashes white first.
BackgroundColour: &options.RGBA{R: 0x16, G: 0x18, B: 0x1d, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnDomReady: app.domReady, OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose, OnBeforeClose: app.beforeClose,
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// The portable-folder contract: a database that lives inside the application
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
// a USB stick) keeps working. A path the operator deliberately put elsewhere
// must be left exactly as it is.
func TestPortablePath(t *testing.T) {
base := appDir()
if base == "" {
t.Skip("no executable dir available")
}
inside := filepath.Join(base, "data", "logbook.db")
if got := portablePath(inside); got != "data/logbook.db" {
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
}
// Outside: an absolute path on another drive / a synced folder is a
// deliberate choice and must survive untouched.
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
if got := portablePath(outside); got != outside {
t.Errorf("outside the app folder: got %q, want it unchanged", got)
}
if got := portablePath(""); got != "" {
t.Errorf("empty path: got %q", got)
}
}
func TestResolvePath(t *testing.T) {
base := appDir()
if base == "" {
t.Skip("no executable dir available")
}
// A relative path is anchored to the CURRENT install, whatever drive it is on.
want := filepath.Join(base, "data", "logbook.db")
if got := resolvePath("", "data/logbook.db"); got != want {
t.Errorf("relative: got %q, want %q", got, want)
}
// An absolute path that still exists is honoured as-is.
dir := t.TempDir()
real := filepath.Join(dir, "logbook.db")
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got := resolvePath(dir, real); got != real {
t.Errorf("existing absolute: got %q, want %q", got, real)
}
// The rescue: a path from ANOTHER machine, with the same file present in this
// install's data folder — the copied-folder case.
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
if got := resolvePath(dir, stale); got != real {
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
}
// No local twin: leave it alone so the failure is REPORTED rather than
// silently replaced by an unrelated database.
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
if got := resolvePath(dir, missing); got != missing {
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import "testing"
// The case that started this: WSJT-X / MSHV can only send a 4-character grid, so
// a digital QSO arrived with JN05 while QRZ knew JN05JG — and the fill-if-empty
// enrichment rule kept the coarse one. Roughly 100 km of accuracy, silently lost
// on every digital QSO.
func TestRefineGrid(t *testing.T) {
cases := []struct{ have, found, want, why string }{
{"JN05", "JN05JG", "JN05JG", "the lookup EXTENDS the received square — take the precise one"},
{"jn05", "JN05JG", "JN05JG", "grids arrive in both cases"},
{"", "JN05JG", "JN05JG", "nothing received — take whatever was found"},
{"JN05JG", "", "JN05JG", "nothing found — keep what was received"},
{"JN05JG", "JN05", "JN05JG", "never trade a precise grid for a coarse one"},
{"JN05JG", "JN05JG", "JN05JG", "same grid"},
// A DISAGREEMENT is not a refinement: the station may be portable, and
// what came over the air is then the better record.
{"JN05", "JN18AA", "JN05", "different square — keep what was actually received"},
{"JN18AA", "JN05", "JN18AA", "different square, coarse lookup — keep the received one"},
{"", "", "", "nothing either way"},
}
for _, c := range cases {
if got := refineGrid(c.have, c.found); got != c.want {
t.Errorf("refineGrid(%q, %q) = %q, want %q — %s", c.have, c.found, got, c.want, c.why)
}
}
}

Some files were not shown because too many files have changed in this diff Show More