Commit Graph
337 Commits
Author SHA1 Message Date
rouggy 26b8dade20 feat(relays): generic HTTP relay board, and point band switching at it
An operator was about to build band→relay mapping into the custom URL rows.
It already exists, and better: relay auto-control has a per-relay "band" rule
driving webswitch, KMTronic, Dingtian, Denkovi and USB boards. It holds state
so a relay is only commanded when its wanted position changed, reads the
boards live before the first apply so it does not re-command one already in
place, and carries per-relay labels. A URL fired on a band change has none of
that, and would have been a second definition of "which antenna on which band"
— the drift that cost us a week on counties.

So the gap was only the hardware: a hand-made switch is none of the five named
types. It is now the sixth, "HTTP relay": an ON URL and an OFF URL, either as
one pattern with {relay} substituted, or one pair per relay. The per-relay
form is the reason it exists — these boxes often have URLs with nothing in
common between channels (…/FF0101 and …/FF0201), which no pattern can express.

Status is remembered rather than read: most have no endpoint worth trusting.
The cost is stated in the code and the panel — after a restart every relay is
re-commanded once, which is harmless on a board with no memory and far better
than assuming an antenna is already selected.

And the Connections panel now says so, exactly where the wrong choice is made:
picking a band change with a URL transport shows a note pointing at Station
Control. The URL transport stays — a lookup pushed to a webhook or a QSO to a
dashboard is an event, not a state, and Station Control has no place for it.
2026-08-15 02:23:03 +02:00
rouggy 6c2c1f106a feat(udp): custom outbound messages — a trigger, a template, UDP or a URL
The hard-coded emitters each speak one published format at one fixed moment.
This is the escape hatch, and it exists mainly for antenna switches: they are
driven by a URL, and the band change is the trigger they want.

Four triggers, each carrying its own field set: band change (the RADIO's band,
not the entry form — a switch follows the rig, not what is being typed), QSO
logged, rotator command and callsign lookup. The panel prints the fields the
selected trigger can fill, which is the point of the whole thing: a placeholder
the trigger does not carry renders as nothing, and without the list an operator
writes {freq} on a band change and has no way to learn why the switch never
moved.

Three things the panel cannot show, handled here:

  - Escaping. A URL needs every value percent-encoded; a UDP payload must not
    be touched. The first portable callsign, F4BPO/P, puts a path separator in
    the middle of a query string otherwise — it works on the bench and fails on
    the air.
  - Blocking. An HTTP call to a switch that is unplugged would otherwise sit
    for the operating system's timeout, on the path of a band change. It runs
    in the background with a 3 s limit.
  - Silence. A switch answering 404 after a firmware update fails exactly like
    success looks from here, so the status code is logged, throttled per row.

The rotator trigger hooks the COMMAND rather than the SP/LP buttons, so the
compass and a spot click fire it too, and the path ("SP"/"LP") is passed
through because an azimuth alone cannot say which was taken — 137° is short
path to one station and long to another.

Credentials in a URL are stored as typed. That is the operator's call, on the
grounds that this is LAN gear, and the hint in the panel says so.
2026-08-15 01:39:16 +02:00
rouggy dc14990b44 fix(udp): send the structured QSO Logged too, not only the ADIF one
The ADIF datagram reached Logger32's socket and still nothing appeared in its
log. WSJT-X sends TWO messages for every contact — QSO Logged (type 5), the
structured one, and Logged ADIF (type 12) — and receivers differ on which they
read: MacLoggerDX takes the structured one by default and offers the ADIF as
an option. We were sending only the half Logger32 does not use.

Both now go out on that row, in WSJT-X's own order. Nothing double-logs: any
receiver built for WSJT-X already sees both from the real thing.

Type 5 is read positionally, so the field order is pinned by a test against
NetworkMessage.hpp — one field out of place shifts every one after it and the
receiver files nonsense without complaining. QDateTime is Qt's own encoding:
Julian day, milliseconds since midnight, then the time spec, which is sent as
UTC (1) because a spec of 0 would have the receiver re-read the contact in its
own zone. The Julian arithmetic is checked against known values; a day out
there files every QSO on the wrong date.
2026-08-15 00:55:24 +02:00
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 7470ebf47e feat(qsl): set the QSL route per direction from the QSL Manager
The paper form had one "Via", which since the QSL_VIA split writes
QSL_SENT_VIA. QSL_RCVD_VIA had no way in at all outside the per-QSO editor —
a field with a column, an import and an export, and nothing to fill it.

Each direction now has its own route dropdown, sitting with the status and
date it belongs to: how the card was sent, beside Sent; how it came back,
beside Received. Both apply to the whole selection, which is the shape of the
job — a stack confirmed in one go usually arrived the same way, a bureau
delivery being exactly that, and doing it one QSO at a time in the editor was
the only option before.

Both store the ADIF enumeration (B/D/E), and neither touches QSL_VIA — that
holds the manager's callsign and belongs to the QSO, not to a stack of cards
being confirmed together.

The two columns were already available in the paper list: it renders through
RecentQSOsGrid, which gained them with the field itself.
2026-08-14 16:32:12 +02:00
rouggy 2cd1274975 fix(logging): say why the antenna stopped and what the ADIF port is receiving
Two problems from one operator's log, both of them the log's fault.

SteppIR. The last line about it is "status query failed, reconnecting: Port
has been closed" and then nothing — for the rest of the session. The poll loop
does retry every two seconds, but a failed open was a bare `continue`: no line,
ever. And startUltrabeam had three silent ways out — settings unreadable, the
antenna turned off, no port or host configured — so a cleared field and a lost
adapter produced exactly the same evidence, which is none. Both now say what
happened, with the port or host named. Reopen failures are reported three
times and then throttled (a port that is gone stays gone), and the recovery
says how many attempts it took.

FLDIGI. Four hundred "ADIF payload ignored" lines and nothing else legible.
The cause is in the same log: two inbound listeners on the SAME multicast
group and port, 239.255.0.1:2237 — one WSJT (MSHV), one ADIF (FLDIGI). Every
WSJT-X packet is delivered to both, and an FT8 cycle is dozens of decodes
every fifteen seconds; the bursts in the log are exactly 15 s apart. The ADIF
listener now checks for the WSJT-X magic and says so once, with what to do
about it, instead of rejecting each packet in writing. Anything else is
described a few times with its payload and then goes quiet.
2026-08-14 16:24:06 +02:00
rouggy 94cba2592e feat(chase-new): drop the frequency column, the beacons and the bands you don't have
Three cuts, all of them removing things nobody could act on.

The frequency column is gone. Clicking the row tunes the rig to it, so the
number was there to be read and never used. It is still carried on the spot —
the click is what needs it — and it is in the row's tooltip. The country takes
the space.

Modes are limited to FT8, FT4, FT2, PSK31 and RTTY. WSPR is the one worth
spelling out: it is a beacon, nobody answers a WSPR transmission, so a "new
entity on WSPR" is a path report and not a station to work — and on a quiet
band it would have been most of the list.

Bands are limited to the operator's own list. PSK Reporter carries every band
its receivers listen on, and a 13 cm decode is not an opportunity for a station
with no 13 cm.

Both tests run before the status lookup and both read a cached map: they throw
away most of the feed, so nothing downstream pays for it. The band list is
re-read when the lists are saved, so ticking a band in Settings applies to the
next decode rather than the next restart — which would have looked like the
filter not working.
2026-08-14 15:41:37 +02:00
rouggy e67f57fee7 fix(serial): list a COM port once, in the order a human reads it
An operator's keyer dropdown showed COM1 and COM3 twice, in the order
COM1 COM1 COM3 COM8 COM9 COM4 COM3, with "COM3COM3" as the selected value —
two entries sharing one value both counted as selected.

Not our bug to start with, but ours to absorb. Windows enumerates serial
ports from HARDWARE\DEVICEMAP\SERIALCOMM, which maps a DEVICE PATH to a COM
name, so the same name legitimately appears twice when two devices claim it:
a driver uninstalled without cleaning up after itself, or virtual-port
software. go.bug.st/serial returns one entry per device, faithfully.

Deduplicated and naturally sorted in the one binding every port dropdown in
the app calls, so CAT, rotators, amplifiers and the keyer all get it. COM4
now sits between COM3 and COM8 rather than after COM9, which is how a port
gets overlooked on a machine with a dozen.

A dropped duplicate is logged rather than swallowed: two devices holding one
COM name is also a good reason for one of them to report that port busy, and
that is not something an operator can work out from a list.
2026-08-14 15:25:45 +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 4f9a366884 feat(spot): put the award references in the spot comment
After the mode, which is the order a cluster line is read in:
"CW POTA FR-11553".

Two sources, because the QSO can be at either stage. While it is being typed
the entry panel holds the references as "CODE@REF;CODE@REF"; once logged they
live on the row as the materialised award_refs. The Send Spot window takes the
entry's when there are any and falls back to the last logged QSO — the same
fallback the callsign and frequency defaults already use — and picking a QSO
from the Latest list fills in that QSO's own.

A self-spot is the opposite case and gets its own builder: it announces OUR
station, so it carries MY_POTA_REF and friends. Using the QSO's award
references there would spot us with the park number of the station we just
worked, announcing us from somewhere we are not.

Both cap at the 30 characters a cluster node keeps, and add a reference whole
or not at all — a truncated park number is worse than none, since nobody can
act on it, and it still costs everyone who reads the spot. Only the comment we
BUILD is held to that; what the operator types is their own business.
2026-08-14 13:22:21 +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 05fc8ad80c fix(qsl): copy a design's pictures, not just its row
copyDirContents wrote into a destination it never created, so the first
os.Create returned ENOENT. The caller was written as "ignore os.IsNotExist"
— meant for a design with no pictures at all — and that guard matched the
failed write exactly: nothing was copied, no error surfaced, and validation
two lines later reported "copied design is incomplete: hero photo file
img_… not found" with no way to tell why.

Three fixes, one cause:

  - copyDirContents creates its destination;
  - the caller stats the source folder instead of pattern-matching an error,
    so a genuine copy failure is reported and rolls back both the row and
    the folder;
  - DuplicateProfile had the same defect in its own form — an INSERT … SELECT
    that cloned the template rows and left their photos behind, giving the
    new profile designs with no thumbnail and nothing to print. It now copies
    row and folder together, per template, since each needs its id first.

A test reproduces the original failure: it fails with the exact ENOENT that
was being swallowed.
2026-08-14 11:57:35 +02:00
rouggy 4e88bdfaa7 fix(qsl): separate the QSL manager from the routing method (#16)
QSL_VIA is the manager. QSL_SENT_VIA and QSL_RCVD_VIA are the ADIF "QSL Via"
enumeration — B bureau, D direct, E electronic, M manager (import-only) —
and say how a card travelled. OpsLog had one column for all three:

  - the import folded QSL_SENT_VIA into QSL_VIA whenever QSL_VIA was empty,
    which is exactly a Log4OM export (it defaults QSL_SENT_VIA to E), so
    OE6CLD saw "E" everywhere OpsLog shows the manager;
  - QSL_RCVD_VIA was listed in adifPromoted with no column behind it, so it
    was not stored, not kept among the extras, and not exported — dropped
    outright on import;
  - neither was ever written on export, so an import followed by an export
    destroyed both;
  - and OpsLog polluted the field itself: the QSL Manager panel wrote
    "Bureau" / "Direct" / "Electronic", in full words, into QSL_VIA.

Two columns added (migration 0027), carried through the five places a
promoted ADIF field has to touch, with round-trip tests pinning the reported
case. The QSL panel now offers Bureau / Direct / Electronic for each
direction and stores the enumeration; the manager field is labelled as the
manager and holds only that. M is kept when a file gives it and never
written back out.

Existing logs hold a mixture of the two in one column. The repair is offered,
not performed: the count is shown once per log with a plain question, and a
"no" is remembered. It moves only where QSL_SENT_VIA is still empty, and only
values that normalise to the enumeration — a manager is a callsign and can
never be one of those six words, which a test pins against real manager calls.
2026-08-14 11:50:25 +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 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 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 4ba5569ca8 fix(dxcc): /MM and /AM belong to no entity
RI1FJL/MM resolved to Franz Josef Land while the expedition was still sailing
there, telling the operator they had worked an entity they had not.

The old behaviour was deliberate — the comment read "strict DXCC says no
entity, but the log should still show the operator's country" — and that is
wrong for exactly this reason. A home country on a maritime mobile is not extra
information; it is a false claim about where the contact happened, and it is
the kind of false claim that gets a QSO submitted for an award it cannot win.

Trailing only. A LEADING "MM" is the Scotland prefix and "AM" is Spain, so
MM0ABC and AM5X keep their entities — getting that wrong would be a far larger
error than the one being fixed. /P, /M and /B are unaffected: a portable
station is still ashore.

In the cluster the column says "Maritime mobile — no DXCC" rather than going
blank, because blank is what an UNRESOLVED spot looks like and the two mean
opposite things — one is "we don't know yet", this is "there is nothing to
know". Display only: the QSO's own Country stays empty, since that field holds
a DXCC entity name and there is none.
2026-08-13 12:25:33 +02:00
rouggy 93879a0ce1 fix(compact): size the window to the strip instead of a tuned constant
compactH was 158px, "tuned so the compact entry strip fits in a single row".
A constant tuned against a layout stops being true the moment the layout
changes, and this one outlived a strip that had since shrunk — leaving about
70px of empty window under the fields.

The frontend now measures what it rendered and asks for that height, watched by
a ResizeObserver so a strip that wraps at a narrow width is followed too. The
topbar is added as its declared h-8 rather than measured, since it is fixed.

Bounded in the backend: a measurement of zero — a layout not yet painted —
must not collapse the window, and the call is ignored unless compact is on so
nothing can shrink the normal window. Rounded to whole pixels so a sub-pixel
reflow cannot start a resize loop.
2026-08-13 11:54:09 +02:00
rouggy 8fccfa29b1 fix(rotator): the SP/LP readout goes BELOW the dial
The wrapper around the svg was a flex ROW, so a sibling landed beside the
compass instead of under it. Column, and the readout spans the full width.
2026-08-13 11:48:59 +02:00
rouggy 784d809290 fix(rotator): put the SP/LP readout inside the compass itself
It went under the Station Control rotator; the compass that wanted it is the
docked one beside the entry strip. Both are the same RotorCompass, so the
readout moves INTO that component: every compass in the app carries it, and no
caller draws its own.

The bezel already showed the short path as a red marker, but a marker is a
direction, not a number — and the numbers in the status bar are 10px, which is
what prompted this.

Clickable only when the caller passed onGoto, since a compass rendered without
one cannot turn anything and a button that does nothing is worse than a label.
2026-08-13 11:45:49 +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 ed3c132f70 chore(udp): log the ADIF record, not just its size
"Was the grid in what WSJT-X sent, or did OpsLog put it there?" is the first
question asked when a contact is logged in the wrong square, and a byte count
cannot answer it. An expedition logged at its licence holder's home QTH stayed
undiagnosable for exactly that reason.

One line per QSO, around 280 bytes, whitespace collapsed so the record sits on
a single line.
2026-08-13 09:13:27 +02:00
rouggy 385e8c4c86 fix: log the grid the station sent, and show the SPE going off first press
Grid: RI0FA transmitted QN35 all evening and was logged with its licence
holder's home square. WSJT-X's logged ADIF usually carries no GRIDSQUARE, so
the lookup was the only source left — and QRZ/HamQTH describe where an operator
LIVES, which for an expedition is the wrong side of the planet.

The square heard on the air is now applied before the lookup runs, so
refineGrid has something to defend. It still upgrades a 4-character square to a
6-character one from the same field, and still refuses a finer square from a
different field.

SPE: decodeCSV marks the client connected on every frame it parses, and the
poll goroutine can be mid-read while PowerOff runs. The frame from a second ago
landed after PowerOff had marked the amp offline and put it straight back,
which is why OFF had to be pressed twice.

Frames are ignored for three seconds after the off key goes out — the window is
opened before the key is sent, so nothing already travelling can beat it. Time
bounded rather than latched: an amp switched back on at its own front panel has
to reappear without being told.
2026-08-13 09:08:19 +02:00
rouggy 61e5736f1e fix(lotw): stop reporting suppressed QSOs as uploaded
Reported: ten fresh contacts upload and appear on LoTW; twenty older ones,
bulk-edited to exactly the same LOTW_SENT/RCVD state, are accepted by OpsLog
and never arrive. The operator's reading was that a failed attempt could not be
overridden. It is simpler and worse than that.

TQSL's exit codes, from its own cmdline documentation:

	 8  NO QSOs were processed — already uploaded OR OUT OF DATE RANGE
	 9  some processed, some ignored — same two reasons
	14  some already uploaded, the rest signed

8 and 9 were both read as plain success. So on 8 — nothing uploaded at all —
OpsLog announced "already uploaded (duplicate)" and stamped every selected
contact as sent. They were never on LoTW and now looked as if they were, which
is exactly the reported symptom, and it is not recoverable by re-uploading
because the operator has no reason to try.

"Out of date range" is the cause that bites here: a contact older than the
callsign certificate's validity is silently left out. Older QSOs failing while
today's succeed is the signature.

Now: 8 is a failure and nothing is stamped — a duplicate left at "R" is
harmless and will be refused again, whereas a contact wrongly marked sent is
one nobody will look at twice. 9 and 14 succeed but carry Ignored, and the
caller says so in the console and a toast.

TQSL's own sentence ("20 QSO records are out of date range") is captured and
shown. It was being read and thrown away, and it is the whole answer to "why is
my contact not on LoTW".
2026-08-13 08:55:29 +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 62256942a1 feat(log): QSO number column, oldest contact = 1
Not the id: the primary key follows insertion order, so importing an old ADIF
gives the oldest contacts the highest ids. This is a rank over qso_date.

Computed over the WHOLE log, not the query result — ranking inside the result
would renumber every contact the moment a filter is applied, and QSO #1 would
change identity as the operator typed.

Held as one id-to-rank map, built from a single ordered id query and dropped
with the other derived indexes when the log changes. A contact logged from the
entry form is the newest, so it takes the next number without rereading the
log: AddQSO deliberately avoids full invalidation because a contest run would
pay for it once per QSO. A contact entered with an OLDER date belongs in the
middle of the order, so there the map is dropped and rebuilt rather than
mis-numbered.
2026-08-13 00:31:38 +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 1ef9e553f7 perf(cluster): rotate the grid cache instead of emptying it, cap 100k
The cache dropped EVERY entry once it passed 20 000. That was survivable while
the only feed was this station's own WSJT-X decodes, which never reached the
ceiling — it is a cliff for anything larger, and every locator in the cluster
list would disappear at once, periodically, for no reason the operator could
see.

Two generations: when the current map fills it becomes the previous one and a
fresh map takes over; lookups consult both. A rotation therefore costs the
older half and nothing more. It needs no insertion order, no per-entry
timestamp and no bookkeeping on the write path — all of which "evict the
oldest thousand" would require, on a path that runs once per decode.

The cap is 100 000, measured at 82 bytes an entry: 8 MB a generation, 16 MB
for both. 20 000 was chosen when the ceiling was unreachable anyway.

Both maps now go through rememberDecodeGrid / lookupDecodeGrid. Rotation swaps
the map headers, so the read had to be guarded; routing every access through
one accessor is what makes that checkable rather than remembered.
2026-08-12 16:48:23 +02:00
rouggy 8fc6673611 fix(awards): apply the per-reference validity window
References are not forever: a park is delisted, a district merged, a castle
loses its number. A QSO made while the reference existed is a valid contact
and must keep counting; one made afterwards must not.

The JSON format did not need to change — ValidFrom/ValidTo were already on
awardref.Ref, already columns in award_references, already round-tripping
through export and import. They were simply never read: awardRefMetas dropped
them on the way into the engine, so nothing downstream could enforce them.
This carries them through and checks them in keepRefs.

Empty means the award's own window governs, which inScope already enforces for
every QSO in the award. That fallback is deliberately NOT duplicated per
reference — two places enforcing the same dates is two places for them to
disagree. The editor shows the award's dates as the hint under the boxes so
the operator can see what empty inherits.

Dates are compared as ISO strings rather than parsed times: the stored shape
is "2006-01-02", lexical order on it IS chronological, and this cannot fail on
a malformed value the way a parse can — a reference with a typo in its window
keeps counting instead of silently vanishing from an operator's totals.

The Explain trace names the date and the cutoff, because "did not count" on a
contact the operator remembers making is exactly when they need to be told it
is the reference that has a window, not their log that is wrong.
2026-08-12 10:43:23 +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 8b66030c89 fix(udp): WSJT-X QSOs were missing the solar data and the distance
The UDP path applies the station profile, the DXCC number, the Club Log
exceptions, the zone refinement and the QSL defaults — and its comment says
"same as the manual AddQSO path", which it was not. applySolar and fillDistance
were never called there.

For an operator running digital, and that is most of the traffic on most
stations, it meant SFI, A, K and distance were empty across the whole log while
a hand-logged contact carried all four. It also quietly undermines the
history-based propagation work, which needs those numbers to be there.

applySolar now refuses a QSO more than a day old, whichever path it arrives by.
The UDP feed and the ADIF monitor normally carry contacts seconds old but
neither promises it — a logger re-broadcasting its backlog, or an operator
typing last month's contact by hand, would be handed this morning's SFI as
though it had been measured at the time. A wrong reading is worse than a missing
one: afterwards nothing tells it apart from a real one.
2026-08-11 22:11:29 +02:00
rouggy 2a6e09a1d7 fix(bulk): mode and RST were offered but could not be saved
A bulk-editable field passes through THREE tables: the list the dialog shows,
bulkFieldColumns in app.go, and bulkEditableCols in internal/qso. I added mode,
submode and RST to the first and the third and missed the middle one, so the
dialog offered them and every save came back "unknown field".

Nothing warns about that. Each table is perfectly valid on its own, and the
mismatch only surfaces when an operator picks the one field that falls through
the gap — which is exactly how it was found.

So both directions are pinned now: every mapped field must be writable by the
qso layer, and every writable column must have a field mapping to it. A column
nothing maps to looks supported from the inside and cannot be reached from
outside, which is the same fault wearing the other hat.
2026-08-11 20:39:46 +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 f7b9bfd0bc fix(webpub): the Distance column was empty for every QSO logged here
Nothing computes a distance when a QSO is logged. The DISTANCE field is only
ever filled by an ADIF import that carried one, so publishing it straight gave
an empty column to anyone whose log was made in OpsLog — which is everyone who
reported it.

It falls back to the two locators, which are on the QSO already. A stored
distance still wins: it came from the log that recorded the contact, which knew
more than two four-character squares do. No grids means an empty cell, not a
zero — an empty cell is honest, a zero is a claim.

Rounded to whole kilometres. The squares are tens of kilometres across and a
decimal would assert an accuracy nobody has.

The geometry moved to internal/geo on the way. It lived in package main, which
internal packages cannot import, so the PSK Reporter watcher already had its
maths injected from main and this would have been a third copy. A bearing that
disagrees with itself between two panels is a fault nobody reports, because each
screen looks perfectly plausible on its own.
2026-08-11 19:41:47 +02:00
rouggy 43095a1d89 feat(rigctld): split that actually reaches the radio, or an honest refusal
set_split_vfo and set_split_freq both answered RPRT 0 and did nothing. WSJT-X
and JTDX in "Split Operating: Rig" send exactly that pair, believed both, and
transmitted on the RECEIVE frequency — on a pileup, straight onto the DX, while
showing the operator precisely what they had asked for. A lie that leaves no
trace in any log is the worst kind of bug this program can have.

The two commands are honoured as a PAIR. Arming alone does nothing on the radio,
because WSJT-X sends the frequency second and split armed on whatever the
transmit VFO happened to hold is worse than no split at all: it transmits
somewhere the operator never chose. The request is remembered and set_split_freq
does the work.

Kenwood gains SetSplit — FB to place the dial, then FR0/FT1 to arm, in that
order for the same reason. It writes what State() already knows how to read.

Everything else REFUSES, and that is the feature, not a shortfall. Only Flex and
Icom could even toggle split before, neither could set the transmit frequency,
and Yaesu, TCI and OmniRig have nothing at all. A refusal WSJT-X can report —
and act on, by falling back to Fake It — is worth far more than a success it has
no way to check.

Both paths are pinned: split reaching the rig as one armed call with the right
frequency, and a backend that cannot do it producing an error rather than RPRT 0.
2026-08-11 17:53:46 +02:00
rouggy 904b451951 fix(station): toggling tracking must not drop the antenna link
SetMotorFollow went through SaveUltrabeamSettings, which tears the client down
and dials again. That is right when the transport changed and absurd for a
checkbox: the operator flipped tracking and watched the antenna disconnect and
come back, which on a remote controller is several seconds of a link that was
working perfectly.

The follow loop is a goroutine with its own stop channel and can be replaced on
its own — the connection underneath never knows. Only the two keys this call
actually changes are written, too: re-saving the whole block to change a
checkbox means re-normalising host, port, baud and bands, and every one of those
is a chance to alter something nobody asked to alter.

startUltrabeam now starts the loop through the same function rather than its own
copy. Two versions of "start the follow loop" drifting apart is how a step
change quietly stops taking effect.
2026-08-11 16:43:10 +02:00
rouggy c4d90e6880 feat(station): drive the motorized antenna from its widget
It showed pattern, elements and Retract — everything except where the antenna
is pointed, which is the thing an operator changes most. Tuning it meant opening
Settings or moving the rig.

A button per band, taken from the bands configured in Settings rather than a
list invented here: a band dropped there cannot be clicked here. They point at
where people actually work, not the arithmetic centre — 20 m centres on 14175
and nobody lives there, 10 m spans 1.7 MHz of which the top half is empty.

Up and down move 25 kHz, which is also the finest tracking threshold: a smaller
nudge would be undone by the next poll while tracking is on. They work from the
ANTENNA's frequency, not the rig's, or walking the antenna across a band while
the rig stays put would be undone on the first press.

Tracking moved within reach because it is an operating decision — off to park
the antenna, on to resume — not something set up once. Its step only appears
when it is on: a threshold for something switched off is a question the operator
cannot act on.

MotorTuneKHz carries the CURRENT direction rather than resetting it. Both
controllers set frequency and pattern in one command, so a bare frequency would
silently drop a 180° or bidirectional pattern — a beam turning round is not an
acceptable side effect of clicking a band.
2026-08-11 15:45:02 +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 e0dfa53f54 chore(startup): measure the part of a slow launch the log could not see
An operator reporting "nothing happens for two or three seconds after I click"
was impossible to answer from opslog.log, because the first line applog writes
already sits inside startup(). Everything spent loading the binary and creating
the WebView2 environment happened before the log begins.

In one of the reported logs, startup() to window-visible is 251 ms end to end -
so whatever the wait is, it is not OpsLog initialising. It just could not be
proven, only asserted.

processStart is stamped on the first instruction of main. Two lines now bracket
the launch: how long was spent before startup() ran at all, and click-to-window,
which is the only number that matches what the operator experiences.
2026-08-10 20:30:08 +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 0d48dbfd17 fix(cluster): drop the worked index when a QSO is logged
AddQSO emitted qso:logged but never invalidated the cluster status snapshot.
The frontend did its part - it re-queried every visible spot two seconds later -
and ClusterSpotStatuses answered out of a snapshot built before the contact, so
it returned exactly the same "new band" as before. For ever, until an import, an
edit or a profile switch happened to invalidate it for another reason.

Reported twice: an E51 and then a ZD7 that stayed yellow on the band map with
the QSO plainly in the log. The first report was answered by fixing the
visibility gate, which was a real bug of its own and hid this one.

Confirmed against the operator's MySQL logbook rather than guessed: ZD7BG has
dxcc=250 on all eleven QSOs and 0 of 29579 rows lack a DXCC number, which ruled
out the missing-entity-number theory and pointed here.

Deliberately not invalidateAwardStats(): that also drops the award matrices,
which are expensive on a large log, and a contest run would pay for it once per
QSO. This index is a few DISTINCT scans and it is what the spot colours read.

Also fixes the new worked-grid query, which asked for a column named
"gridsquare". The column is "grid" - gridsquare_ext is a different one - so NEW
GRID could never have worked on either backend. Verified against the real
database: 12516 distinct grid|mode pairs.
2026-08-10 19:18:49 +02:00
rouggy d6ed9f03eb fix(cluster): strip the skimmer suffix before resolving the spotter's continent
RBN spotters report as "VU2OY-#" and cluster nodes as "DL1ABC-2". That string
went straight into the DXCC prefix matcher, which saw an unknown callsign and
gave up, so every spot came back with an empty continent.

The filter then matched nothing at all - and worse, silently: unresolved spots
are deliberately never dropped, because the status arrives a moment after the
row and filtering meanwhile makes the list flicker. So selecting AS left the
Europeans and the Americans exactly where they were, with nothing to say why.

A real callsign never contains a hyphen, so cutting at the first one is safe.
2026-08-10 19:06:52 +02:00
rouggy 9b2115be8f feat(cluster): grids from the UDP decodes, plus spotter continent and LoTW
Backend groundwork; the columns and filters that consume it come next.

GRIDS. A CQ is the one WSJT-X message that carries a locator, and wsjtSender was
throwing that token away. It is now returned, validated as a real field+square,
and remembered per callsign. This is the ONLY grid source available: a DX-cluster
line carries the spotter's grid at best and never the DX's, and a per-callsign
QRZ lookup under an RBN firehose is not a trade worth making. So grids are known
for the stations this receiver decoded - which is exactly the FT8/FT4 watering
hole an operator is looking at while grid chasing.

RR73 is why the grid is validated rather than pattern-matched. R is inside A-R
and 73 inside 00-99, so a sign-off satisfies the Maidenhead shape exactly and
would have planted a grid that does not exist into the index, silently.

NEW GRID keys on "GRID|MODE" with the mode put through the same normMode as
everything else, so the "group digital modes" option decides whether a grid
worked on FT8 is still new on FT4 - one rule, no branch. Grids are truncated to
four characters: a log holds a mix of JN36 and JN36QU, and without that the same
square is new forever, once per subsquare.

On cost, which was the condition: one more DISTINCT scan when the status
snapshot is rebuilt, then map lookups per spot. The same shape as the county and
POTA sets it sits beside, and the snapshot exists precisely so a spot batch never
touches the logbook.

Spotter continent and the LoTW flag come from tables already in memory - the
DXCC prefix table and ARRL's user list - so they cost a lookup each. The spotter
continent answers a different question from the DX's: whether anyone near you is
hearing this at all.
2026-08-10 16:52:36 +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