393 Commits
Author SHA1 Message Date
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 2adf252942 fix(startup): pin the WebView2 profile, and retry without the GPU after a hang
His log reaches 'entering wails.Run' and stops: WebView2 is installed
(120.0.2210.91) and never hands control back. A hang, not a failure —
there is no error to report, so the two remaining causes have to be
addressed rather than diagnosed.

The profile folder is now named explicitly, on the LOCAL disk. Wails
defaults it into the ROAMING profile, which on a managed account can be
redirected to a share; a WebView2 profile on a share that is slow or gone
does not fail, it hangs. Naming it also gives someone a folder they can
be told to delete, which is the fix when the profile itself is corrupt.

And a marker is written before the window is attempted, removed when it
paints. Finding it at the next launch means the last one never got there,
so that launch runs with GPU acceleration off: a WebView2 that cannot get
on with the graphics driver hangs exactly like one that cannot start, and
this is the single lever that separates them — at no cost on machines
where it was never the problem.
2026-08-25 21:13:22 +02:00
rouggy 660653ce14 fix(startup): report a window that never opens
The data folder is created and stays empty, the process keeps running,
and no window appears. Nothing returns, so nothing is logged: WebView2
never hands control back, and a hang has no error to report.

So the ABSENCE of progress is reported. If OnStartup has not been reached
twelve seconds after wails.Run, that is written to the startup log and
shown in a message box naming the two causes — a missing WebView2 runtime
and an antivirus blocking msedgewebview2.exe — because 'OpsLog is not
responding' otherwise sends people to reinstall OpsLog, which is the one
thing that cannot help: the part that has not started is not ours.
2026-08-25 21:09:30 +02:00
rouggy f3b607b15a chore(startup): name the WebView2 runtime and mark every milestone
His startup.log stops after 'data dir ok', which rules out the two faults
already covered and leaves the window creation itself — where a missing
WebView2 runtime lands, sometimes as a silent exit rather than an error
anyone can print.

So the runtime's version is read from where it registers itself (both
hives, and the 32-bit view where a per-machine install usually lands) and
written BEFORE the window is attempted: 'WebView2: not found' answers the
whole question in one line. Breadcrumbs now also mark entering wails.Run,
reaching OnStartup, and the window painting — which places any launch
that produces nothing between two known points.
2026-08-25 21:07:47 +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 1e87081058 feat(lists): a satellite list, offered on the entry form
SAT_NAME is compared character for character by the awards and by LoTW:
AO-91 and AO91 are two different satellites to everything downstream, and
typing it afresh on every pass is how one of them ends up in a log. So
the station keeps its own list (Preferences → Lists → Satellites) and the
field offers it, alphabetically — while still accepting anything typed,
because a bird worked once and never added to the list must not be
impossible to log.

Not seeded with a shipped list of two dozen birds: an empty list means
this station does not work satellites, and filling the dropdown with
names nobody here has heard makes the field harder to use, not easier.

Also fixes the RDA district comparison: its conflict list was capped at
about six visible rows of a list holding up to two hundred, in a panel
that would not scroll to show the rest, and nothing in it could be acted
on. Taller, scrolling, and a callsign opens the contact.
2026-08-25 19:30:29 +02:00
rouggy bd540ef18b feat(cat): three transports for Kenwood/Elecraft, chosen not inferred
USB, RS-232-to-Ethernet, and the radio's own network protocol are three
different things, so they are three entries in one dropdown — and the
transport is now a stored setting rather than something deduced from
which field happened to be filled in. The old rule (a host wins whenever
it is not empty) is invisible from the settings page: someone who typed a
host months ago and later set a COM port had a radio that never answered
and nothing on screen to say why.

The third entry is named and refused, with the reason. It is a session
with its own framing and login, not the CAT byte stream over a socket, so
it has to be written per radio and against one — and a K4 owner reading
this list should be told that, not left wondering whether the empty
address field was the problem.

The upgrade reads an older install from what it has: a configured host
means the bridge, which is what the previous code used it for. Pinned by
a test, including the case that matters after the fact — choosing USB
with a stale host still stored gives the COM port.
2026-08-25 17:46:14 +02:00
rouggy df1d2767cd feat(antgenius): the selected antenna can fill MY_ANTENNA
A station with a switch knows something the log does not: which antenna
is actually connected. The working conditions hold what was PLANNED for
the band, and stay right until the operator throws the switch — after
which every QSO keeps claiming the other antenna.

Optional, and off by default: a station that names its antennas
differently in the two places would otherwise find its log quietly
rewritten. The name written is the one configured on the device, since
that is the name the operator gave it and the one they will look for.

WHICH PORT is the real problem, and the reason this is not a one-liner.
The switch has two, the radio has two jacks, and the QSO went out through
one of them; naming the wrong port's antenna is worse than naming the
band default, because it looks authoritative. The radio's own TX antenna
selection decides, and the jack-to-port wiring is a setting — it is the
station's cabling and neither device can report it.

When the port cannot be told — a transverter jack, a rig that reports
nothing, both switch ports live — nothing is written and the band default
stands. Silence beats a confident guess in a field nobody re-checks.
2026-08-25 15:08:48 +02:00
rouggy 6536d140ba fix(window): check the saved position against the monitors, not their bounding box
Reported from a multi-monitor station: window.json held x=-7680 and the
window opened where nobody could see it, with no way back short of
editing the file — which nobody knows to do.

The guard existed but asked the wrong question. It tested the position
against the VIRTUAL SCREEN, the rectangle spanning every monitor, and
monitors rarely tile that rectangle: a wide screen beside a tall one, or
one mounted higher, leaves gaps inside the box that belong to no monitor.
A window in a gap passes a bounding-box test and is invisible. The test
now walks the monitors themselves, through EnumDisplayMonitors, and uses
each one's WORK area — a title bar under the taskbar cannot be dragged
either.

A position that is genuinely lost is now MOVED onto the nearest monitor,
keeping the window's size. Handing it back to Windows lost the size too
and dropped the window on the primary screen wherever Windows chose.

The arithmetic is in screenclamp.go with no Win32 in it, and tested
against the reporter's four-monitor layout and against a gap between two:
this fault is invisible by definition and cannot be reproduced without
the reporter's screens, so a table test is the only place it can be held.
The layout is also logged at every start, since the first question after
'OpsLog does not open' is what the screens looked like.
2026-08-25 09:53:34 +02:00
rouggy 27d0952d01 feat(cluster): the spot list size is a setting
The list is a ring buffer that held a thousand spots, and on a busy
evening a thousand arrive in a couple of minutes. So the buffer decided
how long a spot lived, and the spot LIFETIME setting never got the chance
to expire anything: fifteen minutes meant nothing when the oldest spot
was pushed out after two.

Now settable (Preferences → Cluster, beside the lifetime, since between
them they decide the same thing), 100 to 10 000. The ceiling is a real
limit rather than a round number: every spot is matched against the
worked index and the alert rules, and is a row the cluster grid and every
open band map re-render.
2026-08-24 19:15:32 +02:00
rouggy 506a1e6e4c fix(clublog): pace and cap the real-time deletes
Club Log wrote to this station about 167 requests in four minutes and a
coming IP block. That was one deletion of a couple of hundred rows going
through delete.php, which is a REAL-TIME endpoint: it exists for an
operator removing a contact they just logged wrongly, at human pace.

Today's earlier fix stops the requests that were pointless -- QSOs Club
Log never had. This one handles the rest, which are legitimate but still
a batch: one delete every 1.2 s, and a stop at 25 with a line saying to
finish on clublog.org. There is no bulk-delete API to move to, so going
slower and refusing to push a large deletion through a real-time endpoint
is the whole of the answer.

Costs the operator nothing: the withdrawals already run in the
background.
2026-08-23 19:46:42 +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 ab89611eca feat(hamlog): show what the confirmation import did, and export what it could not place
The import printed four numbers and left nothing behind. 1106 matched
and 395 unmatched out of 1501 is not a report: the operator cannot see
which contacts were confirmed, nor work through the discrepancies.

Two additions. The Results view is now filled with the confirmed
contacts, flagged new entity/band/mode/slot against what LoTW and paper
QSL already confirm -- the only sense in which a HAMLOG confirmation
changes anything. And the unmatched ones can be written out as ADIF,
because a few hundred of them are a list to open and compare, not
something to read in a log window.
2026-08-23 15:43:57 +02:00
rouggy e01fc39abc fix(delete): stop asking Club Log to delete QSOs it never had
Deleting a selection with 'delete from the remote services' on took
minutes. Club Log was called for EVERY deleted QSO, uploaded or not, and
for the ones it never had it answers 403 -- a full network round trip per
contact, in series, on the goroutine the UI was waiting on. QRZ already
skipped records with no stored logid; Club Log had no equivalent guard.

Three changes, each fixing one part of the delay:
  - only contacts whose clublog upload status is Y or M are withdrawn;
  - the withdrawals run in the background, after a snapshot -- the local
    log is the operator's own copy and must not wait on two websites;
  - the hooks read the rows in one query instead of one per id, which on
    a remote MySQL logbook was 2 round trips per QSO before the DELETE.

Consecutive refusals now abort the run: Club Log blocks an account that
keeps sending requests it refuses, so working through the rest of the
selection only earns a longer block.
2026-08-23 15:37:49 +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 086581851b feat(hamlog): confirmation defaults and grid columns; drop a settings paragraph
Preferences → Confirmations gains a HAMLOG.online row, sent and received, with
the same defaults every other service has: a new QSO can start at N so the
backlog is meaningful from the first contact. The defaults reach it through a
new setExtraDefault — fill() writes through a pointer to a string field and
these two live in the extras map, which no pointer reaches.

The QSO grid gains four HAMLOG columns (status and date, both directions),
hidden by default: a column per service shown to everyone is how a grid becomes
unreadable.

And the paragraph explaining what "Sent" means is gone from that page.
2026-08-23 13:25:20 +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 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 73806cbd70 feat(hamlog): the settings panel, the key check, and a row-colour channel
HAMLOG.online is now reachable: Settings → External services → HAMLOG.ONLINE
takes the API key, the auto-upload switch and the timing, exactly like the six
services beside it, and a link opens the page where the key is issued.

The button next to it checks the KEY, not the connection. HAMLOG answers
KEYSTATUS with the callsign the key belongs to — something no other service
here offers — so the result reads "Key is valid — account F4BPO" rather than a
green tick. A key pasted from a club account or a second station is visible at
once, which is precisely the mistake a tick would have hidden.

No on-close timing, for the same reason as Cloudlog: there is no per-QSO upload
column to select a backlog from at shutdown, so the option would have done
nothing. Immediate or delayed, and a stored on_close falls back to immediate
rather than silently uploading nothing.

The appearance rules gain a HAMLOG.online channel too, so a row can be coloured
"confirmed" on that alone. It reads the ADIF extras — the standard names a field
for hamlog.EU and none for hamlog.ONLINE — using the same keys the award engine
reads, so the two can never disagree about what a confirmation is. "To send"
skips it: a QSO is either uploaded or not, there is no card to be owed.
2026-08-23 12:29:38 +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 f45952d7fb fix(cat): rebuild the rig link only when the connection itself changed
Pressing Save in Preferences tore down the CAT link and the shared CAT server
unconditionally. Two consequences an operator sees at once: WSJT-X and JTDX are
thrown off their rig mid-QSO with an error box, and every spot disappears from a
FlexRadio panadapter — a Flex reconnect clears them by design, so the wipe on
save was the reconnect, not a stray command. Switching profile re-applies the
same settings and did exactly the same.

Two signatures decide it now. catLinkSig covers what shapes the physical link
(backend, host/port, COM port and baud, CI-V address, network credentials);
catShareSig covers the protocol and port of the rigctl/TCI server, which is the
socket WSJT-X actually holds. Same string in, same connection out, so there is
nothing to gain by rebuilding it. The poll interval, command delay, transverter
offset and spot options are applied to the running manager as before — the tests
pin that a spot option in particular can never trigger a reconnect, since that
is the one that would wipe the panadapter.

Also, two things the log would not say:

- WSJT-X/JTDX report their Enable Tx state in every Status and we wrote it
  nowhere. When a station is called and nothing goes out, that flag is the whole
  answer — a Reply does what a double-click does, it cannot arm a transmitter —
  and the only way to see it was to watch a button in another window. Logged on
  change, so it costs a few lines a QSO.

- The antenna log said "steppir connected" before anything had talked to it,
  which made an unopenable serial port look like a working link with tracking
  switched off. It says "configured", which is what it knows.
2026-08-21 14:35:32 +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 f832d1ba07 Merge main: station callsign on import 2026-08-18 11:08:56 +02:00
rouggy f0e00c63a3 fix(import): "fill my station fields" fills the station callsign too
STATION_CALLSIGN was the one field the option deliberately skipped, on the
grounds that stamping the active call could re-route a mixed-call log.

That protected nothing. The same option already writes this profile's grid,
rig, antenna, city and postal address onto every record it finds blank — it
has assumed "this log is mine" long before it reaches the callsign. And the
thing that actually keeps a multi-op log intact is that a record CARRYING a
station callsign is never touched, which has always been true and still is.
Withholding it only meant the option quietly failed on the one field an
operator goes and checks afterwards, leaving imported QSOs with no station
call at all — invisible in the ADIF they export, and unroutable for LoTW
and Club Log.

Counted and logged: "did it fill the callsign?" is the first question after
an import, and the log is where it gets answered.
2026-08-18 11:08:45 +02:00
rouggy fba7e79a1c feat(decodes): answer a station on click, DT and Freq, badge filters
Clicking a decode now ANSWERS it. It sends WSJT-X/MSHV a Reply message
(type 4), which is the same thing as double-clicking the line in their own
Band Activity window: the application looks the decode up, sets its
transmit frequency to the caller's and starts the exchange.

It deliberately does not tune the radio, which is what it did before and
why nothing happened. On FT8 the whole band sits inside one passband, so
moving the dial changes nothing about who gets answered - the decision
belongs to the decoding application, and the Reply is the only way to hand
it over. Tuning would also just fight it for the VFO. The entry is still
filled so the QSO can be logged here.

The reply is routed by PROGRAM ID, not by listener: two receivers can share
one multicast group, and answering a station heard on the 6 m instance by
talking to the 20 m one would start a call on the wrong band. It goes to
the address that instance's packets actually arrive from - a multicast
listener must answer the sender, never the group. WSJT-X matches the reply
against its own decode list, so the payload replays the decode field for
field: time, snr, delta time, audio offset, mode and message text.

Two columns added, DT and Freq - the audio offset inside the passband, not
the RF frequency, which is the same for every station in the list and says
nothing. Past about two seconds DT takes a warning tint: that station is
drifting out of the window.

The transmit strip. "You cannot see what you are sending, or who you are
calling" - two separate faults. The message was only ever threaded into its
period, and in FT8 you transmit in the slots you are NOT receiving in, so
its period had no decodes and the whole line was dropped; a transmit slot
now creates its period. And the state is a strip of its own at the top,
because it is the one thing on the screen that is about the operator rather
than the band. It is fed by every Status rather than only by one carrying
transmit text, so it can still name the station being called on MSHV and
older JTDX builds, which stop before tx_message in the Status payload.

"New only" became per-category badges, in the colours and the vocabulary of
the Chase New panel. None lit shows the whole band - this is a decode log
first, and a panel that opened by hiding most of the traffic would be lying
about what is on the air.
2026-08-18 06:53:10 +02:00
rouggy 4f77d51ffe refactor(decodes): real columns, spelled-out flags, bigger type
First pass on the panel from operating feedback.

Columns are a grid template shared by the header row and every data row, so
the two cannot drift and the eye has a rail to follow. It is capped at
1500 px and centred: free-flowing, a 2500 px window put the country a foot
from the callsign it belonged to and left a hole in the middle of every
line.

"New" gets a COLUMN. It was only a coloured edge before, which says
something is special without saying what — and every one of these is a
reason to break off what you are doing and call. The entity verdict is a
solid badge, the orthogonal ones (park, grid, prefix, county) are outlined
in the colours markerColour already gives the cluster list and the band
map, so a new park is the same green in all three. Applied inline because
those are categorical --chart-* custom properties, which the theme does not
expose as Tailwind colour utilities: written as border-chart-7 the badge
would simply have had no colour.

Band and mode selectors now appear only when the feed actually carries more
than one of each. One MSHV is one band and one mode, so for most operators
they were furniture; they show up the day a second instance puts a second
band on the link, which is the only day they mean anything. Same rule for
continent, and a receiver count when more than one instance is feeding.

Added a LoTW-only filter, and raised the type throughout (call and message
to 14 px, secondary to 12 px, badges to 11 px) with more room per row.

The decode payload now carries the sending application's own id. It tells
two receivers apart on one multicast group — and it is the address a
WSJT-X Reply message would have to go back to, so it is carried now rather
than requiring another trip through the parser later.
2026-08-18 06:04:28 +02:00
rouggy a197d124dc feat(decodes): an FT decodes tab fed by the inbound UDP link
Every FTx decode WSJT-X, JTDX or MSHV puts on the wire, grouped by T/R
period. Optional and closable, from Tools -> FT decodes; its open state is
remembered, because an operator running digital modes leaves it open for
the session rather than consulting and closing it.

The period is the point, and what separates this from the cluster list.
FT8 is a sequence of fifteen-second slots and a band is read by watching
them go by: who called CQ this slot, who answered, what I was sending while
they did. A flat list sorted by time loses exactly that, so the list is
grouped one section per period, newest first, with the operator's own
transmission shown inside the slot it went out in.

Three fields had to be carried up from the wire to make it possible:

  - the decode's OWN timestamp, which the parser read and threw away. It is
    what assigns a slot: a period's decodes arrive in one burst a second or
    two after it closes, so arrival time piles a whole period into the next
    one. Rebuilt to UTC from milliseconds-since-midnight, with the
    day-boundary case handled - a decode stamped 23:59:58 arriving at
    00:00:01 would otherwise be dated a day ahead and sit at the top of the
    list for the rest of the session.
  - the decoded line itself. The exchange is what says where a station is in
    a QSO, and no set of extracted fields reads like "R-09" does.
  - tx_message and transmitting from Status, which nothing parsed before.
    Recorded once per message rather than on every Status, which repeats it
    about once a second for the whole over.

Also picked up on the way: is_new, low_confidence, off_air, the operator's
own call and grid, and the T/R period itself - better authority on slot
length than the mode name, which says nothing about a custom period. The
Status tail is read defensively: those fields were appended over successive
schema versions and JTDX and MSHV each stop at their own point, so a short
packet is normal and keeps whatever parsed.

Status flags come from ClusterSpotStatuses, the resolver the cluster list
and band map already use, filling the same cache. One verdict per call:
"new band" in this panel and plain worked in the cluster two seconds later
would be worse than no flag at all. Clicking a call goes through the same
handler as a cluster spot, so answering a station is one gesture whether it
came off telnet or off the receiver.

Filters: CQ only, new-anything only, band, mode, continent, an SNR floor
and a free search. The band, mode and continent choices are built from what
is actually on the feed - offering 160 m to a station whose receivers are
all on 6 m is noise.

Decodes are held in the frontend and pruned to a rolling half hour: they
are a live view, not data, nothing outside the panel reads them, and a
night of FT8 on 20 m would otherwise grow a list no filter can rescue.
Arrivals are staged on a 300 ms timer so a period landing as fifty packets
costs one status lookup and one render.
2026-08-18 05:51:34 +02:00
rouggy 9599c3e0b9 chore: release v0.25.9 2026-08-18 05:09:04 +02:00
rouggy 5e80c27f61 feat(appearance): the band/mode matrix colours can be chosen
The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its
palette was fixed per theme. Settings -> Appearance now offers the six: the
four status fills, the never-worked fill, and the ring on the cell being
entered.

Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an
--mx-* ramp tuned to its own background, so an operator who only wants a
different green must not thereby freeze the other four to the theme they
happened to be using that day. An empty value means "whatever the theme
says"; the chosen ones are stamped inline on <html>, where they win over
every theme; switching the feature off hands the colours straight back.

The pickers are seeded from what the matrix is painting at that moment
rather than from a fixed palette, so the choice starts from the colours in
front of the operator. A new --mx-cur token carries the current-entry ring:
it follows --warning by default, so it stays theme-correct on all twelve,
but can be recoloured without dragging every other warning in the app along.

The legend under the matrix and its cell tooltips were hardcoded English.
They now go through t() with the same keys as the pickers, so the grid and
the settings cannot disagree about which green is which.
2026-08-17 16:23:39 +02:00
rouggy 7be6f64596 chore: release v0.25.8 2026-08-17 13:18:32 +02:00
rouggy 0bab7f05b9 feat(relays): accept a self-signed certificate on a generic HTTP board
HTTPS to a relay board could not work. Nearly every board that offers it signs
its own certificate — there is no authority anywhere that could have signed it —
so the request failed verification before it left.

A checkbox, per board, off by default. Not a blanket switch, because the other
HTTPS case is real and opposite: a board reached from outside through a proxy
with a genuine certificate, where verification is the only thing standing
between an antenna switch and the internet. Same setting, two boards, different
answers.

Off by default is only safe if the failure explains itself, so a certificate
error now names the box to tick. Go's own "x509: certificate signed by unknown
authority" is accurate and tells an operator nothing about what to do next.

Shown only once an https:// URL is actually in the board's configuration. A
board on plain HTTP has no certificate to argue about, and an option that cannot
matter yet is one more thing to wonder about.

The flag joins the driver cache key: ticking it has to rebuild the driver, or
the cached one would go on refusing the certificate with the verifying client it
already holds.

The boards that take a bare host — WebSwitch, KMTronic — keep verification. An
https:// typed there is the proxy case by construction, since they default to
plain HTTP on the LAN.

Three tests against a real self-signed TLS server: accepted with the box,
refused with a message naming it without the box, and one board's setting not
leaking into another's.
2026-08-17 10:57:29 +02:00
rouggy dc898ce2af fix(amps): combined amplifiers are commanded together, power level included
Two faults in the combiner coupling, both reported from the operating position.

THE POWER LEVEL WAS NEVER COUPLED. ON, OFF and OPERATE fanned out to the group;
L/M/H did not — it was simply the command nobody had linked. Two combined
amplifiers left at different power levels feed the combiner unevenly, which is
the thing the coupling exists to prevent.

THE COMMANDS DID NOT LEAVE TOGETHER. The second amplifier was commanded only
once the first had answered, and an SPE answers over its own link in its own
time. The combiner heard power appear on one input before the other and beeped
about it, on every OFF and every ON.

Each target now gets a goroutine, all parked on one channel until every one is
ready; closing it releases them together. That is the difference between "start
one, then start the other" and "both leave at once" — they have separate clients
and separate connections, so nothing downstream re-serialises them.

It matters most on the power level, which is not one command at all: an SPE has
no "set level", so the driver taps the POWER key and waits for the amp to report
the new one before tapping again — up to three taps, up to two seconds each. One
after the other, the pair would sit at different levels for six seconds.

A single amplifier still runs inline: no goroutine, no barrier, nothing new to
go wrong for the operators who have one amp. The one that was clicked stays
first, because its failure is the one worth reporting.
2026-08-17 10:42:19 +02:00
rouggy 21a0d560de feat(awards): a reference's number can be corrected in the editor
The one field the editor would not let you touch, and the one that was wrong on
WAJA. Every other property of a reference — its name, pattern, entity list,
validity window — was editable; the code was rendered readOnly, so correcting a
number meant deleting all 47 references and importing a new list, throwing away
anything the operator had adjusted in it.

A rename in the store, not a delete plus an insert: everything the reference
carries travels with it, which is the whole point of correcting a number rather
than replacing an entry. A number already in use is refused — REPLACE INTO would
have let one reference silently swallow another, discovered much later as a
prefecture quietly missing from the list.

The typed code is held apart from the selection. The list and every field patch
key off the selected code, so editing it in place made the editor lose the
reference mid-edit.

SaveAwardReference now recomputes the log like Delete and Replace already did. A
reference's name is what the award column SHOWS for awards displaying by name,
and its pattern is part of what matches at all — so editing one changes rows,
and the grid was left showing the old label until something else happened to
trigger a pass.

Changelog: the three TCI-sharing lines are merged into one. The server and the
two fixes made to it while building are one unreleased feature, and an operator
only ever meets the finished thing. The TCI-client PTT line stays separate — it
is OpsLog driving a SunSDR, the other direction entirely.
2026-08-17 10:26:13 +02:00
rouggy 3f95763ca6 feat(lookup): a cache TTL of 0 switches the cache off
Wanted for the case where the answers are moving: an operator correcting their
own QRZ record, or chasing a DXpedition whose page changes during the operation,
otherwise waits out thirty days before OpsLog will ask again. Clearing the cache
by hand works once; this is the setting for a whole session.

Nothing is read from it and nothing is written to it — rows stored while it is
off would only sit there going stale, waiting for the day it comes back on.
Switching off is NOT clearing: what it already holds stays, and the Clear cache
button remains the way to throw that away.

Two distinctions the code now has to keep, both load-bearing:

An EXPLICIT stored zero is off; an ABSENT key is the thirty-day default. Every
operator who has never opened this setting has nothing stored, and reading that
blank as a zero would silently switch the cache off for all of them.

The CONSTRUCTOR's zero is still the default, not off. At startup the settings
have not been read yet, and beginning with no cache would hammer the provider
for the first seconds of every launch. Only SetTTL, called once the operator's
settings are known, can switch it off.

A negative lifetime is meaningless and is ignored rather than rounded into
either meaning.

The input had to change too: it derived its value from the stored number on
every keystroke, so the box could not be emptied — and 0 was unreachable
outright, since parseInt('0') || 30 is 30.
2026-08-17 09:21:12 +02:00
rouggy 72ec3cbb97 feat(cat): share the rig over TCI as well as Hamlib — one or the other
internal/rigctld exists because Windows gives a COM port to ONE process: the
moment OpsLog talks to the radio natively, nothing else can. It answers the
programs that speak Hamlib NET rigctl. This answers the ones built around Expert
Electronics' TCI instead — and it answers them whatever radio is connected,
because it sits on the same backend-agnostic Rig interface. An operator with an
Icom or a Yaesu can now hand a TCI-only program a working rig.

One server or the other, never both. They answer the same questions about the
same radio, nothing speaks both, and a second listener is only a second thing to
go wrong.

Written against the official TCI Protocol document (ExpertSDR3/TCI, 12 January
2024, MIT — downloaded and read, not recalled): the initialisation set of §4.1
in its documented order, and the argument order of every command from §4.2. A
client will not proceed past connect without that block, which is why it is
written out in full rather than stubbed.

The one dangerous detail is the VFO mapping. TCI's channel A is where you
LISTEN and channel B where you transmit — the opposite way round from OpsLog's
RigState, which follows ADIF. Getting that backwards would put a station on the
DX's own frequency, so it is pinned in both directions by a test, and RxFreq was
added to the adapter rather than inferred.

Writing channel B while the rig is simplex is ignored: the client asked to
prepare a split transmit frequency, not to QSY, and a logger doing that on every
spot click would drag the operator off the station they were listening to. A
backend that cannot split still refuses out loud.

Only changes are pushed. TCI clients redraw on each command, so re-sending an
unchanged frequency four times a second makes a VFO readout flicker and fights
the operator's own tuning.

Nine tests, no socket needed — the protocol is the decision, not the transport.
2026-08-17 02:35:21 +02:00
rouggy a8bca8c316 feat(awards): an entity that IS one island group fills its own IOTA reference
QRZ carries <iota> only for the operators who filled it in, and most have not.
But for 99 entities the reference follows from the entity alone — a station in
Ascension is on AF-003 whatever its callbook record says — and the entity is
known for every callsign from cty.dat, with no callbook at all.

So this reaches the operator with no QRZ subscription and the station that has
never touched a callbook, and it lands before the contact is logged, which is
when a reference is worth having.

Filled ONLY when the callbook gave nothing: an operator who typed a reference
knows something a table cannot — an IOTA-heavy entity, a rare island being
activated — and that still wins, as does one picked by hand on the entry.

The table is the IOTA programme's own dxcc_matches_one_iota.json, which lists
exactly the entities that map to ONE reference. France is not among them: a
French station is usually on the mainland and on no island, and guessing would
put a reference on hundreds of contacts that earn none.

Held as a table rather than a download — 3.5 kB that changes when an entity
appears, so fetching it daily would buy nothing and would fail exactly where a
portable station usually is. The header says where it came from and how to
refresh it.

Follows 747c2b9 and eab11db, which read the callbook's own tag.
2026-08-17 02:26:52 +02:00
rouggy 91b21a4a36 feat(sync): the folder loop, the hooks and the panel — it can be switched on now
The three layers underneath were already there and unreachable: the change-log
format (7d664bd), the identity column (724e68b) and the no-backfill decision
(c48294b). This is the wiring that gives the operator a switch.

Settings, all profile-scoped, because each profile can point at its own logbook:
the folder, this PC's name, the machine id minted once from it, the per-peer read
offsets and the tie-break counter. Scoping the machine id is what keeps two
profiles sharing one folder from writing two logbooks into one file.

Three hooks. Add and update publish asynchronously, down with the rest of the
after-the-fact work — a folder on a network share can block for seconds and a
contact belongs on screen long before another machine hears about it. Deletion
publishes SYNCHRONOUSLY and BEFORE the row goes, for the same reason
deleteRemoteCopies does: once it is gone its identity is gone with it and the
tombstone names nothing.

The apply path uses the repository directly and never AddQSO/UpdateQSO/DeleteQSO
— those publish, and a change applied here would be written straight back out,
two machines echoing each other for ever.

Saving writes a probe file to the chosen folder rather than asking whether it
exists. A read-only cloud folder, or a share whose credentials expired, exists
perfectly well and would swallow every contact in silence; if the probe fails the
switch goes back off instead of sitting on while nothing is written.

The panel is mostly status, and deliberately: every part of this runs on another
machine and on a sync client OpsLog cannot see, so "it is not working" has to be
answerable from the settings page — which PCs are in the folder, when each last
logged, what is waiting unread.

Four tests on the apply path, the middle two being the ones that matter: an edit
made on the other PC lands on the copy already here, matched on the contact
itself, instead of becoming a second row — that is what makes the no-backfill
decision safe — and a contact with the same station on another band stays a
separate contact.
2026-08-17 01:34:00 +02:00
rouggy 93732ee563 fix(entity): show the QSO's own entity, and date the ClubLog exception
Two faults behind one screenshot: 3Y0K, recorded in the log as Bouvet Island,
showing the Antarctica matrix.

SELECTING A QSO now shows what that QSO records. The panel asked WorkedBefore
with no DXCC hint, and a hint of zero makes the backend resolve the entity from
cty.dat and the ClubLog exceptions AS THEY ARE TODAY — right for a live contact,
wrong for one being looked at in the log. The repo's own "infer from past QSOs"
path never ran, because the hint was no longer zero by the time it got there.
Hence Antarctica, and "5 QSOs with this entity" against "11 with this call":
two different entities, one of them nobody had asked about. The selected row's
dxcc now travels with it and is passed as the hint.

Browsing the log shows what the log says, even where the log is wrong.
Correcting an entity stays a deliberate act — right-click, Update from ClubLog.

BACK-ENTERING A QSO now resolves the exception at the CONTACT'S date. An
exception carries a validity window and a DXpedition's window closes: 3Y0K typed
months later, with the activation's own date in the form, was resolved against
today, matched nothing, and fell back to cty.dat. Both callers pass the date
they already hold — the entry strip's, and in the editor the record's own.

The date is trusted to move the resolution BACKWARDS only. A half-typed
"2026-0" must not send the lookup to the year 20, and a mistyped future date
must not resolve against a window that has not opened; both fall back to now.
Midday rather than midnight, because a window given in whole days is inclusive
of its end date and 00:00 sits exactly on the boundary.
2026-08-16 21:34:15 +02:00
rouggy 37805fe3ed perf(awards): build the logbook snapshot once, not once per caller
A field log showed three pulls of the same 123 615 QSOs inside ten seconds, the
Go heap going 725 MB → 2213 MB → 2539 MB. It is released after the idle TTL, so
not a leak — just the same work done three times with all three results alive at
once.

The cache lock was released before the logbook was read, so every caller that
arrived during a build missed the cache and started its own. Opening the Awards
panel does exactly that. One builder at a time now, with the usual re-check
after taking the lock: the second and third caller wait out the seconds they
were going to spend anyway, minus two round trips to the database.

The slice is also sized from the previous build. Growing to 123 000 structs by
doubling copies the whole thing a dozen times and holds the old and the new
array together at each step, on the largest object OpsLog keeps.

Also, the settings panel registry. The hook trap that broke the window when the
power-supply panel was opened was headed off by a comment, and the comment did
not survive contact with the next panel — so it is now structural. PanelHost is
a module-scope component that calls the selected panel and is keyed by section:
hooks inside a panel land in a component context that persists, section changes
remount it so each gets a fresh and consistent hook list, and no panel state
leaks into the next.

Rendering the panels as <Panel /> instead — the obvious refactor — would have
been wrong: they are nested inside SettingsModal and close over its state, so
each parent render makes a new component TYPE and React would unmount and
remount the panel on every keystroke.
2026-08-16 14:40:18 +02:00
rouggy 8683a450a7 feat(psu): switch a Modbus RTU bench supply from OpsLog
The manufacturer's document arrived, so this is no longer guesswork: 9600 8N1,
function codes 03 and 06 only, and a register map with the output on/off at
0x0001, the measurements at 0x0010…0x0013 and the set points at 0x0030/0x0031.

ONE REGISTER IS WRITTEN — 0x0001, the output. The map also exposes the voltage
and current set points and the three protection trip levels as writable, and
none of them belong to a logbook: a wrong value there is 30 V where a radio
expected 13.8, or a trip level lifted on a supply feeding an amplifier. They are
read and displayed instead, next to the measured values, which is also how an
operator sees at a glance that the supply is on and the radio is drawing nothing.

The wire layer is tested where it can be. CRC-16/MODBUS is pinned against its
published check value — the CRC of "123456789" is 0x4B37 — which fixes the
polynomial, the initial value, the reflection and the absence of a final xor all
at once; the rest of the protocol is checked frame by frame against the manual,
including the byte order of the CRC, an exception reply told apart from a broken
line, and a reply from another slave on the bus refused. The write echo must
match the value sent: it is the only confirmation the output really switched,
and accepting the frame without it is how a radio ends up dark behind a green
light.

Framing follows the manual's own rules: 3.5 character times of silence between
frames (4 ms at 9600), and a frame is over when the line falls quiet — Modbus
RTU has no terminator, and a serial read that times out returns (0, nil) here,
so the reader is built on a deadline and a quiet-time rather than on an error
that never comes.

Untested against hardware — nobody here has the supply.
2026-08-16 13:15:55 +02:00
rouggy 423cf1f998 feat(relays): follow the entry Band selector when there is no CAT link
Relay automatic control and the band-change outbound rows both hang off the rig
state, which is right when there IS a rig: changing Band in the entry strip
pushes a QSY, the new state comes back through the CAT callback, and the relays
follow from there — which is why this works for anyone with rig control.

Without a CAT connection nothing is pushed and nothing comes back, so a station
whose rig OpsLog does not control changed band and the antenna switch sat
exactly where it was. Reported as automatic control not working; it was never
told the band had changed.

The entry selector now says so itself, but only when the CAT push did not
happen, and never when the band or frequency lock is on: a lock means the entry
is deliberately decoupled from the rig, and moving an antenna to match a contact
logged from last year is worse than doing nothing.

The frequency is passed as unknown on that path — a band selector gives a band
and nothing else, and rules written on a frequency RANGE are left alone rather
than evaluated against a made-up dial reading.

The de-duplication of "is this a new band" is now shared by both sources, so a
station that has both does not command its switch twice for one QSY.

Also: the same credit line as the QSL e-mail now closes the default recording
e-mail body, on the same terms — a default in the template, so a stored one is
untouched.
2026-08-16 11:15:56 +02:00