Commit Graph
184 Commits
Author SHA1 Message Date
rouggy d95c998081 feat(udp): speak WSJT-X to Logger32, and lighten Sahara
"sent 1153 bytes to 127.0.0.1:2250" and nothing in Logger32. Both true: the
line the operator screenshotted is titled "Setup additional WSJT/JTDX UDP
sockets", and those receivers take UDP LOGGING PACKETS — the WSJT-X v2
protocol. A bare ADIF record posted to them is dropped without a word.

So there is a new outbound service rather than a change to the existing one:
"WSJT-X logged QSO (Logger32)" sends the same ADIF wrapped in a Logged ADIF
datagram (magic 0xadbccbda, schema 2, type 12), defaulting to port 2250. The
plain-text row stays for JTAlert/GridTracker-style receivers, because the two
really are different wire formats and one row cannot be both.

The id string is "OpsLog", not "WSJT-X": a receiver uses it to tell instances
apart, and impersonating the real thing would make a second WSJT-X
indistinguishable from us.

Tested both ways — the header byte for byte, and a round trip back through our
own ParseWSJT, since that is the same decoding every receiver applies.

Sahara moves to the parchment an operator asked for: page #e6ded0, panels
#f3eee2, toolbars #ded5c4, terracotta #c4501e on the buttons and the focus
ring. Lighter and less saturated than the deep sand it was.
2026-08-14 17:51:31 +02:00
rouggy a9e3a159d9 feat(cluster): Chase New — a panel for what is new and audible here
PSK Reporter tells you what is actually being decoded in your region, which
is a larger set than what somebody chose to spot: nobody spots the FT8 caller
running ten watts from a rare square.

Almost all of it existed. The MQTT payload already carries frequency, mode,
transmitter and both grids; the watcher already drops any report collected
further than NearKm from the operator, which is exactly the question worth
asking — the station is being heard HERE, not in Japan; and with grid chasing
on the subscription is already every band, filtered at the broker by receiver
square, measured at 0.2 to 1.2 messages a second. This reads messages that
were arriving and being discarded.

"New" is not decided here. Every spot goes through ClusterSpotStatuses, the
same function the DX cluster grid uses and the same cached index, so the two
panels cannot drift apart the way the county columns did. Cost per message is
map lookups behind an option cached in an atomic, because the MQTT goroutine
must never wait on the settings store.

The option is its own, not nested under grid chasing: chasing squares and
chasing entities are different wants, and the feed now has three consumers,
any one of which brings it up and none of which cuts the others loose when it
goes down.

The panel says "digital modes only" in its footer. An empty list has to mean
"nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never
show a new entity on CW.
2026-08-14 15:20:18 +02:00
rouggy 7c781bf793 fix(spot): spot the references worth chasing, not the derived ones
The comment came out "SSB WAC EU WAZ 15 WPX HA5" — the QSO's materialised
award_refs hold both kinds, and I took them all. Those four are what every
reader of the spot derives from the callsign in their head. They spent the
whole 30-character comment saying nothing and buried SOTA HA/KM-018, the one
reference anyone would have acted on.

Computed awards are now filtered out with the same rule the QSO editor uses
(isComputedAwardField), which is why they are the ones it lists on the left
and not in its read-only panel. The field map moves from a ref to state as
well: a ref left the first spot of a session computing before GetAwardDefs
had answered.

Also opens 0.25.2, since 0.25.1 shipped before the spot work, and sets the
default mode list to SSB, CW, FT8, FT4, FT2, RTTY, PSK31, FM. AM and
DIGITALVOICE stay in the catalogue but are no longer selected on a fresh
install. FT2 joins the digital family everywhere its siblings are listed —
report list, RST defaults, Flex power class — so it gets dB reports rather
than the 59 an unknown mode falls back to.
2026-08-14 13:31:17 +02:00
rouggy 3ce930e9cc refactor(winkeyer): learn the slow boot instead of naming the keyer
The K3NG entry added an hour ago is gone. It named one clone among many —
WKmini, home-built Arduinos, unbranded boxes — for hardware that speaks
exactly the same protocol, and it was the only line in the engine list that
picked a boot delay rather than a protocol. An operator with an unlabelled
clone would have had to guess.

The delay is now learnt per port. The first connect finds out by failing the
quick attempt and succeeding on the slow one; that fact is written to a
global setting keyed by the port, and every connect afterwards goes straight
to the slow attempt. Global rather than per profile on purpose: which keyer
is plugged into COM3 belongs to the computer, and switching profiles for a
different rig does not change the keyer on the desk.

Two tests hold the contract from both sides — a slow keyer must be reported
as slow, and a keyer that answers at once must not be, or every K1EL connect
would inherit seconds it never needed.
2026-08-14 13:05:13 +02:00
rouggy aca4dc5678 feat(winkeyer): K3NG keyers, and wait out their reboot
The operator's keyer is a K3NG — an Arduino running an emulation of the
WinKeyer protocol — which changes the diagnosis and nearly broke it.

Checked against K3NG's own source before anything else, because yesterday's
handshake now FAILS a connect where it used to press on regardless. It is
safe: k3ng_keyer.ino implements admin echo (0x04) and echoes the byte back,
0x13 is a documented no-op, and OPTION_WINKEY_STRICT_HOST_OPEN — on by
default — ignores every byte except 0x00 before host open, so the resync
nulls are dropped harmlessly and the echo probe still gets through.

The real cause is in K3NG's options file, beside the feature itself:
"disabling Automatic Software Reset is highly recommended", and an option
to "discard errant serial port bytes at startup" for when it is not. On an
Arduino, DTR is wired to reset through a capacitor: opening the port reboots
the board into its bootloader. Ours spoke 400 ms later, to a keyer that was
not running yet.

So the retry now waits 2.5 s, which recovers it without an operator pressing
connect twice, and the engine list gains a K3NG entry that skips the doomed
fast attempt altogether. Everything else is identical to a K1EL — same
settings panel, same protocol.
2026-08-14 12:40:42 +02:00
rouggy 30143b01bf fix(counties): make the cluster and the entry panel agree on a US county
An operator asked why K1SEI showed "Middlesex" in Info (F2) and "Lower
Connecticut River Valley" in the cluster. Two sources: the entry panel has
a callbook answer, a spot carries only a callsign so the cluster derives one
from the FCC licence ZIP through GeoNames — and GeoNames has followed the
Census in replacing Connecticut's counties with the 2022 planning regions.
No award, callbook or log uses those, so every CT station matched nothing:
new county for ever, and counting toward nothing.

Measured against a full ULS import (1 556 444 US callsigns), 23 223 resolved
to a name the USA-CA reference does not contain. Three causes, three fixes:

  - Spelling. "City and County of San Francisco", "Baltimore (city)",
    "Nome (CA)", plus counties renamed since the award list was drawn
    (Kusilvak, Oglala Lakota, the Valdez-Cordova split) and Alaska's four
    "X City and Borough", whose reference codes read "JUNEAUCITYAND" because
    the county-type suffix strip eats the wrong end. Normalised in
    award.USCountyKey, which both sides already go through. 7 515 callsigns,
    no re-download needed.

  - Doña Ana, NM shipped into the reference as "NM/DO̱AANA" — mangled by a
    non-UTF-8 CSV line, a code nothing could ever produce, so that county was
    unwinnable and silent about it. Row repaired, cntygen now refuses such a
    line, and a test makes every one of the 3 102 references reproduce its own
    code from its own name.

  - Connecticut. A planning region is drawn from towns in several counties, so
    no name maps to a name — only the ZIP can resolve it. cmd/ctzipgen builds
    the table from the Census 2020 crosswalk, filling PO-box-only ZIPs from the
    nearest resolved centroid; all 11 ZIPs GeoNames still labels with a real
    county agree with the result. 15 037 callsigns, applied at import, so the
    store now carries a rules version and Settings says when a re-download is
    needed.

Alignment itself is the last piece: a spot now shows the county the station is
logged with when we have one, and falls back to the ZIP-derived county only for
stations never worked.
2026-08-14 00:42:27 +02:00
rouggy fbd62fda30 fix(appearance): make Sahara actually differ from Warm light
The sampled colours were Warm light's own (#faf6ea / #e8dfc9 / #ddd2b8) —
the annotated screenshot was that theme, and the request written on it was
"just a little darker". Keep the cream on panels and the entry strip, take
the page, tab bar, toolbars and table headers a shade deeper, and move the
accent from burnt orange to ochre so the two are told apart in the picker.
2026-08-14 00:07:50 +02:00
rouggy ab0db0ef3a feat(appearance): Sahara theme
Built from the three sands an operator sent: #faf6eb for panels, #e8dfca
for the page behind them, #ddd2ba for toolbars, table headers and rows.
The ordering is what makes it work — a panel reads as lifted off the page
and the log grid as settled into it, so rows stay scannable without extra
rules. Ink is a deep warm brown; pure black on sand glares.
2026-08-13 18:18:52 +02:00
rouggy fabd1becce fix(amp): couple a chosen GROUP, not every amplifier
The first version was a single switch meaning "command them all", and that is
wrong the moment a station has three: two SPE on a combiner and a PowerGenius
on another antenna would all go into OPERATE together, keying an amplifier that
has nothing to do with the pair.

It is now a set. Each amplifier is ticked into the group or not, the group is
stored as a list of ids, and an amplifier outside it keeps its own buttons —
which is the entire point of it being a set.

A group of fewer than two members is stored as none: one amplifier coupled to
itself would make every command fan out to a single member for ever.

A remembered member that is no longer running is skipped rather than failing
the command — deleting one amplifier must not break the button on the other.

An amplifier saved without an id cannot join, and the panel says so instead of
quietly omitting it from the list.
2026-08-13 15:24:43 +02:00
rouggy 345be94c65 fix(amp): put the coupling switch where it can be seen
It sat above the first amplifier card. With three cards configured — the case
it exists for — that is a scroll away from anything the operator is looking at,
and it was reported as missing rather than misplaced.

Moved next to Add amplifier: coupling belongs to the SET of amplifiers, not to
any one of them, so it belongs with the other set-level control.
2026-08-13 15:03:46 +02:00
rouggy d0c6e420d2 feat(amp): command linked amplifiers together for a combiner
The SPE CO1-2 combiner is an RF device: it sums two amplifiers and commands
nothing. So "combined" operation is really two amplifiers that must be held in
the same state, and one left in STANDBY while the other keys means the combiner
sees power on a single input.

A switch in Settings → Amplifier makes ON, OFF and OPERATE act on every
configured amplifier. Offered only with two or more: coupling one amplifier to
itself is a switch that cannot do anything.

The METERS stay per amplifier, deliberately. Two amps combined are still two
amps, and an operator watching for one of them to run away needs to see them
apart — a summed bar would hide exactly the fault worth catching.

Fanned out in AmpOperate/AmpPower rather than in the UI: the card and the docked
widget both call these, and a coupling built into one would be missing from the
other, which on a combiner means one amplifier keyed and one not. The clicked
amplifier goes first, so a partial failure still did what the operator asked
before it stopped.

While linked, an amplifier with no power command — a PGXL on its direct link —
is skipped silently rather than reported: it would make a successful pair look
broken.
2026-08-13 14:02:34 +02:00
rouggy eba54344b0 feat(hrdlog): ON AIR — publish the live frequency, mode and rig
Puts "F4BPO is on air 14,074,000 FT8 IC-7300" on hrdlog.net's front page while
the station is operating, which is what HRDLog's own "Automatic HRDLog ON AIR"
setting does.

The endpoint and its field names come from HRDLog's own library
(github.com/iw1qlh/HRDLOG-net-library, HrdProtocol.SendOnAirAsync): OnAir.aspx,
with Frequency in Hz, Mode, Radio, Callsign, Code and App. Read rather than
guessed — an invented endpoint would have produced a switch that silently did
nothing.

HTTPS where that library uses plain HTTP: the upload code is a credential and
has no business crossing the network in clear, and the sibling NewEntry
endpoint on the same host already serves TLS.

A heartbeat every two minutes, not a change notification — HRDLog drops a
station from the list when it stops hearing from it. Silent when switched off,
unconfigured, or when the rig reports no frequency: announcing 0 Hz would say
the operator is on air on nothing. Failures are logged, never toasted; a
broadcast the operator did not ask for at that instant must not interrupt them
every two minutes on a flaky link.

Its own switch beside auto-upload rather than folded into it: this is a live
status and needs the rig readable, not a QSO.
2026-08-13 12:03:51 +02:00
rouggy 8e49d37cbd feat(lookup): optional QRZ nickname as the logged name
QRZ publishes <nickname> — the name an operator goes BY on the air — and
OpsLog was composing fname + name instead. "Bob" is what belongs in a log;
"Robert J Smith" is what belongs on a licence.

QRZ only, and deliberately so: HamQTH's <nick> already fills the Name field
that way, so the same switch there would toggle a behaviour it has no way to
turn off.

A published nickname is optional, so an empty one falls through to the
registered name. That is the whole point of it being a fallback rather than a
swap, and it is what the test pins — a blank nickname must never blank the
name.
2026-08-13 10:49:52 +02:00
rouggy b2382a6135 feat(cluster): configurable spot lifetime
Spots were bounded by COUNT alone, so on a quiet band a two-hour-old spot sat
on the band map looking like something to chase. Settings → DX Cluster now
takes a lifetime: presets at 5/10/15/30/60 minutes, or any value up to twelve
hours, and 0 keeps the old behaviour.

Spots are REMOVED from the shared list rather than hidden at render. The
cluster list, every band map and the counts all read that one array, so pruning
it once is what makes the setting mean the same thing in every view — the
lesson already paid for when the band map ignored the cluster's filters. It
also gives the memory back.

A spot whose timestamp will not parse is never dropped: an unreadable stamp is
a reason to distrust the clock, not to throw away the spot.

Swept every 30 seconds — close enough that a 5-minute setting is honoured, and
invisible next to the spot stream. Clamped in the backend as well as the UI.
2026-08-13 10:38:08 +02:00
rouggy 858c04d267 feat(appearance): colour log rows by QSL status, and fix UDP QSO numbering
New Settings → Appearance section: whole-row colouring in the log grid driven
by QSL / LoTW state, each rule with its own colour from a palette or a free
picker. What Logger32 does, with one difference that matters — the colour is
applied as a 24% tint, not a fill. Logger32's grid is white; this one is dark,
and a saturated user-picked colour behind white text is unreadable at exactly
the moment the operator is scanning for what still needs sending.

Rules are ORDERED and the first match wins, because a contact is usually
several of these at once: one confirmed on LoTW and by card is confirmed, not
"sent, awaiting reply". The order lives in the data so the panel can show the
rules in the order they actually apply, numbered.

The colour is interpolated into a CSS color-mix(), so anything that is not
plainly #rrggbb is refused on the way in and falls back to the default.

Also fixes the QSO number column, which was empty for contacts logged from
WSJT-X: that path inserts through the repo directly and never reached AddQSO.
Rather than chase each of the remaining bulk-insert paths — the POTA hunter
import and the LoTW/QRZ "add what I was missing" passes, which insert OLD
dates and so shift every number after them — the index now checks its length
against a COUNT and rebuilds when they disagree. One indexed count beats
remembering to invalidate in a place that does not exist yet.
2026-08-13 01:06:21 +02:00
rouggy 7d33379fe1 feat(cluster): feed the locator store from PSK Reporter, filtered at the broker
The store shipped without its main source. Locators came only from this
station's own WSJT-X decodes, which is what the whole MQTT discussion was
about.

PSK Reporter now feeds it through a new OnGrid callback, fired before any
geographic filtering: what the store wants is "which square is this callsign
in", and that is true whoever happened to hear the report.

The subscription filters on the RECEIVER's square, a level the v2 topic
exposes. Measured on the live feed: the four opening bands unfiltered are 83
messages a second, of which roughly one in a hundred survived the NearKm test
that already existed here — the rest was received, TLS-decrypted, JSON-parsed
and discarded. One ring of squares is 0.2 to 1.2 a second.

By square rather than by DXCC, which was the obvious alternative: one country
measured 1.2 messages a second (OH) against 72.5 (K) on a single band, because
a DXCC can be a continent. By square the same measurement is 0.2 to 1.2, so the
load follows distance — what the feed is actually about — and is the same for
every operator.

Grid chasing subscribes with the "+" band wildcard, so one subscription per
square covers every band instead of one per band per square.

The store gains a source column (decode | mqtt), migrated in place on an
existing file.
2026-08-12 17:16:47 +02:00
rouggy 82a49150d2 feat(cluster): persist learnt locators behind a "Chase new grids" option
The callsign->grid map died with the process. Every restart began with an empty
Locator column that took an hour of listening to refill, and everything learnt
the day before was thrown away.

internal/gridcache is its own SQLite file in the data directory, not a table in
the settings database: that one sits wherever the operator chose to put it,
often a synchronised folder, and a store that rewrites itself every minute has
no business there. Deleting the file costs a few days of listening and nothing
else.

Callsign is the primary key and the newest report wins — operators move, go
portable, go on expedition, and a stale locator is worse than none for grid
chasing because it reads as a square already worked. Entries not seen in two
years are pruned at open: that is the one way this cache can be actively wrong
rather than merely empty, and it is what bounds a store that would otherwise
only grow.

Only CHANGES are queued. The feeds repeat themselves, so writing every report
would put the whole stream in the batch instead of the news in it. Batches
flush on a timer and on shutdown — a restart is exactly when the cache is worth
the most.

Rotation is switched off while the store is attached: the cap would discard
callsigns the database still holds and the lookup would then miss what we know.
Age in the store is the bound instead.
2026-08-12 17:01:05 +02:00
rouggy 1b32b1ddec feat(antenna): per-band tune frequency for Ultrabeam and SteppIR
The band buttons in Station Control tuned to a fixed mid-band frequency baked
into the frontend. An operator who lives in the CW segment, or in the FT8
window, got the antenna resonant somewhere he never operates and had to nudge
it every time. The SteppIR's own controller has a Frequency (KHz) column per
band for exactly this; this is that column.

Stored sparsely: a band with no entry uses its default, so nothing migrates
and an operator sets only the bands he cares about. Left empty the Settings
box shows the default as its placeholder, which makes clearing it the obvious
way back.

The value is checked against the band plan before it is kept, because it goes
to the antenna as a tune command — a lost digit (1450 for 20 m) or kHz typed
as MHz would send the elements travelling to a length wrong for the band the
operator is on, and on a SteppIR that journey inhibits transmit the whole way.
A rejected entry is logged and the band falls back to its default.

Resolution happens in the backend and rides on the existing status poll, so
the widget no longer decides where a band button goes and cannot drift from
what Settings shows. Its own table stays only as a floor for the first poll.
2026-08-12 08:58:01 +02:00
rouggy 65bbaa85f3 feat(antenna): three tracking modes for Ultrabeam and SteppIR
The follow loop only ever had one behaviour — re-tune once the rig moved
further than a fixed step. The SteppIR's own controller software offers three,
and operators arrive with that mental model: every frequency change, past a
threshold, or only on a band change. They are real operating trade-offs, not
preferences: "always" keeps resonance perfect at the cost of motors running
constantly (and on a SteppIR every move inhibits transmit while the elements
travel), "band" moves them a handful of times a day.

"Always" is implemented as the threshold mode with a 1 kHz threshold rather
than as a separate branch, so all modes keep the one deadband reference that
matters: the rig frequency last commanded for, NOT the antenna's own reported
frequency — a SteppIR flips its reported frequency between the commanded value
and its home value, which would re-issue a SET on nearly every poll and leave
the operator permanently unable to transmit.

Band mode compares against the band last COMMANDED for, not the rig's previous
band, so the first move after startup and any move made by hand still get
reconciled. It applies to the immediate spot-click path too: a click inside the
band the antenna is already resonant in moves nothing.

An unset or unrecognised mode resolves to the step mode, so every config
written before this option behaves exactly as it did.

Also routes the antenna settings block through t() — it was hardcoded English.
2026-08-12 08:31:44 +02:00
rouggy 75a2f73992 fix(distance): compute it where QSOs are made, not in a settings button
The button was the wrong shape twice over. A maintenance chore does not belong
beside the options an operator actually chooses, and nobody should have to be
told their log is missing a field before it gets filled in.

So the distance is computed where QSOs come from: on the logging path and on the
ADIF import. fillDistance is its own function rather than part of
applyStationDefaults, because the import only applies those when the operator
ticks a box — and a distance is not a station default. It is derived from the
QSO's own two grids and is true whatever was chosen about profile fields.

What is already in the log is handled by a one-time migration at startup,
recorded by a settings key. In the background: on a large log over a remote
MySQL that is thousands of row updates and startup must not wait for a tidy-up.
Marked done only on SUCCESS, so a run cut short by a closed program tries again
next time rather than leaving half the log filled for ever.

An imported value still wins. It came from the log that made the contact, which
knew the real positions rather than two four-character squares.
2026-08-11 20:14:27 +02:00
rouggy 92a5f30ac0 feat(db): fill in the distances nothing ever recorded
The published page derives a distance as it renders, which fixed the empty
column there but not the cause: the field is empty in the database, so it goes
out empty in every ADIF export and leaves the same gap in whoever imports it.

BackfillDistances computes it from the two locators for QSOs that have both.
Only where it is EMPTY: a stored distance came from the log that recorded the
contact, which knew the real positions, and two four-character squares are a
worse answer that must not overwrite a better one. Whole kilometres, for the
same reason the published column is.

In the Database panel rather than beside the county backfill, which lives under
US Counties: this one touches the whole logbook, whatever country the QSOs are in.
2026-08-11 19:49:03 +02:00
rouggy 8afba2c4e8 feat(bandopen): read PSK Reporter, and arrange the sources the watch needs
The detection shipped reading whatever the operator's cluster nodes happened to
carry. On VHF that is a few hundred skimmers, nearly all of them on HF: a 6 m
opening carrying 869 stations reached OpsLog as a handful of spots or none, and
Nexus flagged it on the same PC while OpsLog stayed silent.

internal/pskr subscribes to pskr/filter/v2/<band>/# on PSK Reporter's MQTT
broker. Every ordinary station running WSJT-X reports what it decodes, so the
difference is two orders of magnitude rather than a threshold. The feed's shape
suits us exactly: BOTH grids are in each message, so distance and bearing are
arithmetic — no lookup, and no DXCC-centre approximation, which is what made the
cluster path's bearings coarse. The geometry is injected from app.go so it stays
the same arithmetic the cluster path uses; two answers to one question is how a
bearing quietly becomes wrong.

Volume was the design constraint, not the protocol. Six metres open is thousands
of messages a minute and this runs on some very old PCs, so nothing is kept or
persisted in the watcher: each message is parsed, measured and handed on or
dropped, and the detector's existing window does the deciding.

And the part that was the real bug: enabling the watch now ARRANGES ITS OWN
SOURCES. It adds the two RBN nodes when missing and brings the feed up. A
feature that silently depends on sources nobody can know are needed does not
look unconfigured, it looks broken. Matched on host and port, not name, so an
operator who renamed theirs does not get a duplicate — which the detector would
read as twice as many stations, and announce an opening that is not there.

Turning it off leaves the nodes alone: they may have been wanted for their own
sake, and removing a node someone is using is worse than leaving one they are not.
2026-08-11 12:48:29 +02:00
rouggy ae7472a67d feat(station): MY_IOTA in the station profile
IOTA is an official ADIF field, and almost everything for it was already here:
the qso table has carried my_iota since the first migration, the insert and scan
handle it, and both ADIF import and export write it. The one missing link was
the station profile, so the reference had to be typed on every contact of an
island activation, or added afterwards in a bulk edit.

Migration 0025 adds the column; it joins my_sota_ref and my_pota_ref in the
profile, in the Station Information panel, and in the same stamp-if-empty block
that fills the other My* fields on a logged QSO.

Uppercased and trimmed on save. ADIF spells it EU-005, and an operator typing
"eu-005" would otherwise put a reference no award matcher recognises on every
QSO of an activation - the kind of mistake you only find months later.
2026-08-10 19:28:07 +02:00
rouggy 9d8b69d804 feat(cluster): mark the cell that carries the fact, and add the US county
Colour moves from the text to the CELL. A filled Band cell means new band, a
filled Pfx cell means new prefix, a filled County cell means new county. This is
not the pills coming back: a pill is a box inside the cell with its own height
and padding, so it pushed the text off the row baseline. A background has no
geometry - the text does not move a pixel - and it reads from across the room,
which a tinted glyph does not. Worked-call stays text-only: it is not a novelty,
and filling it would wash most of the rows.

The colours still come from the semantic tokens and lib/spotMarkers, so a fact
keeps one colour across the grid, the band map and the filter chips - and the
per-marker colour setting will drive all of it from one table.

US County column. The backend already resolved the county from the offline ULS
store to decide NewCounty and then threw it away; it now returns it, which costs
nothing.

The Locator column is renamed Spotter locator. It always held the SPOTTER's grid
- cluster.go says so - so a column labelled Locator next to a DX callsign was
reading as the DX's grid and was mostly empty besides. The DX grid is not in the
feed at any price worth paying under an RBN firehose.

Both display options move out of Preferences into the cluster filter panel,
beside Hide worked. They are changed while working a run, not set up once, and
a preferences dialog reopened every ten minutes is a filter in the wrong place.
2026-08-10 12:29:57 +02:00
rouggy a3815c24a1 feat(cluster): quiet the worked spots, light up the empty slots
Two options that change what the eye is pulled towards, both applying to the
cluster list AND the band map.

Mute worked before: a spot that brings nothing new loses its colour and its
badges. It stays in the list - the operator asked for less noise, not less
information. "Brings nothing new" reuses the dimming rule the cluster list
already had rather than inventing a second notion of done, so it keeps obeying
the same-slot option and the digital-mode grouping for free. A new-band or
new-slot status is NOT muted: having worked that callsign once on another band
says nothing about the band in front of you.

Highlight unworked in this slot: colours any callsign not yet worked on this
band and this mode, whatever the entity says. For an operator filling slots a
common entity on a fresh band+mode is the whole point, and the entity-level
status flatly calls it worked. It reuses the existing new-slot status, so no new
colour, no new legend, no new badge - both panels already knew how to draw it.

WorkedSlot is computed independently of the same-slot preference: it is what
this option reads, and it must not change meaning because a different option was
toggled. The slot index is now built when either option needs it, and the status
cache is keyed on both so a toggle invalidates it.

The rules live in one module used by both panels. Marker colours already taught
us what happens when the two derive the same thing separately.
2026-08-10 11:44:21 +02:00
rouggy da9c76e161 feat(icom): offer the bands the connected radio actually has
The band row was hardwired to 160-6 m, so an IC-9700 - the one radio in the range with no HF at all - showed ten dead buttons and none of 2 m, 70 cm or 23 cm. bandsFor(model) follows the model, exactly as attOptions already did for the attenuator steps. bandOfHz's labels had to move with it: it returned '70' where the button says '70cm', so the current-band highlight could never have matched, and it knew nothing of 23 cm.
2026-08-10 09:07:08 +02:00
rouggy 80dac64b56 feat(theme): four themes with an actual hue — Indigo, Ocean, Plum, Nordic
The seven existing themes are warm beige, cool grey, sage grey, slate, warm
dark, graphite and black — every one neutral, six of the seven accented orange.
Picking a theme changed the shade of grey and little else.

These colour the SURFACES, not just the accent, and each takes a different
primary so the picker tells them apart at a glance: violet on deep indigo, cyan
on deep teal, magenta on aubergine, indigo on a crisp cool white.

The semantic colours stay recognisable as themselves. A logger is read for
hours and "red means a problem" cannot become a decorative choice, so the hue
budget went on the surfaces and the primary. Where a theme's primary would have
collided with a meaning, the MEANING kept its identity and the ornament moved:
Ocean's info is blue rather than cyan, Plum's danger is red rather than rose,
and the matrix entity ramp shifts to cyan under Indigo and teal under Nordic so
a "confirmed" cell never reads as a button.

Everything else follows for free: the grids resolve var(--…) at runtime, so
they re-skin with no re-render. The three shared registrations that do NOT
follow automatically are done — the chart palette (light vs dark ramp) and the
date-picker icon inversion, which is keyed on an explicit list of dark themes.
2026-08-09 10:06:05 +02:00
rouggy 642ed358c2 feat(worked): fold portable callsigns into the worked-before history
Typing RK3DWA found nothing while RK3DWA/3 found 21 QSOs, so a station's
history was only visible if you happened to type the exact form it had been
logged under — and an operator who worked it as /0, /P or /MM saw none of it.
The other RDA tools and Log4OM fold these together; this does too.

The predicate strips the suffix from what was typed and matches "call = base OR
call LIKE base/%", so it works from either end: the base call finds the portable
QSOs and a portable call finds the plain ones. Deliberately not a bare prefix
LIKE 'RK3DWA%', which would also match RK3DWAB — a different station. The '/' is
what makes it the same operator.

Settings -> General to turn it off. Default ON, hence the inverted storage: an
existing install has no key, and reading that as OFF would leave everyone with
the behaviour we were asked to change.

Contest dupe checking is untouched — it runs through ContestDupe, a separate
binding, and stays an exact match as a contest requires.
2026-08-08 23:24:23 +02:00
rouggy 1f55cfe9fa feat(cluster): self-spot on the master node while logging
Settings -> DX Cluster: a toggle and an interval. When it is on, logging a QSO
announces the station on the master cluster — the QSO's own station callsign as
the DX, on the frequency the contact was made on — so callers find the run
without waiting for someone else to spot it. The node fills the DE field from
the login, so the spotter is us too: a self-spot.

It fires on the FIRST QSO of a frequency and then at most once per interval.
Both halves matter: announcing every QSO would flood the node and get the
station filtered out, while a pure timer would stay silent for minutes after a
band change. A drift of up to 500 Hz still counts as the same run, so nudging
the VFO mid-pileup does not re-announce.

Five minutes is the floor, clamped in SaveSelfSpotSettings as well as in the
input: the limit protects the node from us, so it must not depend on the
frontend. The interval input keeps raw text and clamps on blur — clamping per
keystroke rewrote "10" to "5" as soon as the "1" landed.

Wired into both log paths (manual entry and UDP auto-log) on the async side, so
a cluster that is slow or down never holds up logging. A send failure restores
the previous throttle state, so the next QSO retries instead of sitting out an
interval that produced no spot.
2026-08-08 20:34:10 +02:00
rouggy f4b7c9dcf8 fix(settings): restore the settings-database buttons and unstack the backup options
The Settings -> Database panel had lost every action on the settings
database except "Open folder", which launches Windows Explorer — where a
.db cannot be selected at all, so double-clicking it only asks which
program should open it. The handlers were still there, just no longer
rendered; New / Open existing / Save a copy / Rename / Reset are back,
along with the restart banner (the DB pointer is only read at startup).

The backup block put the rotation field and three checkboxes on one flex
row inside max-w-xl. That fitted until "keep every backup" arrived with
its two-line hint, after which every label wrapped into an unreadable
sliver. The options are stacked now, and keep-all sits under the option
it depends on.
2026-08-08 17:36:31 +02:00
rouggy 9c757e5175 feat(cat): distinct Elecraft K3/K4 backend in the rig list
The Elecraft handling was buried in the Kenwood "data mode" option, so users
couldn't find it. Add "Elecraft K3/K4" as its own entry in the CAT backend
selector. It reuses the Kenwood-dialect transport and the Kenwood USB/network
settings (the K3 emulates the Kenwood command set — a separate transport would be
a near-total duplicate), with an `elecraft` flag on the client that forces
digital modes to DATA A (MD6+DT0) — no data-mode dropdown needed. RigState still
reports Backend "kenwood" so the CW-over-CAT keyer capability keeps working.
2026-08-08 14:15:55 +02:00
rouggy 949cc17ed1 feat(backup): "keep every backup" option — timestamped files per exit
Sub-option of "back up on every exit": when on, backup.Run/RunADIF stamp the
filename with the time (opslog-YYYY-MM-DD-HHMMSS.db) so each run is a distinct
file instead of overwriting the day's snapshot. Rotation still trims to the
newest N (raise Rotation for a longer history). Threaded as a `unique` flag
through runConfiguredBackup/backupLogADIF from BackupSettings.KeepAll
(keyBackupKeepAll); UI sub-checkbox shown only when EveryExit is on.
2026-08-07 12:28:52 +02:00
rouggy 7d7d1042c0 feat(qsl): OpsLog QSL received marker + PSE/TNX card stamp + default QSL message
Received flag: new APP_OPSLOG_QSL_RCVD extra, toggled on the QSO edit window
next to QSL Message (immediate targeted write via SetOpsLogQSLReceived so it sets
AND clears reliably), plus a live PSE QSL / TNX indicator and a Recent-QSOs
column mirroring the sent one.

PSE/TNX card stamp: automatic — received → TNX, otherwise PSE QSL — exposed as
the {qso.pse_tnx} token (added to qslVars, so preview and send agree) and placed
in the default QSO-box footer; it can be moved to its own element in the designer.

Default QSL message: new qsl.default_message (QSLEmailTemplates.DefaultMessage),
edited under Settings → E-mail → QSL card e-mail. qslVars falls back to it when
the QSO's own QSLMSG is empty, so a per-QSO message always wins. Single choke
point covers both live preview and send.
2026-08-07 10:36:57 +02:00
rouggy 5338ad0b29 feat(backup): option to back up on every exit, not just once a day
New BackupSettings.EveryExit (keyBackupEveryExit) with a Settings → Backup
checkbox. When on, the three shutdown-backup gates (plannedShutdownSteps,
runBackupForShutdown, maybeShutdownBackup) bypass the HasBackupToday /
HasADIFBackupToday "already done today" check and always run, so a second
session's QSOs are captured instead of skipped until the next day. The dated
backup file for today is refreshed (overwritten) each exit; prior days are still
kept by rotation.
2026-08-07 09:46:23 +02:00
rouggy 75d11a4069 feat(email): editable subject/body for the QSO recording e-mail
The recording e-mail's subject and body round-tripped through EmailSettings and
the backend (keyEmailSubject/keyEmailBody) but had no UI editor — only the QSL
card e-mail did, so the recording mail was stuck on the default text. Add the
same subject/body editor to the E-mail panel, above the QSL block, sharing the
{CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} template variables.
2026-08-06 18:14:24 +02:00
rouggy 4c1b4fd1a8 ui(awards): keep the Followed column sorted alphabetically like Available 2026-08-06 17:36:09 +02:00
rouggy f2cc4f11cd fix(awards): stop the follow-list flicker/lost clicks (inline rows, no nested component)
The Row helper was defined inside AwardsSelectionPanel, so each render minted a
new component type and React remounted the whole list on every click — flicker,
and clicks lost between mousedown and click (hence needing many tries). Inline
the row markup so the keyed buttons keep a stable identity.
2026-08-06 17:32:25 +02:00
rouggy faf084ddfc feat(awards): follow-list picker in Settings → Awards, filter the Awards tab
New per-profile "tracked awards" selection: Settings → Awards (under User
configuration) is a two-column transfer list — every defined award on the left,
the ones you follow on the right, click to move either way. The Awards tab's
list is narrowed to the followed set; an empty set means "show them all" so the
tab is never blank.

Backend: app_awards_tracked.go adds keyAwardsTracked (per-profile JSON array of
award codes) with GetTrackedAwards/SaveTrackedAwards; saving emits
awards:tracked-changed so the Awards tab re-filters live. Award definitions stay
global — only the follow selection is per profile.
2026-08-06 17:29:00 +02:00
rouggy a10fb2413a ui(antenna): drop the Covered bands help paragraph 2026-08-06 16:12:46 +02:00
rouggy bd8ca2e9de feat(motor-antenna): band selector for the follow filter (replaces min/max range)
A contiguous FreqMin/FreqMax range can't drop a single band while keeping its
neighbours — so it couldn't express "40 m and 20 m yes, 30 m no" for an antenna
whose 30 m extension isn't fitted. Replace it with a Covered bands selector
(40 m–6 m) that applies to both the Ultrabeam and the SteppIR.

The follow loop and immediate re-tune now gate on band membership
(motorBandAllowed) instead of a MHz range: only bands in the operator's set are
followed, and 80 m/160 m are never followed (outside a beam's reach) regardless.
The legacy FreqMin/FreqMax is migrated to a band set on load and still
round-trips, so existing configs are unchanged. UI swaps the two number inputs
for a row of toggle chips.
2026-08-06 16:07:42 +02:00
rouggy df56391eb8 fix(motor-antenna): honor the configured tunable range for the Ultrabeam
The follow logic skips out-of-range frequencies only when it knows BOTH edges
of the antenna's range. The Ultrabeam path took those edges from the
controller's status frame, but an RCU-01 behind an RS232-to-Ethernet bridge
answers with the short frame that omits FreqMax → the guard saw FreqMax==0 and
disabled itself → OpsLog forwarded every rig frequency, so 80 m (un-tunable)
reached the controller, which clamped the elements down to its lowest band
(~30 m).

ubAdapter now carries the operator-configured FreqMin/FreqMax from Settings →
Antenna (like steppirAdapter already did) and uses them as a hard floor/ceiling,
falling back to the controller-reported bound only when one is left unset. This
guarantees both bounds are present so the guard actually engages. The Tunable
range editor in the Antenna panel, previously SteppIR-only, is now shown for the
Ultrabeam too, with a generic bilingual hint.
2026-08-06 11:02:14 +02:00
rouggy 82ce3353c4 feat(rotator): add Hy-Gain DCU-1 backend (RotorCard DXA, Rotor-EZ, Green Heron)
New internal/rotator/dcu1 client speaking the DCU-1 command set (AP1nnn / AM1
to go-to, AI1 to read bearing), over a serial COM port (4800 baud default) or a
raw TCP serial-over-IP bridge. Azimuth-only; Stop re-commands the current
bearing since the base set has no stop opcode.

Wired through app.go (normRotorType, default port 4001, GoTo/Heading/Stop/test
switches, park returns the same "PstRotator only" note as ARCO) and exposed in
the rotor settings as "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)"
with the ARCO's serial/TCP transport chooser and a bilingual hint. Protocol is
implemented from the standard DCU-1 spec and still needs field confirmation
against a real RotorCard DXA.
2026-08-06 10:40:36 +02:00
rouggy d0f25aea9b feat(cluster): "already worked only on the same slot" option
New DX Cluster setting (Settings → DX Cluster). Off (today's behaviour) a call
worked anywhere reads as already worked; on, the WORKED-call flag needs the same
band AND mode. It folds through the digital-mode grouping (Settings → General):
grouped, a 20m FT8 contact also marks a 20m FT4 spot worked; ungrouped they are
separate slots. Backend: qso.WorkedCallSlotKeys builds CALL|band and
CALL|band|mode(grouped) keys; ClusterSpotStatuses uses them when the option is on
(a spot with no inferable mode falls back to same-band).
2026-08-05 14:11:06 +02:00
rouggy 61c6666a4c ui(cat): trim verbose Kenwood/CAT-sharing help text
Removed the long explanatory hints under the Kenwood COM/baud/host, data-mode,
CAT-trace and PTT-hotkey fields; shortened the CAT-sharing hint to just the
client setup line; and replaced the DTR/RTS hint with a one-liner. Less clutter
in the CAT settings.
2026-08-05 10:17:54 +02:00
rouggy 95482ec1d8 feat(cat): per-rig Kenwood/Elecraft data-mode choice (USB / MD6 / keep)
No single Kenwood mode digit fits every rig for a data mode: a K3/K4 wants
DATA (MD6), a TS-590SG/TS-990S data mode is a USB modifier set on the rig,
and MD6 on a plain Kenwood is FSK/RTTY. So the operator now chooses in
Settings → CAT: USB (default), DATA (MD6), or leave the rig's mode unchanged.

Only soundcard/data modes (FT8, PSK, JT…) follow this; RTTY/FSK stays its own
native mode, and voice/CW are untouched (isKenwoodDataMode + test).
2026-08-04 18:14:26 +02:00
rouggy 87ff0a9e16 chore: release v0.23.3 2026-08-04 16:46:32 +02:00
rouggy 18f9b915c5 fix: PGXL meters/auth/fan, TCI spots+click, PTT-over-CAT, CW <LOGQSO>
- PGXL: remote AUTH password; direct-link meter fallback (VITA-first) with
  250 ms poll + peak-hold; fan mode uses bare "fanmode=" (was ignored).
- TCI: spot colour sent as decimal ARGB (ExpertSDR dropped the hex string);
  spot click handled via CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT to fill the entry.
- FlexRadio: clicking an OpsLog spot on the panadapter now applies the mode too.
- Filter presets: the trash icon deletes again (Radix pointer-down intercept).
- PTT hotkey: keys over CAT when a backend is active (was hijacked by a stale
  Audio-tab RTS/DTR serial setting); AltGr keys allowed; presses logged.
- CW macro <LOGQSO>: logs after the preceding CW is sent, not on the first letter.
2026-08-04 15:55:10 +02:00
rouggy b3fdd0b7fa feat(rotator): multiple rotors of any type, plus a CAT PTT hotkey
Settings -> Rotator is now a list like the amplifiers: add/remove rotors,
mix PstRotator / Rotator Genius / ARCO, and a Rotator Genius can drive both
its ports (one entry = two rotors). The compass gains a rotor selector, and
a per-rotor motorized-antenna flag so the boom/pattern paths show only for
the rotor carrying the Ultrabeam/SteppIR.

Also in this batch: a keyboard PTT hotkey (hold-to-talk or toggle, reusing
the audio PTT method with a CAT fallback), and TCI spot push to the
panadapter with click-to-fill of the callsign.
2026-08-04 09:01:21 +02:00
rouggy 72f692aa04 chore: release v0.23.0 2026-08-02 20:03:16 +02:00
rouggy 296a4a55c0 refactor(cat): one DTR/RTS setting per backend, none shared
The 0.22.7/0.22.8 breakage came from a Xiegu-reported fault being fixed
inside the Yaesu and Kenwood backends. The lesson is structural: nothing
a backend does may be steered by another backend's report or another
backend's setting.

The lower-lines checkbox introduced for 0.22.9 was still one shared key
applied to both Yaesu and Kenwood — the same reflex in miniature. It is
now cat.yaesu.low_dtr_rts and cat.kenwood.low_dtr_rts, each checkbox in
its backend's own settings block, each wired only to its backend. The
shared key never shipped, so nothing needs migrating.
2026-08-01 20:35:57 +02:00