Commit Graph
333 Commits
Author SHA1 Message Date
rouggy c5e0ec9033 fix(filter): "equals nothing" found nothing
A NULL never equals '', so a condition written that way returned zero rows. The
question is perfectly clear — the operator wants the ones with nothing in that
field — and answering it with silence makes the filter look broken rather than
mis-stated. eq and ne with a blank value now run the empty / not-empty test.

Empty on a NUMERIC column also had a real fault behind it. IFNULL(col,'')=''
compares 0 against '', which SQLite calls false and MySQL calls true — one
expression quietly answering two different questions depending on where the
logbook lives. Numeric columns test NULL or zero explicitly now; text keeps the
string test, where '' is a real value and 0 is not.
2026-08-11 21:02:33 +02:00
rouggy 1b7f8ec9c1 docs: one entry for the distance work, not two 2026-08-11 20:33:08 +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 deee8c4618 fix(rda): 1015 districts were on the wrong DXCC entity
Every one of the 2660 references was filed under European Russia. Corrected
against the RDA reference list: 991 are Asiatic Russia (15), and the 24 KA-
districts are Kaliningrad (126), which is not Russia for DXCC purposes at all.
1645 were already right.

dxcc_filter went from [15,54] to [15,54,126] with them. That filter decides
which QSOs are even considered, so leaving it alone would have made the 24
Kaliningrad districts unclaimable — a correction that quietly removes references
is worse than the error it fixes.

BA-27 (Mezhgorye) and BO-33 (Schebekino) are not in the reference list at all
and were left exactly as they were. Guessing an entity for a district nobody
lists is how a second wrong answer gets added to the first.

Only the dxcc field changed. Names differ in transliteration between the two
sources — Maykop against Maikop — and rewriting those was not asked for and
would churn 2660 lines to no purpose.
2026-08-11 19:55:37 +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 d3a405f4f6 fix(webpub): the published page hid columns it had room for
The page was capped at 1100px — a comfortable reading width, and the right
choice for the eight default columns. Now that all 123 fields can be published,
anything past about eight sat behind a horizontal scrollbar on a screen wide
enough to show the lot.

Worse, Windows hides overlay scrollbars until something moves, so a table that
scrolled looked exactly like a table with its right-hand columns missing. That
is what was reported, and the report was reasonable: nothing on screen said
otherwise.

The wrapper grows with its content now, up to the window, and keeps a minimum so
a two-column table does not collapse on a large display. The table sizes to what
its cells need rather than being squeezed to the container first — that squeeze
could wrap a callsign while empty space sat further along the same row. And the
scrollbar is drawn permanently, thin and in the page's own colours.
2026-08-11 19:45:00 +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 102097c5c4 feat(bulk): mode, submode and RST are repair fields, so allow them
They were excluded as "per-QSO", alongside callsign and date. That rule confused
two different things: bulk edit is not for describing QSOs, it is for REPAIRING
a batch of them — an import that mapped every contact to SSB, an ADIF that
carried no MODE at all. Refusing because a hundred rows should not normally
share a value left the operator editing a hundred rows by hand.

Setting the mode CLEARS the submode. A submode belongs to the mode it was
recorded under; left behind it contradicts the new one, and "FT8 with a submode
of USB" is not a thing — worse, the submode is what most ADIF readers believe.

Band is still refused on its own, and that half of the rule stands. It travels
with the frequency through BulkSetFrequency, which writes the pair: a band
contradicting its own frequency is invalid ADIF, and every export would carry
the contradiction out into the world. Callsign and date stay out too — they
identify the contact rather than describe it.
2026-08-11 19:36:56 +02:00
rouggy 3b90ef6b7a chore: open 0.24.6
Empty block at the top so the next change has somewhere to go. 0.24.5 keeps its
nine entries; the release script stamps the version constants.
2026-08-11 18:20:42 +02:00
rouggy 9fd801ea69 fix(bandopen): stop subscribing to a band nobody watches, and drop the toast
A saved band selection outlives the code that made it. 12 m was removed from the
watched set, but every operator who had already enabled the watch kept
subscribing to it — paying for a firehose whose messages the detector then threw
away. The stored list is filtered against the offered one now.

The badge appeared on the next poll rather than on the event, up to twenty
seconds after the announcement, so the two read as unrelated things that happened
to mention the same band. It appears immediately.

And the toast is gone. It said the same thing as the badge and vanished after
five seconds — an operator who was tuning when it passed had no way back to it,
which is precisely what the badge was built to fix. Keeping both announced an
opening twice and still lost it once.
2026-08-11 18:11:09 +02:00
rouggy 78e99afc63 fix(ui): the opening badge must say what is open, not that something is
It read "6M OPEN" and kept the rest in a tooltip nobody hovers — an alarm with
no content, announcing that something was happening and refusing to say what.
Band, direction and typical distance are on the badge now: the three facts that
decide whether to point an antenna, all readable without touching anything. Out
of season gets a mark there too, since that is the one an operator must not
learn last.

The compass point is computed in Go and carried on the Opening rather than
derived in the UI. Sector() already gets the wrap round north right, and it got
it wrong once — a second copy of that arithmetic is how a badge ends up naming
the opposite direction to the log line beside it.
2026-08-11 17:58:51 +02:00
rouggy 740f007395 docs: one entry for the Kenwood split work 2026-08-11 17:55:59 +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 fa01968207 fix(kenwood): split frequencies swapped on transmit
IF reports the VFO "in use". In split that is the RECEIVE VFO while receiving
and the TRANSMIT VFO while transmitting — the backend took it as the receive VFO
in both cases. Everything therefore read correctly until the PTT closed and then
reversed, which is exactly how it was reported from a TS-590 on USB.

The IF frame has carried the transmit bit all along; parseKenwoodIF already
decoded it into f.TX. It was simply never consulted here.

This is not only a display fault. FreqHz is what a QSO is LOGGED on, so a
contact worked in split went into the log on the DX's frequency instead of the
operator's — wrong in the log, wrong in every ADIF exported from it, and
invisible until someone checked a QSO by hand.

The emulated rig in the test package can now be keyed, so the case is pinned
from both sides: verified failing without the fix (tx and rx exchanged) and
passing with it.
2026-08-11 17:28:23 +02:00
rouggy 91452cefc0 feat(filter): "is one of" — so a real question can be asked
Every condition was joined by ONE global AND or OR. "2 m or 70 cm, in FT8, since
January" therefore had no expression at all: AND killed the two bands, OR let
every FT8 QSO through. The filter could ask simple questions and nothing else.

Rather than grow nested groups — a tree in the UI to answer something that is
nearly always "this field, any of these values" — the OR lives INSIDE one
condition and everything else keeps ANDing. Two operators, a comma-separated
value, no change to how the rest of the filter behaves.

Two edges that matter more than they look. An empty list matches NOTHING rather
than being dropped: dropping it would widen the result set, the opposite of what
someone typing a filter expects. And "is none of" wraps the column in IFNULL,
because raw SQL NOT IN discards NULL rows — a QSO with no band recorded is not
one of the listed bands, so it belongs in the answer.
2026-08-11 17:07:32 +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 e107b0b741 docs: one changelog entry per thing, not one per commit
Band-opening detection had five entries in 0.24.5 — long paths, the PSK Reporter
feed, which bands, the densest sector, the region filter. Those are five steps I
took, not five things that happened to the operator, and the version read like a
work diary. Merged into one entry describing what the feature now does.

The 12 m mention went with them: it was added and removed inside this same
version, so it never existed as far as anyone reading this is concerned.
2026-08-11 15:26:40 +02:00
rouggy 24668d5981 fix(pskr): an opening has to be an opening HERE
A PSK Reporter message says "X was heard BY Y". The watcher measured the
distance and bearing from the operator to X and stopped there, never asking who
had actually heard it — so a station 1400 km away, decoded by somebody in Japan,
counted as evidence. That proves the path from X to JAPAN and says nothing about
whether anything reaches this station. It is how a "2 m opening" came to be
announced out of a KX9X in the United States, and the operator was right to find
the callsign list absurd.

Reports are now kept only when the RECEIVING station is within 300 km. Far
enough to borrow the ears of a whole region — an opening reaches an area, not a
postcode, and waiting for a decode at one's own antenna is just working the band
— and close enough that the ionosphere doing something there is it doing the
same thing here. The transmitter still supplies the direction and the path
length, which is what was always wanted from it.

Also drops 12 m from the watched bands, at the operator's request: at this point
in the cycle it is open often enough that announcing it is a notification rather
than news, and a band that cries wolf costs the ones that do not. One fewer
subscription is also less traffic on a PC that pays for every message.
2026-08-11 14:58:10 +02:00
rouggy 8aa0e39aff fix(bandopen): find the busiest sector, and name it correctly
Two faults, both found from one field log: 70 000 decodes, 10 and 12 m plainly
open, nothing announced — and the two openings that DID fire named the wrong
direction.

evaluate() required EVERY distinct station in the window to fit inside one 90°
arc. That is the shape of a sporadic-E cloud and of nothing else. With 10 and 12
m open on F2 the reports arrive from all round the compass, the arc is 360°, and
the test can never pass — so the busier the band, the less likely an opening was
announced. Exactly backwards. It now finds the DENSEST sector instead, which
keeps the Es signature intact (a cloud still makes one direction dense) and lets
a real F2 opening be seen through the handful of neighbours who are always
there. Scattered-but-busy still reports nothing: the point was to stop demanding
global agreement, not to call every open band an opening.

Sector() averaged the two bearings arithmetically, so an arc crossing north was
labelled by its opposite: 353–61° averaged to 207° and went out as SW when it
was NE. Not a vague error — a reversed one, given to an operator who may turn a
beam on it. The detector has handled the 0/360 wrap since it was written; only
this label had not.
2026-08-11 14:50:37 +02:00
rouggy 96c99f0ae6 feat(bandopen): a blinking badge that lasts as long as the opening does
The only sign of a detection was a toast. It is gone in seconds, and an operator
who was tuning at that moment had no way back to it — for an event that lasts
hours and happens a handful of times a season, that is the wrong shape entirely.

The badge sits at the right-hand end of the status bar, before the clock: an
opening is a state of the WORLD, not of this station, so it belongs with the
time and the logbook rather than among the rig and amplifier chips.

Knowing when to go out was the real work. Nothing announces that an opening
ended, and the detector deliberately says nothing more about a band for 45
minutes after announcing it — right for a message, useless for a badge. So it is
inferred: every qualifying spot on that band pushes a deadline out, and when
they stop arriving the badge fades by itself. Fifteen minutes, comfortably more
than the detector's own twelve-minute window, so a quiet couple of minutes
mid-opening does not blink it off and on again.

Gated on the same distance floor the detector uses. Without that, a band busy
with short-range tropo would hold an Es badge lit indefinitely on spots the
detector itself had refused.
2026-08-11 13:48:26 +02:00
rouggy 0550ecdac3 fix: a test button that cannot fail is worse than no button
TestClublog checked that three fields were non-empty and returned "Ready — CALL
via EMAIL". Nothing was ever sent to Club Log, so a wrong password produced the
identical green message. It now signs in, through getadif.php because that is
the one authenticated endpoint that cannot change anything: a test must never
put a record into someone's log. A future start year keeps it from downloading
130 000 QSOs to prove a password, and a rejected login answers 403 before any
body arrives.

LoTW is two credentials doing two jobs and the button reported only the first.
Uploads go through TQSL signed by the certificate — the website password is
never involved — so a wrong one breaks nothing until the day confirmations are
downloaded, by which time nobody connects the two events. Both are now checked
and, more importantly, reported separately.

Also: the detector still refused 12 and 10 m while the PSK Reporter feed was
already subscribed to them, so those decodes were fetched and thrown away. The
rule was never "HF is out", it is "is an opening here an event" — 20 m being
open is the normal state of the band, 10 m opening is not.
2026-08-11 13:16:09 +02:00
rouggy 9c33feecfa feat(ui): PSK Reporter chip in the status bar
Beside the rig and amplifier chips, before ON AIR, because it is the same kind
of fact they report: a link that is either up or it is not. Clicking it opens
the settings that control it, like the amplifier chips do.

Shown ONLY while the opening watch is on. A permanently grey chip for a feature
nobody enabled is clutter, and the bar is 28 px tall — every chip in it has to
earn its width.

Green once decodes are arriving, amber while connected but silent: those are
different states and an operator wondering why no opening has been announced
needs to tell them apart. The count itself is in the tooltip, not on the chip —
it moves several times a second on an open band, and a number flickering in the
corner of the eye is a distraction rather than information.
2026-08-11 13:08:04 +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 2d3bfa704e fix(bandopen): drop the distance ceiling
The detector refused any path over 2400 km, reasoning that past a single hop the
bearing test stops meaning anything. That was wrong, and it discarded exactly the
openings worth announcing: Nexus flagged a 6 m opening at 5477 km that OpsLog
never saw, because the spots were thrown away before any test ran.

Multi-hop Es is ordinary on 6 m — 5000 km paths are common, 10000 km happens —
and it stays directional: a second hop leaves the sector the first one entered.
So the sector test, which is what does the real work here, holds perfectly well
at any distance. The ceiling was standing in for a judgement it could not make.

The floor stays at 500 km: a short 6 m contact is tropo or ground wave and says
nothing about the ionosphere. Both are now pinned by a test.
2026-08-11 12:30:51 +02:00
rouggy 29bdea1e43 feat(rigctld): answer lock_mode and stop_morse instead of refusing them
Both are ordinary Hamlib commands, and Nexus sends them around every transmit.
Refusing with RPRT -11 is permitted and a tolerant client carries on, but
nothing obliges it to — and while they sit in the log as "unimplemented" they
stay suspects every time something else goes wrong.

get_lock_mode answers 0, which is true: OpsLog never locks the dial against its
own clients. set_lock_mode is accepted and ignored, like set_vfo — there is no
lock to set, and failing would abort a client's transmit sequence over a setting
with no effect either way. stop_morse answers success because nothing is queued
here: CW is keyed by the rig's own keyer through the backend, never buffered in
this server, so "stopped" is accurate rather than polite.

Whether this is what Nexus is actually unhappy about is not established. It
removes two known irritants and two lines of noise from the log; if the trouble
persists, what remains in the log will be about the trouble.
2026-08-11 12:14:12 +02:00
rouggy 735dfe69db fix(webpub): the column picker is a dropdown, not a wall
The first attempt laid 123 fields out on the page, sectioned by group. That
buried every other setting in the panel and was no easier to read than the flat
wrap it replaced — more surface, same problem.

One dropdown now, alphabetical, with a search box pinned at its top. Alphabetical
because any other order means hunting: the operator arrives knowing the name of
the field they want. The menu stays open while ticking, since picking eight
columns should be one visit rather than eight.

The chosen columns keep their place above it, in publication order — that list
answers "what will the page look like", which no catalogue can.
2026-08-11 11:11:24 +02:00
rouggy 67fe5bcff0 feat(webpub): offer every field, and make choosing among them possible
The catalogue held 23 hand-picked columns, so publishing a county, a satellite
pass or an award reference was simply not possible. It is generated from the
qso.QSO struct now — 123 fields — which also means a new ADIF field cannot be
forgotten here, as a hand-written list always eventually is.

Three keys were renamed to their ADIF names on the way (pota, sota, station).
An alias map keeps existing configurations publishing the same columns; without
it three would have vanished silently on upgrade, which is the worst way for a
setting to change.

The picker had to change with it: 123 chips in one wrap is a wall nobody reads
to the end of. It is sectioned by group, filtered as you type, and the chosen
columns sit on top in publication order — after picking eight out of a hundred,
the question stops being "what exists" and becomes "what did I pick".

The package doc said columns were curated so the operator's address could not
be published. That is no longer true and the comment now says so plainly: the
judgement moved to the operator, the default selection is unchanged, and nothing
is published that was not chosen.

Also collapses the changelog: four separate shared-CAT entries were one thing
from where the operator sits, and all of them were far longer than the one or
two sentences this project asks for.
2026-08-11 10:59:01 +02:00
rouggy d36d431d9a feat(rigctld): only pass PTT on when it actually changes
Nexus sends set_ptt 0 about sixteen times a second, and every one of them became
an "xmit 0" to the FlexRadio — a write every 60 ms, for ever, saying nothing.

Repeating a state is not a request to change it. Only transitions reach the
radio now, and each is logged, so the next "it will not transmit" can be traced
to whoever asked rather than inferred from a wall of identical lines. The first
call always goes through: how the radio was left is not ours to assume.

This is not the whole of the reported symptom, and should not be read as such.
The log also shows "xmit 1" followed by "xmit 0" one to two milliseconds later,
off the 60 ms cadence — that is a genuine transition pair, so it still gets
through and the radio still unkeys at once. Those two commands come from the
client, not from here; the writes originate only in set_ptt, and there is no
loop in OpsLog that emits them.
2026-08-11 10:29:23 +02:00
rouggy 0c8d79e2fa feat(rigctld): say so when another program owns the CAT port
Listen() succeeding is not the same as being reachable. OpsLog binds 0.0.0.0,
and Windows lets a second program bind the SAME port on the specific address
127.0.0.1. Connections to localhost then go to the more specific listener, so
every client reaches the other program while ours sits there having logged
"sharing CAT on port 4532" and never seeing a single connection.

Found with Nexus, which starts its own rigctld on 127.0.0.1:4532 and talks to
it. Neither side reports anything wrong: the operator gets a CAT timeout from a
daemon with no radio behind it, and OpsLog's log is silent because nothing ever
arrived. Three exchanges went into establishing that the connection simply never
reached us — the port table was what settled it, not the code.

So the server now dials its own port at startup and checks the connection lands
on its own accept loop. If it does not, it says which program will be receiving
the CAT connections and what to do. The counter it compares can only be raised
by our own accept loop, so a real client arriving during the probe makes the
check pass, never fail wrongly.
2026-08-11 10:15:19 +02:00
rouggy 796a1d8e7d feat(rigctld): name the misconfiguration instead of timing out silently
A client set to a RIG MODEL (Kenwood, Yaesu, …) pointed at the CAT-sharing port
speaks raw rig dialect: "ID;", "IF;". That is not rigctl, so it fell to the
unknown-command branch and got RPRT -11 like anything else.

The symptom hides the cause completely. RPRT -11 has no ';' for the client's
parser to terminate on, so it waits out its timeout and reports "reply
incomplete, got nothing" — a hard failure that reads as "OpsLog's CAT sharing
does not work", when it is one setting in the other program.

A frame ending in ';' with no space in it cannot be a rigctl command, so the log
now says what it is and what to set instead. Reported from Nexus, whose Hamlib
error named kenwood_transaction — the one word that gave it away.
2026-08-11 09:54:28 +02:00
rouggy 9531a54ac1 docs: changelog the shared-CAT fix that was committed without one
cfdd24d shipped the "?;" tolerance with no changelog entry, against the
project's own rule. Placed before the CW entry: the link had to survive being
busy before anything keyed through it could matter.
2026-08-11 08:59:03 +02:00
rouggy d2d64706f5 fix(kenwood): KY takes a FIXED 24 characters, not a string
CW over CAT never worked on a Kenwood. The KY implementation was written against
Elecraft's, which accepts a string of any length, so "KY OH5CX;" went out and a
TS-590SG answered "?;". OpsLog read that as "this radio refuses CW over CAT" and
told the operator to fit a serial keyer — advice that was wrong, and expensive.

The TS-590 manual is explicit: P2 has a fixed length of 24, blanks are filled
with spaces, and those spaces are not keyed. So the fix costs nothing on air; it
is simply the shape the command has. Elecraft stays variable-length, where
padding would key the trailing spaces as word gaps.

The semicolon is also gone from the allowed CW characters. It TERMINATES a CAT
frame, the manual forbids it in P2, and one in a macro would have closed the
command early and left the rest of the message to be read as commands.

Found from a log and a manual page, not from a rig: nobody here owns a Kenwood.
What is proven is the frame shape; that a TS-590SG then keys it still needs the
operator to confirm.
2026-08-11 08:55:49 +02:00
rouggy 8052bd2935 update 2026-08-10 23:40:42 +02:00
rouggy 55879809f2 fix(udp): ignore N0CALL, and stop returning a grid as a callsign
N0CALL is what WSJT-X transmits under when its owner never set a callsign. It
has a letter, a digit and an ordinary shape, so nothing rejected it: it was
spotted, coloured, counted as a new WPX prefix, and now that CQ grids are read
it would have put a grid into the worked index under a callsign nobody holds.

The obvious fix - adding it to looksLikeCall's reject list - was wrong, and the
test caught it before it shipped. That function answers "could this token be a
callsign at all", and the CQ grammar uses it to decide whether the word after CQ
is a modifier (DX, NA, a zone) or the call itself. Teaching it that N0CALL is
not a callsign made "CQ N0CALL JN36" skip a slot and return JN36. Shape and
policy are different questions and now live in different functions.

Chasing that turned up the real defect behind it: ANY unrecognised word after CQ
made the parser skip a slot, and a four-character grid passes every shape test a
callsign does. "CQ FOO JN36" returned JN36 as the sender - logged, spotted and
coloured as a station. A grid in the callsign slot is now refused outright.
2026-08-10 21:51:49 +02:00
rouggy 6969560efb chore: open 0.24.5
Empty block at the top so the next change has somewhere to go. 0.24.4 keeps its
seven entries; the release script stamps the version constants.
2026-08-10 21:19:31 +02:00
rouggy 4f87cedc2c feat(cluster): show the grid, and flag a new one
Finishes the half of this that was already computing on the backend and reaching
nobody. A Grid column in the Geo group, NEW GRID as a badge in Status, a filled
cell in the marker's own colour, and a filter chip.

new_grid joins lib/spotMarkers rather than getting colours of its own, so the
badge, the cell fill and the chip cannot drift apart - and the per-marker colour
setting will drive it with the rest from one table. Magenta: the last hue in the
categorical set not already spoken for, and one that does not read as a status,
because a new square is never urgent the way a new entity is.

The band map leaves it to the cluster, as it already leaves the prefix. The pill
is 22 px tall and its accent strip stops being readable past three segments.

A row carrying a new grid is no longer "dull", or the dimming would grey out the
one thing worth looking at.

The column is off by default, like the other Geo columns: it is only ever filled
for stations this receiver decoded over the UDP link, so for an operator who
does not run digital it would be a permanently empty column.
2026-08-10 20:05:34 +02:00
rouggy d273d21f1a fix(cluster): muting a worked spot must not brighten it
"No colour on worked" blanked the spot status as well as the worked-call flag,
on the theory that a spot bringing no novelty should stop painting entirely.

But the status is exactly what the cluster list reads to DIM a row. Blanking it
made isDull() fall through its "unresolved, never dim" guard, so every quiet grey
row came back at full brightness. The option meant to calm the list was the one
making it shout. It also forced a `muted` flag to exist purely so tooltips could
say that an empty status did not, this time, mean "entity not resolved".

It now removes the blue already-worked mark and nothing else, which is all it
ever needed to do: a worked entity already renders with no colour of its own and
is already dimmed. The flag, its two tooltip strings and the special-cased band
map styling all go with it.
2026-08-10 19:55:02 +02:00
rouggy bde136b98b fix(pgxl): match a reply to the command that asked for it
The amplifier PUSHES status frames ("S0|state=…") on the same socket it answers
commands on, and it pushes them constantly once in OPERATE - power, SWR and
temperature all move while transmitting.

command() read exactly one line and took whatever arrived first as its answer.
One pushed frame therefore put the stream permanently one reply behind: every
later command read the PREVIOUS command's answer, and eventually one waited out
the 3 s deadline, failed, and dropped the connection. Hence a fresh TCP
connection and a fresh authentication every few seconds in the log.

The stall in transmit is the same defect seen from the other side: command()
holds the connection mutex across that whole dead wait, so SetOperate and every
other control queued behind up to three seconds of nothing.

It only appeared remotely and in OPERATE because that is when there is anything
to push - on a LAN with an idle amplifier the race hardly ever opens. Which is
why it did not do this yesterday.

readReplyLocked now reads until the R<id>| that belongs to the command, feeding
every frame it passes to parse() on the way - a pushed status is fresher than
the one we were about to ask for, so nothing is wasted.

authLocked used the same one-line read and worked around this by re-sending the
whole handshake, which is visible in the log as "auth reply=S0|state=IDLE
(try 1)" followed by a second attempt. It goes through the same reader now.
2026-08-10 19:45:54 +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 c95a1137fc refactor(cluster): one rule for the filter panel
The panel had grown two shapes for the same kind of choice. Some filters were
checkboxes, some were chips, and New counties only was a checkbox duplicating a
chip - which I added, and which was the worst of it: a control that exists twice
is not more discoverable, it is one control the operator has to recognise twice.

The rule now:

  SWITCH  a behaviour that is on or off, and narrows nothing by a property of
          the station: hide worked, group duplicates, the two display options,
          LoTW users only.
  CHIPS   pick any number from a set; none picked means all. Status, mode,
          spotter continent. Selected is solid, unselected is the same chip
          faded, so the palette keeps teaching the colour code while switched
          off.

Nothing appears in both shapes. The duplicate county checkbox is gone.

Spotter continent became a chip row: seven two-letter codes fit on two lines,
they read as a set the way Status and Mode do, and several can be picked at
once - which "EU or NA" needs and a dropdown cannot express.

Both Lock buttons now sit in the heading of the section they lock, instead of
floating between sections, and every multi-select section clears the same way
through the same heading slot. One section helper and one chip helper, so the
next filter cannot drift.

The panel was also entirely hardcoded English - Search call, Hide worked, Bands,
Status - against the project's own bilingual rule. All of it goes through t()
now, both locales.
2026-08-10 19:00:09 +02:00
rouggy d41352a3a5 feat(cluster): LoTW badge and filter, spotter-continent filter
The L badge is a single letter, not a word: the call column is 120 px and holds
a callsign. It keeps the muted blue of a confirmation and never a status colour,
because whether a station uploads to LoTW says nothing about whether the spot is
worth chasing - those are different questions and must not share a palette.

The spotter's continent is not the DX's. It asks whether anyone near you is
hearing the band at all, which is why it earns its own control rather than
reusing the existing Continent column. The spotter callsign now travels with the
status query so the backend can resolve it against the one DXCC prefix table,
instead of a second continent rule appearing in the frontend.

Both AND with the status chips rather than joining their OR: "a new band, and
from Europe" is the question being asked. An unresolved spot is never dropped by
them - the status arrives a moment after the row, and filtering meanwhile made
the list flicker.

New counties only is the SAME state as the NEW COUNTY chip, reached a second
way, not a second filter. County chasing is a mode you switch into, and hunting
for one chip among eight is not how you switch into it.
2026-08-10 18:51:56 +02:00
rouggy 0a8c1ac45f chore: open 0.24.4
Empty block at the top so the next change has somewhere to go. 0.24.3 keeps its
four entries; the release script stamps the version constants.
2026-08-10 13:57:32 +02:00
rouggy 9f62808392 fix(webpub): the published page could not sort a date, and could not be unsorted
parseFloat accepts a numeric PREFIX. "2026-08-10" therefore became the number
2026, every date in the same year compared equal, and since the sort is stable
nothing moved: the Date column looked as though it were simply not sortable.
The same trap caught every callsign starting with a digit - 8B81SU and 8P9AB
both read as 8 - and the times, where "09:56" became 9. The numeric test is now
anchored to the whole cell, so anything that is not entirely a number is
compared as text, which is exactly right for an ISO date.

Sorting had no way back either. Each row now carries the index it was published
at, and a third click on a header restores that order. Headers also show an
arrow: with no indicator, a column that silently refused to sort was
indistinguishable from one that had sorted into the same order.

Blank cells sink in both directions rather than leading the ascending sort. An
empty field is missing data, not the smallest value.

Tested where it can be: the page ships its own script, so a regression is silent
- the table still renders, it just sorts wrongly.
2026-08-10 13:52:13 +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 f6f5235a8b feat(bandopen): announce sporadic-E openings on 6, 4 and 2 m
Observation, not prediction, and it needs no new data source: the cluster event
worker already enriches every spot with the great-circle distance and bearing
from the operator's grid, which is exactly what a single-hop Es detection rests
on.

The signature is four or more DISTINCT stations at 500-2400 km inside a 90
degree bearing sector within twelve minutes. Each constraint earns its place:
distinct callsigns because one station spotted by six skimmers is six spots and
one station; the lower bound because a 6 m contact under 500 km is ordinary
tropo and says nothing about the ionosphere; the upper bound because past one
hop the bearing test stops meaning anything; and the sector because a real Es
cloud illuminates a direction, which is what separates an opening from a merely
busy evening.

Fed AFTER the Historical guard in the worker. A SH/DX reply replays a hundred
past spots in a second - precisely the shape of a burst - and would announce an
opening that ended hours ago.

Season LABELS, it never gates. Both hemispheres get a summer peak and a lesser
winter one, and an opening outside those is announced with "unusual for the
season" attached: the rare one is the one an operator must not hear about last.

One announcement per band per opening (45 minute quiet period). An opening runs
for hours and produces hundreds of spots; one alert is information, forty is
noise.
2026-08-10 09:49:22 +02:00
rouggy 86a644863a chore(changelog): open an empty 0.24.3 block
Opened as soon as 0.24.2 is tagged, so the next change has somewhere to go and cannot end up described under a version that already shipped - which is exactly what happened to 0.24.1. Invisible until the build reports 0.24.3: GetChangelog drops every entry newer than appVersion.
2026-08-10 09:25:16 +02:00
rouggy 74acf88976 chore(changelog): move the post-release entries to 0.24.2
v0.24.1 was tagged and shipped mid-session; the six commits that followed kept appending to its block, so the released version appeared to describe changes it does not contain. 0.24.1 is restored to exactly the five entries that shipped - verified against git show v0.24.1:changelog.json - and the five later ones open 0.24.2.
2026-08-10 09:23:27 +02:00