Commit Graph
502 Commits
Author SHA1 Message Date
rouggy 55a643d9a4 feat(kpa): the Elecraft KPA500 / KPA1500 protocol, decoded and pinned
One package for both amplifiers: they share the Elecraft command set — a
caret, letters, a semicolon, case-insensitive in and upper case out — the
same family as the K3/K4 panel. What differs is the transport and which
commands exist, not the grammar.

Everything here comes from the KPA1500 Programming Reference, and the
document's own examples ARE the test:

  ^WS1204 014;  1204 W and SWR 1.4:1 — power and SWR in one exchange
  ^VI513 061;   51.3 V and 61 A — volts in tenths, amps whole
  ^FL91;        HEX, and 0x91 is 'antenna not connected?'

That last one is why the parsing is pinned rather than eyeballed: read as
decimal, 90 and 91 become 144 and 145 and match nothing, so an amplifier
shut down by high reflected power would report a fault OpsLog could not
name. SWR in tenths is confirmed by the reference too — 'expressed in
tenths, 123 is 12.3:1' — where it had only been inferred from Hamlib.

The client is question-and-answer under one lock, never two questions in
flight: the reference states there is no flow control and that commands
are paced by waiting for the reply. Fast cycle four times a second for
power, SWR and the fault; the rest once a second.

Faults are named in the operator's terms — 'the ATU found no match', not
'fault 92' — and an unknown code from a newer firmware still says
something rather than nothing.

Not wired to the app yet, and two commands are deliberately absent: ^TX
makes the amplifier transmit from software, and ^ON0 cuts the main
supplies with Wake-on-LAN as the way back. Neither belongs on a poll loop
or behind a button that can be pressed by accident.
2026-08-26 17:43:32 +02:00
rouggy 10e1504531 fix(cat-share): answer get_freq with the listening VFO, not the transmit one
Reported from a station running an IC-7850 over USB: with split engaged,
turning VFO B moved VFO A slightly. OpsLog writes nothing to that radio on
its own — but it shares it, and what it was sharing was wrong.

Hamlib's 'f' means the VFO IN USE. RigState follows ADIF, where FreqHz is
where we TRANSMIT, and the two are the same number until split is
engaged — so the adapter handed FreqHz over unchanged and every client
asking for the dial was given VFO B. A client that reads the dial and
writes it back, which is what WSJT-X and its like do, then wrote VFO B's
frequency into VFO A. Hence a shift the size of the split offset, and
hence 'it did not do this before': it only happens with split on.

get_split_freq ('i') already answers the transmit frequency and is
untouched. The one-line rule is lifted into shareRXFreq with a test that
includes this case, the simplex case, and a backend that reports split
without ever filling in the receive frequency — which would otherwise
answer a client with 0 Hz.
2026-08-26 11:41:15 +02:00
rouggy dc8f9c5099 fix(stats): confirmed outranks worked in the band/mode matrix
Reported by VK4DX with the case that names itself: YB confirmed on 20m
digital, painted blue, because one unconfirmed YB station had also been
worked on that slot.

The ladder ran call_c > call_w > dxcc_c > dxcc_w, so a callsign worked and
not confirmed beat an entity CONFIRMED on the same band and mode. The grid
answers 'what do I still need here', and a confirmed entity needs nothing
whoever was worked afterwards — so the order is now call_c > dxcc_c >
call_w > dxcc_w.

Two things made it easy to get wrong, and both are fixed rather than
merely corrected. The code was a run of assignments where the later test
silently overwrote the earlier, so 'call worked' erased 'entity
confirmed'; it now takes the maximum. And the rule lived inside a scan
loop where nothing could reach it, so it is lifted into bandStatusCode
with a table test — including this exact case, and one that fails if
anybody reorders the constants.
2026-08-26 10:53:36 +02:00
rouggy fdc2378191 feat(tci): name the transmit audio source when keying, per the protocol
The specification settles what a night of experiments could only guess at.
TRX takes an optional THIRD argument naming the signal source — tci, mic1,
mic2, micPC, ecoder2 — and TCI 2.0 says it plainly: 'The signal for
transmitting is always taken from the microphone selected in the
ExpertSDR3. If a third-party software connected via TCI wants to transmit
its audio signal, you must specify the third argument - TCI.'

Without it the radio sends no chrono at all, whatever the mode. That is
what the SSB attempts ran into, and what was misread here as 'digital
modes only' — the mode was never the rule, the missing argument was.

So OpsLog says it, following the 'To radio' device: 'tci' when the voice
keyer owns the transmission, nothing at all otherwise, which leaves the
operator's own microphone alone for every other PTT. Nobody has to find
that setting in ExpertSDR3 and set it again for every mode, which is how
it is remembered there.

Two more things from the same document. A chrono with no audio ready is
now answered with silence rather than left unanswered — the vendor calls
that preferable. And the receive stream declares float32 and two channels
instead of trusting the defaults: they are the documented defaults, but a
default is something another program sharing this radio can have changed,
and a stream in an unexpected format is heard as noise rather than as a
mistake.
2026-08-26 00:21:32 +02:00
rouggy ceb88d2e29 refactor(tci): remove the test bench now that choosing the device is the setup
The TCI section of the audio settings was an investigation: open the
stream, read what arrives, record ten seconds to listen to, key a tone.
It answered every question it was built for — the frame layout, the
sample width, that the radio asks rather than follows a clock, that the
transmit audio source decides — and confirmed 80 W on real hardware.

None of that belongs in front of an operator now. The radio is simply one
of the devices in the two dropdowns, and choosing it IS the configuration:
one control, in the place where the question is already being asked.
Keeping the tick box beside it would have been two switches for one
decision, with the second one where nobody looks.

Gone with it: the stream/record/probe bindings, the audio.tci_rx setting,
and twenty-four translation keys. The plumbing they proved out stays and
now carries the voice keyer.
2026-08-26 00:13:04 +02:00
rouggy 5c6df90526 merge: TCI audio — receive, transmit, and the voice keyer over the CAT link
A SunSDR carries its audio on the same WebSocket as its commands, so
OpsLog can take it directly: no virtual cable, no second sound card, no
Windows mixer between the recording and the air. The radio appears as a
device in both audio lists and can be chosen for either direction.

Everything here was settled on real hardware over one evening, and none of
it was guessable from the documentation:

  - The receive stream answers format=3 for four-byte floats, so the
    sample width is derived from the frame rather than trusted from the
    field.
  - The radio asks for transmit audio only when the transmission is the
    CLIENT'S, and only when its transmit audio source is TCI rather than
    the microphone.
  - The chrono is a REQUEST, not a clock: no payload, carrying the size it
    wants, 47 times a second. Audio goes out in answer to it and never on
    a timer of our own — a timer was the first attempt and all 234 frames
    of it were ignored.

Confirmed: a clean test recording, and 80 W out of a 1 kHz tone.
2026-08-26 00:05:40 +02:00
rouggy deeb654482 feat(tci): the voice keyer can send its messages through the radio
The radio now appears as a device in both audio lists — 'Radio (TCI
network audio)' — so the receive audio and the voice keyer can both take
the CAT link instead of a sound card. No virtual cable, no second card, no
Windows mixer between the recording and the air.

The transmit side reuses the exchange the tone probe established, with a
WAV in place of the sine: the radio asks, we answer with the next slice,
and it sets the pace. The message is converted once, up front, rather than
per frame — a voice message is a few hundred kilobytes, and resampling
inside a callback that has 21 ms to answer would put arithmetic on the
path where a late frame is a gap on the air.

The PTT is untouched by all this: the keyer keys before and unkeys after
exactly as it does with a sound card, so a transmission is bracketed by
the same code whichever way the audio travels. And the playback runs OFF
the CAT goroutine, since everything else about the rig goes through that
one place and a ten-second message would otherwise freeze the frequency
display and the antenna following for ten seconds.

Two refusals rather than silent failure. A radio that has gone away stops
being offered as a device at all, and a radio whose transmit audio source
is still the microphone is caught within a fifth of a second — a voice
keyer that transmits silence is worse than one that says why it will not.
2026-08-25 23:59:09 +02:00
rouggy 8e0865a71f fix(elecraft): read the SWR as three digits, not four
Elecraft's release note settles it: SW; returns the most recent reading in
transmit or TUNE as three digits in tenths of a ratio — SW023 is 2.3:1,
and SW999 is the 99.9:1 it reports instead of infinity.

This asked for four, so every answer failed to parse and the bar stayed
empty for the whole transmission. A tester saw exactly that: no SWR at
all, which reads as an unsupported radio rather than an off-by-one.

SW also comes out of the probe list. It is known now, and asking again
mid-transmission costs a round trip on the link the carrier depends on.
2026-08-25 23:58:20 +02:00
rouggy d4d22eb4b2 fix(tci): the mode was never the rule — ask the radio instead
Transmit over TCI is confirmed on a SunSDR: 80 W out of a 1 kHz tone at
70% of full scale into 80% drive, every request answered.

Which makes the earlier rule wrong. 'Digital modes only' came from a real
observation — SSB silent four times over, DIGU answering at once — but the
mode was a coincidence. ExpertSDR3 has a transmit audio SOURCE, microphone
or TCI, kept per mode, and it was on the microphone in SSB. Refusing SSB
would have blocked the one thing a voice keyer exists for.

So nothing is refused on the strength of the mode. The radio declares what
it wants by asking for audio, 47 times a second when it wants any: key,
wait for one request, and stop within two tenths of a second if none
comes — naming the setting to change rather than theorising about it.

That is better on three counts. It works in SSB when the source is set
right, it cannot be wrong about a mode nobody thought to test (AM, FM,
RTTY), and a misconfiguration costs a quarter-second of carrier instead of
five seconds.
2026-08-25 23:51:28 +02:00
rouggy 592dd08835 fix(tci): send the tone near full scale, and log both levels
The transmit path works: six passes, the radio asked 231 times and was
answered 231 times, none missed, and the tone was there on the panadapter.

What was missing was power on the meter, and the cause was the level. The
tone went out at a quarter of full scale, out of caution, into a radio set
to 15% drive — enough to draw a clean signal and not enough to move a
needle. In a digital mode the radio expects a line level it can drive to
full output; the POWER is its own drive control, so sending quietly only
wastes the range. Now 0.7, short of 1.0 to leave room for the peaks.

The start line carries both numbers — ours and the radio's drive — because
a quiet transmission has two possible causes and one line should settle
which, rather than an evening of guessing. Reading 'drive' off the radio
is the only reason it is parsed at all.
2026-08-25 23:35:08 +02:00
rouggy c6294d9eb3 feat(tci): answer the radio's requests instead of pushing audio at it
Three transmissions on a real SunSDR settled how the transmit side works,
and none of it was guessable from the documentation.

The radio asks for audio only when the transmission is the CLIENT'S: with
the operator keying the microphone it sent 282 receive frames and nothing
else. And it asks only in a DIGITAL mode — keyed from here in SSB it stayed
silent four times over, and answered in DIGU immediately. In SSB the
modulator is wired to the microphone, which is also the honest answer to
'why can I hear myself but not the tone'.

The chrono turns out to be a REQUEST, not a clock. It carries no payload —
the message itself is the ask — and it names the size it wants in the
header: 2048 samples, two channels interleaved, 47 times a second, which
is 1024 pairs at 48 kHz, exactly real time.

So audio goes out in answer to a request and never on a timer of our own.
The timer was the first attempt and the radio ignored all 234 frames of
it. Answering also hands the pacing to the radio: no drift, no buffer to
tune, and the size taken from what it asked for rather than from what we
assumed. The sine keeps its phase across frames, since one restarted every
frame is a click 47 times a second.

A pass in SSB is now refused rather than attempted. It keys the
transmitter, produces nothing and teaches nobody anything — and it is
still a transmission.

The feed mechanism is the one the voice keyer will use: WAV samples in
place of the sine, everything else unchanged.
2026-08-25 23:27:26 +02:00
rouggy 848ce68ec5 feat(tci): key the radio ourselves and push a tone, to see what it wants
A first real transmission settled one question and raised a better one.
With the receive stream open and six seconds of transmit, the radio sent
282 frames of receive audio and NOTHING else: no chrono, no transmit
audio. So the chrono the documentation describes is not offered to a
client that merely happens to be connected while the operator keys the
microphone, and waiting for it to appear is waiting for nothing.

The reading that fits is that the radio asks for audio when the
transmission is the CLIENT'S and takes the microphone when it is the
operator's — which makes the experiment obvious. Key it from here, push a
1 kHz tone, and watch. Chrono frames appearing gives their size and
cadence by measurement instead of by guesswork; no chrono but a tone on
the meter is just as useful, because then the pacing is optional and the
voice keyer can push frames at the rate the stream already runs at.

A tone rather than silence so the answer shows on the power meter and not
only in the log.

It transmits, so: an explicit button inside a warning box, five seconds,
capped at ten, and every path out unkeys — including the panic that has
not happened yet and a socket that dies mid-tone. A transmitter left keyed
by a defect is the one fault here that would reach somebody else's band.

Writes are now serialised too. send() held the lock only long enough to
read the connection, which was enough while every command came from the
poll loop; a stream of audio frames from a second goroutine is not, and
gorilla panics on a concurrent write rather than failing quietly.
2026-08-25 23:18:11 +02:00
rouggy 95e57fb812 chore(tci): mark the transmit passes and count every frame type
The first transmit test came back with a log that said nothing, which is
the one answer that cannot be read: either no transmit frames arrived, or
they arrived and went unlogged.

So each pass is now bounded by a line of its own, and every stream type is
counted without limit. A pass that reports 'receive audio: 240, and
nothing else' is a result — it says the radio sends no chrono unless
something more is asked of it — where a log with no transmit lines was
merely a silence. The forty-frame logging budget is also handed back to
the transmit types on each pass, since it was always spent on receive
audio long before anyone got round to keying.

The start line says whether the receive stream is even open, because a
radio with nothing streaming has no reason to send chrono, and that is the
likeliest reason the first attempt saw nothing.
2026-08-25 23:03:14 +02:00
rouggy f50bbc005c feat(tci): the QSO recorder can take its audio from the radio
Confirmed on a real SunSDR: the stream decodes and the test recording
plays back clean. So it can do the job a virtual audio cable was doing —
this wires it to the QSO recorder, which already accepts a pushed source
(the Icom network audio uses the same door).

The conversion lives here rather than in internal/cat: the radio's job is
to hand over what it sent, not to know that the recorder works in 16 kHz
mono. Three samples are AVERAGED rather than two of them dropped —
decimating by picking every third folds everything above 8 kHz back into
the voice band, and on a receiver that is hiss, which a QSO recording has
plenty of already.

Off by default, and applied the moment it is switched: it replaces a
sound card the operator has already wired up, and an option that needs a
restart to take effect reads as an option that does not work.
2026-08-25 20:14:45 +02:00
rouggy 2f2fced15c fix(cat): make the Kenwood/Elecraft link an explicit USB-or-network choice
The network option IS implemented — the same ASCII CAT over TCP instead
of a cable, for a ser2net bridge, an Ethernet-serial adapter, or a radio
exposing its raw CAT port. What was wrong is how it was offered: the COM
port and the network address sat side by side with nothing to say that
the address wins whenever it is not empty.

Worse, the example address read 192.168.1.50:4532. That is Hamlib
rigctld's port — a different protocol, and the one OpsLog SERVES under
'Share CAT'. Anyone copying the example was pointing the radio link at
OpsLog's own server.

Now one choice, then the fields that belong to it. And when the far end
answers something that is not the radio's CAT, the error says that rather
than 'check the baud rate', which over TCP is advice about a setting that
cannot be the cause.
2026-08-25 17:27:44 +02:00
rouggy ed099a660e fix(pgxl): read the amplifier's real state instead of assuming standby
Reported: OpsLog shows STANDBY on a PowerGenius XL that is operating, and
pressing the button 'puts it in Operate' — because it was already there.

The amplifier's status frame has no operate= field. The direct GSCP
client only looked for one, so Operate stayed at its zero value until the
operator pressed the button: at startup OpsLog was not reading the state
wrongly, it was not reading it at all.

The live state is in the frame under 'state', and the FlexRadio side of
this same amplifier has been reading it that way all along — anything but
STANDBY/OFF means the amp is in line, with IDLE meaning in line but not
keyed. The GSCP client now does the same when no operate= is present.

An unknown state leaves the flag alone rather than guessing: claiming
STANDBY on an amp that is in line is precisely the error being fixed, and
it invites the operator to switch on what is already on.
2026-08-25 14:59:50 +02:00
rouggy 6a6b7ad6c2 chore(tci): probe binary frames per stream type
The frame log had one budget for the whole session, and the first forty
receive-audio frames spend it in under two seconds. A transmit-chrono
frame — the thing the voice keyer will have to answer, and whose size and
cadence cannot be read off the documentation — only appears once the
operator keys the radio, by which time nothing would have been logged.

Counted per type now, so the first frames of each kind are recorded
whenever they turn up.
2026-08-25 08:29:24 +02:00
rouggy 01a23ccb77 feat(tci): read the radio's declared format, and record a test WAV
The SunSDR announces its own stream at connect —
audio_stream_sample_type:float32 and audio_stream_channels:2 — and both
were being logged as unhandled while the code worked the format out from
frame arithmetic. The declaration is better evidence and arrives before
the first frame; the arithmetic stays as the check on it. The channel
count now drives the mix-down instead of an assumed stereo.

Adds a ten-second test recording, written as a WAV beside the QSO
recordings. Counting frames proves a socket is delivering bytes; it says
nothing about whether those bytes are the receiver's audio, at the right
rate, in the right order. A stream decoded with the width wrong or the
samples misaligned counts exactly as well as a correct one and sounds
like a fan — so the test is a file the operator can play, the same way
the CW decoder was settled on the air rather than on a spectrogram.

The file is written at the rate the RADIO reported, not a constant: a
recording at the wrong rate plays at the wrong speed, which is the one
fault that would be blamed on the decoding.
2026-08-25 08:25:29 +02:00
rouggy 9b8168370f fix(tci): derive the sample width instead of trusting the format number
A real SunSDR answers format=3 where this expected 0 — and 0 was read
from the documentation, which is exactly the kind of detail a memory of a
document gets wrong. The stream was refused outright: 'format=3, expected
float32', zero frames, silence.

Swapping one magic number for another would only move the guess, so the
width is now MEASURED: the header says how many samples the payload
holds, and dividing gives the bytes per sample. Four is float32, two is
16-bit PCM scaled to the same -1..1 the rest of the audio path uses, and
anything else is reported rather than mangled. That stays true whatever
number the format field carries on the next firmware.
2026-08-24 22:36:21 +02:00
rouggy f9113b6ad3 fix(elecraft): read the TX meters when the RADIO says it is transmitting
The transmit meters were read on k.tx, which records only that OpsLog
keyed the radio. An operator using the front-panel PTT, a footswitch or
the mic button therefore had a panel that believed the rig was receiving
— and since the power and SWR bars are read only while transmitting,
they were never read at all. IF carries the radio's own transmit bit;
that is what decides now.

The meter probe logs RAW answers rather than parsed numbers, and asks a
wider set (SM, SMH, BG, SW, PO, TQ). A command answering in a shape we
did not expect is the interesting case, and parsing hid it behind the
same dash as a command the radio refused — which is what still stands
between us and a working SWR reading on a K3.
2026-08-24 22:16:53 +02:00
rouggy d8064cc426 fix(udp): two copies of MSHV shared one dial frequency
FT8 decodes made on 14.095 were labelled 2190 m. That band is 135.7-137.8
kHz, and every affected decode's audio offset plus 136.1 kHz lands inside
it — while the ones shown with no band at all land just above 137.8. The
decodes were being stamped with another instance's dial.

WSJT-X refuses to start a second instance without --rig-name, so its ids
differ and keying on the id worked. MSHV has no such rule: both copies
call themselves 'MSHV', so the LF instance's Status overwrote the HF
one's dial, T/R period and mode.

Instances are now identified by their sending socket as well as their
name — the socket is stable for as long as the program runs, which is
exactly the lifetime this has to hold over. A second application claiming
an id already in use is shown as 'MSHV #2' and says so in the log, so the
decodes panel can still split them.

This also fixes replies: a Reply was addressed to whichever copy sent
Status last, which on a two-instance station is a coin toss.
2026-08-24 21:38:30 +02:00
rouggy b38b9030f0 fix(elecraft): MOX would not unkey, and the TX meters never moved
Both are the same bug. ReadState returns early while transmitting — the
rig answers '?;' to IF; then, and treating that as a fault used to drop
the link — but that early return also skipped the panel update and the
meter read.

So the panel believed the radio was still receiving. Pressing MOX again
therefore sent ANOTHER transmit command instead of RX, and the K3 stayed
keyed, exactly as reported. And the power and SWR bars were only ever
read at the one moment they mean nothing.

Reading meters while transmitting needs one more guard: a '?;' then says
when the question was asked, not what the radio supports, so the
unsupported-command memory is suspended for that read. Without it, one
badly timed refusal would silence a meter for the rest of the session.

Adds RIT/XIT offset control (±10/±100 Hz, offset shown), a 4.0 kHz filter
button for FT8, and logs the K3's icon word when it changes: there is no
command for 'is the ATU in line', the reference says the switch functions
show up as icon changes, so an operator toggling the ATU while watching
the log will name the bit and the button can then light up honestly.
2026-08-24 21:06:21 +02:00
rouggy efa711af78 feat(tci): receive audio over the TCI WebSocket (experimental)
A SunSDR already carries its receive audio on the same WebSocket as its
commands, so a virtual audio cable and a second sound card are two pieces
of plumbing an operator installs for no reason. This is the receive half:
what the QSO recorder and the CW decoder need.

The reader now looks at the frame type. It used to ignore it and split
every frame on ';' -- harmless only for as long as no stream was ever
opened, since audio bytes would otherwise have been handed to the command
parser a hundred times a second.

NOTHING HERE IS CONFIRMED ON A RADIO. The header layout comes from the
TCI documentation, and the stream-type numbers are exactly the sort of
detail a document gets right and a memory of it does not -- so the first
forty frames of a session are logged verbatim, and a test bench in
Preferences > Audio reports the sample rate the radio chose, the frames
arriving and the peak level of the last second. 'The stream is open' and
'audio is arriving' are different claims and only the second is worth
anything to whoever tries this first.

Transmit (the voice keyer) is the other half and is deliberately absent:
it has to answer the radio's chrono packets at the right pace, and that
is worth doing once the format is settled on real hardware.
2026-08-24 20:34:06 +02:00
rouggy a27fa08e4e fix(elecraft): the ATU is switch 19, not 20
SWT20 was written from memory of the reference rather than from Table 7,
and 20 is not an ATU switch at all — it would have pressed something else
on a real K3. An operator read the table out for us: switch 19 is the ATU
row, TAP for a tuning cycle and HOLD for tuner in line or bypassed.

Adds the HOLD as its own ATU button, because on the radio they are two
different things: tuning is a cycle you start, bypassing is a state you
leave it in.

Power takes 0-110 W on an Elecraft, per the reference — a K3 makes a
little over its rated output — while a Kenwood keeps 200.
2026-08-24 19:20:30 +02:00
rouggy 0848c275d2 fix(yaesu): mic gain scale, 200 W models, and a NAR button that did nothing
Reported from a real FTDX101, and each one is a different kind of wrong.

MIC GAIN was read and written through the 0-255 scale the audio gains
use, but the CAT reference gives MG000-100. A rig set to 80 therefore
showed 38, and moving the slider sent 204 — outside the range the radio
accepts, so it refused the command and the slider sprang back. That
snap-back was the symptom; the scale was the cause.

POWER was capped at 100 W by the slider, not by the radio. There is no
CAT command for 'how much power can you make', so the ceiling comes from
the model name, with the rig believed if it ever reports more than the
table expects — it has just proved what it can do.

NAR is the narrow IF filter, and on a rig that does not implement NA the
button showed a state the radio never gave and did nothing when pressed,
which reads as a fault in the radio. Now shown only when the rig answers,
with a tooltip saying what it is.

Also: the frequency readout steps the Hz digits under the wheel, not just
the kHz ones. Zero-beating a CW signal is a few tens of Hz and it was the
one move the display would not make. And the split chaser is CW-only —
its marker comes from a CW skimmer.
2026-08-24 17:59:13 +02:00
rouggy dd77e7f467 feat(elecraft): the rest of the main controls on the K3/K4 console
So the tester can exercise everything in one session instead of one
control per build: RF and mic gain, squelch, preamp, attenuator, NB, NR,
AGC, filter width, antenna, RIT/XIT with a clear, and the keyer speed.

The analogue scales differ between an Elecraft and a Kenwood — RF gain
000-250, mic 000-060, squelch 000-029 against 0-255 — so each is mapped
through its own full scale and shown as a percentage. Using one rig's
range on the other silently halves or doubles every setting.

Antenna only appears when the radio answers AN: a K3 without the internal
ATU has one socket, and a switch that goes nowhere is worse than none.
2026-08-24 14:29:38 +02:00
rouggy 60dad353ce feat(elecraft): a K3/K4 console — power, volume, S-meter, SWR, MOX, tune
The Elecraft backend already existed; what was missing was somewhere to
operate the radio from. This adds the panel, on the Kenwood-dialect
client the K3 already speaks, in a tab of its own beside the Yaesu and
Icom consoles.

Scope is the six controls asked for and nothing else. Every extra command
added without a radio to test it against is a control that may or may not
do what its label says, and a K3 exposes dozens.

No K3 was available while writing this, so the two halves are treated
differently. The setters are the commands the reference documents
unambiguously and whose effect is visible and reversible (PC, AG,
TX/RX). The meters are the opposite: their scaling differs by model and
firmware, so the raw answers are logged next to the power setting, the
panel says the scaling is provisional, and an unmeasured SWR shows as
'—' rather than as a perfect 1.0 — a good match on an antenna nobody
measured is the reading that costs a radio.

ATU tune sends SWT20 (the K3 front-panel tap) and logs exactly what it
sent, so a wrong mapping names itself instead of leaving an operator
guessing which button OpsLog pressed.
2026-08-24 14:09:28 +02:00
rouggy 1b63483596 chore(lotw): log the record TQSL refused
'No QSOs processed' (exit 8) covers already-uploaded, out-of-certificate-
date-range, and a station callsign that does not match the location being
signed with -- TQSL's message does not say which. An operator whose QSO
was refused and then accepted after a round trip through another logger
is reporting a difference in the record itself, and the temp ADIF is
deleted the moment TQSL returns, so that difference could not be seen.

Logged on any non-zero exit: the record verbatim, the exit code and the
station location. It is QSO data; the key password is not logged.
2026-08-24 11:08:15 +02:00
rouggy 2dd68da284 feat(flex): chase a split pile-up on the skimmer's report marker
Working a DXpedition split means guessing where it listens. The useful
information is not the callsign the DX answered but the FREQUENCY that
station was calling on, and a CW skimmer already marks it: SDC posts each
decoded report to the panadapter as a spot.

Those spots reach OpsLog through the radio's spot feed. On a marker, the
TRANSMIT slice moves there plus a signed offset; the receive slice never
moves, because losing the DX is worse than missing a call. The marker
text is a setting -- it is chosen in SDC by the operator, so any constant
here would be wrong for whoever chose otherwise -- and the switch is a
button beside SPLIT, since it is turned on when a DXpedition appears and
off when it is worked.

Moves are throttled and ignore a marker landing where the slice already
is: a busy pile-up produces several reports a second and the slice would
otherwise never be anywhere long enough to call.

The spot feed is now subscribed to unconditionally. It was tied to
OpsLog's own spot overlay, so an operator running SDC with the overlay
off received nothing -- the reason no foreign spot was ever logged. The
connect-time 'spot clear' stays behind the overlay flag: it wipes every
spot on the radio, a skimmer's included.
2026-08-23 18:49:40 +02:00
rouggy 65991be09d chore(flex): log the spots other programs post to the radio
Chasing a split pileup with a CW skimmer means acting on the report the
DX just sent: SDC posts what it decodes as panadapter spots, so the 5NN
is already on the radio and OpsLog already subscribes to 'spot all'.

What is not known is the shape of those spots -- which field carries the
report, whether it is the callsign or the comment, what source the
skimmer names itself. Guessing produces a feature that never fires, so
this logs the first 60 foreign spots of a session verbatim and does
nothing else. Bounded because a skimmer posts hundreds an hour.
2026-08-23 18:36:55 +02:00
rouggy 1c4697ecb2 feat(awards): match on CNTY as it stands, not only keyed to a US state
The award field list offered us_county, which returns STATE,County --
correct for the United States, where Jefferson County exists in several
states, and wrong for every award whose district is already unique. An
RDA reference (RO-19) never matched, and there was no other field to
point an award at.

Adds county, the raw CNTY value, alongside it.
2026-08-23 18:30:10 +02:00
rouggy 803f4dc4ed feat(hamlog): read confirmations back from a HAMLOG.online ADIF
Their agent protocol has no download verb, so the return path is a file
exported from their site. Running that file through the ordinary ADIF
import is the wrong tool and does real damage: it matches on callsign +
UTC minute + band + mode, their export is rebuilt from their own
database and rarely agrees to the minute, and 'update duplicates' then
inserts everything that failed to match -- several hundred copies of
contacts already in the log.

This path matches only. It stamps the confirmation on QSOs it finds,
falls back to the mode-CLASS key for the modes their export renames, and
REPORTS what it could not match instead of adding it: an unmatched
confirmation is a question about the log, not a contact to create.

Also logs and reports how many rows a delete actually removed -- silence
there made a delete that did nothing indistinguishable from one that
worked.
2026-08-23 15:32:41 +02:00
rouggy 20784c2fb1 feat(filter): filter on when a QSO was added to the log
After an import that inserted instead of updating, there was no way to
ask which records it created: an import of old contacts carries old QSO
dates, so it hides among them. created_at is the only column that knows,
and it was not filterable.

Listed as a date column so a bare YYYY-MM-DD compares on the date part,
like qso_date.
2026-08-23 15:13:55 +02:00
rouggy b3e398b8d6 feat(rda): measure the two district sources before arbitrating between them
A log imported from HAMLOG.online carries the Russian district in CNTY, and the
offline RDA database has its own answer for the same contact. Which wins is a
real question — but only if the two are independent. If HAMLOG's value is itself
a lookup in the same kind of database, there is nothing to arbitrate and a
precedence rule would be ceremony around a redundancy.

So this measures instead of deciding: Settings → RDA gains a read-only compare
that reports agreements, disagreements, and the contacts where only one side
knows, then lists the disagreements — callsign, date, both districts, whether
the database answer was a DATED record (the award administrators' own fact) or
the callsign's current district (an assumption), and whether the QSO is
confirmed on HAMLOG (their value has then been through a match of two logs).

It changes nothing in the log. What to do about a conflict is the next decision,
and it should be taken with the operator's own numbers in hand.

Also: the four HAMLOG fields become filterable, so 'never sent there' is a
filter rather than a guess. A field that can be written and not read back is
half a feature — pinned by a test against the bulk-editable list.
2026-08-23 14:30:48 +02:00
rouggy f4a72da118 fix(hamlog): read the site's own confirmation field, not a guessed one
A real record from their export settles it:

	<CALL:4>RL6M … <CNTY:5>RO-19 <APP_HAMLOG_R150COUNTRY:6>Russia
	<APP_HAMLOG_QSO_CFM:1>Y

The confirmation lives in APP_HAMLOG_QSO_CFM. The four names guessed before a
file was available — APP_HAMLOG_QSL and friends — were all wrong, which is the
argument for reading one rather than reasoning about it.

So that becomes the canonical key everywhere: the award source, the row colours,
the grid column and the bulk editor. Importing a log downloaded from HAMLOG now
carries its confirmations into OpsLog with nothing to rename. The older names,
including the APP_OPSLOG_HAMLOG_QSL that OpsLog itself wrote in the meantime,
are still honoured on read so nothing already stamped stops counting.
2026-08-23 13:43:10 +02:00
rouggy f8fdfab031 fix(hamlog): a status AND a date, like every other service
Bulk edit offered two HAMLOG fields, both dates, and the first question they
drew was the right one: which of these is the status? Collapsing the flag into
the date was neat in the storage and a riddle in the dialog, where the eight
fields above it are four status/date pairs.

So four keys now — APP_OPSLOG_HAMLOG_SENT / _SENT_DATE and _QSL / _QSL_DATE —
and four fields, in the shape an operator already reads for eQSL, QRZ, Club Log
and HRDLog. The stamp written after a successful upload follows: Y in the
status, the day in the date.

Nothing else changes: the QSL Manager's backlog still asks whether the sent key
is present, and the award engine still treats any non-N value in the QSL key as
a confirmation.
2026-08-23 13:14:05 +02:00
rouggy 3f82da3c18 feat(hamlog): mark an already-uploaded log through bulk edit
A log sent to hamlog.online by hand — an ADIF exported from OpsLog and dropped
on their site — had no way of being marked as sent afterwards. The QSL Manager
then listed every one of those contacts as backlog and offered to upload them a
second time.

Both HAMLOG keys join the bulk-editable extras, so a selection (or the whole
log) can be stamped in one pass. They are DATES rather than Y/N flags because
that is the shape the sent stamp already uses: the QSL Manager's backlog asks
whether the key is present, and a date also says when it went.

The test pins that they are reachable through the extras path and NOT offered as
columns — a mapping claiming both would write to a table that has neither.
2026-08-23 13:02:04 +02:00
rouggy 036dc53fe8 feat(hamlog): mark what has been sent, and a QSL Manager backlog
An upload now leaves a trace on the QSO: APP_OPSLOG_HAMLOG_SENT holds the day
it went. The date rather than a Y, so a log says WHEN — and so one shape serves
three readers: eligibility (a stamped QSO is not sent twice), the QSL Manager's
backlog, and the appearance rules' sent channel.

HAMLOG joins the QSL Manager's service list. It has no status column to select
on, so its backlog is the ABSENCE of that extras key — a LIKE over the extras
JSON, which is a full scan and is the right trade here: it answers a button an
operator presses, and the alternative is a promoted column for a field no other
program would ever read. The match is on the quoted key, so a QSO whose comment
merely mentions the text is not taken for one already sent — which is what the
test pins.
2026-08-23 12:40:09 +02:00
rouggy ee148732dd feat: HAMLOG.online upload and confirmations, Yaesu antenna, a named port holder
HAMLOG.online is a seventh external service: one API key, one ADIF record per
QSO, immediate / delayed / on-close like the rest. They publish no API
documentation, so the protocol is read from THEIR OWN client — the HAMLOG Agent
(github.com/hamlogonline/Agent), which is the authoritative source short of
asking them:

	POST https://hamlog.online/api/agent/
	{"ADIFADD": {"APIKEY": k, "ADIFDATA": record}}   → {"STATUS":"OK"}
	{"KEYSTATUS": {"APIKEY": k}}                     → {"STATUS":"OK","CALLSIGN":…}

Success is STATUS == OK, not "no ERROR field": their failure carries ERROR and
no STATUS, and reading an unknown reply — a proxy page, a maintenance notice —
as an acceptance is how a contact goes missing without anyone noticing.

KEYSTATUS buys something no other service here offers: the key can be checked
BEFORE the first QSO, and the answer names the account. A key pasted from
another callsign is caught in the settings panel rather than through a week of
silent refusals.

Their confirmations are also an award source now, ticked like LoTW rather than
expressed through "custom". It reads the ADIF extras, not a column: the standard
names a field for hamlog.EU and none for hamlog.ONLINE, and borrowing the other
site's field would write a falsehood into every exported log. Three plausible
key names from their own export are accepted too, so nobody has to rename a
column by hand after an export.

Yaesu gains antenna selection (AN), remembered per band — the antenna picked on
a band comes back with it, with no table to fill in anywhere. Rigs with one
socket never answer AN and never show the row; the log says which case it is.

And a serial port that is refused now names its likely holder. OmniRig stays
resident once activated and keeps the port of the rig configured in it, so a
native backend never gets it — "Serial port busy" alone accused nobody, and an
FTDX10 spent a morning being blamed for it.
2026-08-23 12:18:27 +02:00
rouggy 405b0e24cf feat: network audio into the QSO recorder, and a Yaesu that stays on its VFO
An Icom reached over the LAN streams its receive audio through the Icom
protocol, and Windows sees no sound card for it at all — the audio settings
could only offer the PC's own microphone, so "From radio" had nothing right to
point at and the QSO recorder had nothing to record. The recorder now accepts a
PUSHED source: the decoded stream goes to the speakers and to the recorder
alike, with no virtual cable to set up. Which source it uses follows the CAT
backend, and it is restarted only when that answer changes, so an ordinary
settings save never cuts a recording in half.

OmniRig no longer sends SetSimplexMode to a Yaesu when tuning. It is a silent
no-op on some — an FT-891 logged OK on every spot click while FreqA never
moved — and actively harmful on others. From an FT-2000 log, one QSY:

	Vfo="AB"(0x80)  Split=0x10000 (off)   the operator's state
	Vfo="BA"(0x100) Split=0x8000  (ON)    after SetSimplexMode

The rig-agnostic "receive and transmit HERE, simplex" call turned split on and
moved reception to VFO B. OpsLog then displayed B — reading the radio correctly,
after having moved it itself. Icom is untouched: there the call is the
authoritative one and the direct write is unreliable.

Also:
  - the NEW county badge shows in the entry form itself, inside the field,
    where the operator is deciding whether to call.
  - the basemap buttons clear the zoom controls; Light sat a few pixels from
    the minus button and was being clicked by mistake.
  - French cluster status: DÉJÀ CTC reads DÉJÀ QSO.
2026-08-23 10:43:11 +02:00
rouggy f50806fbd9 feat: panadapter spots that read, a CI-V link that survives, no auto-call
Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.

"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.

And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.

CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.

Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.

Also:
  - the log rotates while running, not only at startup: the CI-V trace left on
    wrote 416 MB and nothing would have stopped it before the disk did. Closing
    it now releases the crash file too — the runtime keeps its own duplicate.
  - the interface zoom announces itself, with a badge, a click back to 100% and
    a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
  - no more elastic bounce, and no swipe-to-navigate out of the app.
  - Edit QSO: your own TX power and the contacted station's extended locator
    were saved and written back with no box to set them.
  - FT decodes: continents are a multiple choice; a compound MSHV message that
    answers two stations in one line is recognised as addressed to you.
2026-08-23 01:16:26 +02:00
rouggy cb430a22ee feat: Ultrabeam over USB, Paper QSL from the awards grid, per-role schemas
Ultrabeam on a serial port never worked, and three faults were stacked so
each hid the next:

  - Stop() did not wait for the poll loop, so a stopped client kept the COM
    port. Every later client then failed with "Serial port busy" — the
    program holding it being OpsLog itself.
  - startUltrabeam tore the old client down CONCURRENTLY with starting the
    new one, and "Test connection" built a second client on a port already
    ours. Harmless over TCP, fatal on a port with one owner.
  - A silent serial port returns (0, nil) and bufio retries that a hundred
    times: a 4 s timeout became ~7 minutes of a frozen poll loop logging
    nothing.

The controller then answered at once. Confirmed on hardware: the USB cable
presents TWO COM ports, only the second reaches the controller, and only at
19200 baud — so the speed is pinned in code (an FTDI cable opens at any
speed, and a wrong one is indistinguishable from a dead controller) and the
port field says which one to pick. The first exchange after each connect is
hex-dumped, which separates silence from a wrong baud from a misread frame.

Databases now carry only the tables their role needs. Every target used to
get the whole migration set, so a shared MySQL logbook grew settings and
station_profiles tables nothing ever wrote to — an operator inspecting the
server could not tell which copy was authoritative. Statements are filtered
by role, unknown tables are kept in both (fail-safe), and existing databases
are cleaned once, dropping only EMPTY tables. Settings → Database gains a
Compact button, since SQLite frees pages inside the file and never shrinks it.

Also:
  - Awards: the callsigns behind a cell open the QSL Manager on Paper QSL,
    searched, ready for the card dates.
  - The record button no longer goes missing after an update: whether manual
    recording is possible is a per-profile question that was asked once, at
    startup, before the profile was known.
  - Alert rules and filter presets confirm that they were saved.
  - Spot clicks on the radio panadapter carry the POTA park into F3.
  - The build gate is re-checked wherever the active callsign can change; it
    ran at startup alone, and a fresh install has no callsign then.
2026-08-22 14:07:00 +02:00
rouggy abfed4afa2 chore: release v0.26.4 2026-08-22 08:35:49 +02:00
rouggy d48420485f chore: release v0.26.3 2026-08-21 23:28:55 +02:00
rouggy 0cd243fe00 chore: release v0.26.2 2026-08-21 18:21:33 +02:00
rouggy e6889caae9 chore: release v0.26.1 2026-08-21 01:43:14 +02:00
rouggy e3b7a35e2c fix: stop losing decodes, hanging on exit, and wedging the rig link
Three faults an operator's log finally made visible, plus the interface
work that came out of the same session.

Reliability:

- UDP events were dropped on backpressure without a word. A period hands
  over twenty-odd decodes at once, and one slow write to the radio was
  enough to fill the queue — so a decode simply never appeared, and the
  only detector was the operator comparing the panel with JTDX. The drop
  is now counted and logged, panadapter spots went to their own goroutine
  so the radio can no longer hold the decode stream up, and the queue is
  deep enough for a full period.

- The CAT manager waited for its poll loop with a bare <-done. A loop
  wedged in a serial read then blocked every later restart inside Start,
  before it could even try to connect: the rig stayed dead, no line was
  written anywhere, and only killing the process recovered it. The wait
  is bounded at ten seconds and says what it abandoned and why the next
  connect may fail.

- Shutdown had no logging at all, so a hang left nothing to go on and a
  process the operator had to kill — which then blocked the restart after
  an update. Every step is logged, and a watchdog forces the exit if one
  of them never returns.

Auto-call:

- A QSO in progress is now held by OpsLog itself rather than inferred
  from the sender's Status. The moment WSJT-X/JTDX dropped the DX call or
  the Enable-Tx flag between overs, the exchange looked finished and the
  next CQ was answered, interleaving two and then three QSOs on one
  slice. Released on log, on halt, on taking over, and by a watchdog.

Cluster console:

- Replies to a command were buried under the spot flood; a Replies
  toggle hides the DX spots, which the list above already shows.
- Twelve named command buttons beside the input, configured in
  Settings -> Cluster; a button with no command is not drawn.
- Following the tail is now an explicit switch, and sending a command
  re-arms it. It used to measure "am I at the bottom" AFTER committing
  the new lines, so a ten-line reply looked like the operator had
  scrolled up and was never followed — the one case it exists for.

Awards:

- An award can name NO field. The matching controls disappear with it
  and only hand-assigned references count, which is the only thing that
  can feed a reference like WWBOTA. A test pins that nothing else is
  scanned.
- WWBOTA added to the catalogue with its 31 342 references.

Elsewhere: the rotor widget's Stop button acknowledges the press like
the direction presets already did, and the docked band map can be
switched to fit-to-band from its own header.
2026-08-21 01:07:10 +02:00
rouggy 1e507225dd chore: release v0.26.0 2026-08-20 17:53:55 +02:00
rouggy 47992b5f03 fix(decodes): a decode's mode is a marker, not a mode name
Every station on an already-worked band was flagged NEW MODE.

A WSJT-X Decode does not carry the mode's name. It carries the
one-character marker from the decode line - "~" for FT8, "+" for FT4 - and
that character was passed straight through as though it were a mode. The
status resolver then compared "~" against the modes worked for the entity,
matched nothing, and concluded the mode had never been worked. Same cause
put "~ -07" in the comment of every decode spot pushed to the FlexRadio
panadapter, which nobody had traced back.

Resolved through a marker table, with the mode from the sender's last
Status as the fallback - Status is the message that carries the real name.
So an unlisted or future marker degrades to correct rather than to
nonsense, and a sender that puts the name in the field directly is believed
as-is. With neither available the mode is left empty, which makes the
resolver answer "worked": the safe side, since a wrong mode invents a
new-mode flag exactly as the marker did.
2026-08-18 11:40:13 +02:00
rouggy 8c68a7d711 Merge main: OpsLog's own Club Log API key 2026-08-18 10:10:42 +02:00