The selector offered 1, 2, 4, 8 and 16 and none of them took: the board stayed
at four relays.
The count is decided in three places — deviceRelayCount in Go, chanCount in the
Station Control panel, relayCountUI in Settings. The new type was added to the
first and the dropdown, and to neither of the others, so the panel resized the
labels from a helper that had never heard of it and fell straight through to
its fixed default. The Go side was right the whole time, which is why the
driver would have been built correctly for a count the operator could not set.
All three now know the type. A test pins the Go side against every count the
dropdown offers, and against the fixed boards, whose hardware decides and which
must keep ignoring the field.
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.
Two answers to "can people debug this when it does not work".
They could not. A custom UDP row logged "sent 18 bytes to 127.0.0.1:12000"
and nothing about the message — which is a template the operator wrote, so
the byte count says nothing about whether the substitution came out as they
meant. The payload is now in the line, with the trigger that fired it, for
custom rows only: an ADIF record or a binary WSJT frame on every QSO would
bury the log rather than fill it.
Worse was the silent case. A template that is a single placeholder the trigger
does not carry — {freq} on a band change — renders empty, sends nothing, and
looked exactly like a row that had never fired. It now says so, throttled per
row, and points at the field list.
And a password in a URL is redacted before it reaches the file. Storing
http://admin:secret@switch/ as typed was a deliberate choice for LAN gear;
writing it into a log that gets sent to whoever is helping is a different
choice, and was never made.
The Settings section is "Connections" rather than "UDP integrations", since it
has not been UDP-only since the URL transport landed. The log prefix stays
"udp:" on purpose — renaming it would orphan every log an operator has already
sent and every note anyone has written against it.
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.
"WSJT-X logged QSO (Logger32)" reads as Logger32-only, and it is not: the row
speaks the WSJT-X UDP interface, which several loggers listen on. An operator
running something else would have scrolled past a service that would have
worked for them.
Logger32 stays in the hint as the example it is — it is what this was built
against, and its additional sockets are where most people will point it — and
port 2250 stays as the default, a starting point rather than a rule.
"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.
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.
An operator waited over a minute and the button never appeared; opening
Settings and pressing Cancel made it appear at once. That is the whole
diagnosis — Cancel was the only thing that re-read the option.
GetChaseNew returned the cached atomic, which exists for the MQTT goroutine
and is false until startup has read the setting. The UI asked while the
database was still opening, was told "off", believed it, and never asked
again. It now reads the setting, like the grid-chasing binding beside it.
The frontend asks again at the two moments this class of race resolves: when
GetStartupStatus returns, and when the first logbook load succeeds — the seam
that already re-reads the connection label for exactly this reason, with a
comment saying so.
The open/closed state was already remembered per machine; it is now in
PORTABLE_KEYS with the other widget toggles, so it travels with data/ like the
rotor and amplifier panels rather than being the one that does not.
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.
Four things from an operator's first look at the panel, and one of them was
mine.
The frequency column showed "—" on every row. pskr.Spot.FreqHz was declared
and documented in one edit and never assigned in the next, so the payload's
"f" was parsed and dropped. A click therefore filled the callsign and left
the rig where it was, which is half the point of the panel. Assigned, with a
test that runs a real payload through and checks the value survives into the
Spot — the field being declared is what made it look done.
A row now carries ONE indication instead of stacking them. A station can be a
new band and a new prefix at once; the row says the first that matters, in the
order that would make an operator leave what they are doing: entity, band,
mode, slot, then prefix, then square. Two badges on one line made the list
unreadable at a glance, which is the only thing it is for.
Each category has a filter chip in the header, in its own colour, remembered
across sessions. A toolbar button shows the panel and a cross closes it — the
same split the Super Check Partial panel uses, where the setting decides
whether the feature exists and the button whether it is on screen.
The panel is wider and the country column gets what is left, which is more
than it was now that a row carries one badge.
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.
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.
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.
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.
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.
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.
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.
The sampled colours were Warm light's own (#faf6ea / #e8dfc9 / #ddd2b8) —
the annotated screenshot was that theme, and the request written on it was
"just a little darker". Keep the cream on panels and the entry strip, take
the page, tab bar, toolbars and table headers a shade deeper, and move the
accent from burnt orange to ochre so the two are told apart in the picker.
Built from the three sands an operator sent: #faf6eb for panels, #e8dfca
for the page behind them, #ddd2ba for toolbars, table headers and rows.
The ordering is what makes it work — a panel reads as lifted off the page
and the log grid as settled into it, so rows stay scannable without extra
rules. Ink is a deep warm brown; pure black on sand glares.
A second profile is usually the same operator with a different rig or a
different locator — same callsign, same cards. Having to redraw a design
because the antenna changed is work nobody should do.
The designer now lists the designs the active profile cannot see and copies a
chosen one into it. Designs it already sees — its own, and the shared ones —
are left out: they are not something to copy.
A COPY, not a move or a share. The original profile keeps its design untouched
and the duplicate is free to diverge, which it usually will: a second profile
exists because something differs, and that something often ends up on the card.
The PICTURES are copied too, and that is the part worth getting right. Stored
documents reference photos by name relative to the template's own asset folder,
so duplicating the JSON alone would point the new design at files that are not
in its folder. It is validated against its own folder afterwards, and a failure
rolls the row back rather than leaving a design whose pictures are missing.
Sharing the source's folder was the other option and a worse one: deleting
either design would then have emptied the other.
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.
It sat above the first amplifier card. With three cards configured — the case
it exists for — that is a scroll away from anything the operator is looking at,
and it was reported as missing rather than misplaced.
Moved next to Add amplifier: coupling belongs to the SET of amplifiers, not to
any one of them, so it belongs with the other set-level control.
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.
Puts "F4BPO is on air 14,074,000 FT8 IC-7300" on hrdlog.net's front page while
the station is operating, which is what HRDLog's own "Automatic HRDLog ON AIR"
setting does.
The endpoint and its field names come from HRDLog's own library
(github.com/iw1qlh/HRDLOG-net-library, HrdProtocol.SendOnAirAsync): OnAir.aspx,
with Frequency in Hz, Mode, Radio, Callsign, Code and App. Read rather than
guessed — an invented endpoint would have produced a switch that silently did
nothing.
HTTPS where that library uses plain HTTP: the upload code is a credential and
has no business crossing the network in clear, and the sibling NewEntry
endpoint on the same host already serves TLS.
A heartbeat every two minutes, not a change notification — HRDLog drops a
station from the list when it stops hearing from it. Silent when switched off,
unconfigured, or when the rig reports no frequency: announcing 0 Hz would say
the operator is on air on nothing. Failures are logged, never toasted; a
broadcast the operator did not ask for at that instant must not interrupt them
every two minutes on a flaky link.
Its own switch beside auto-upload rather than folded into it: this is a live
status and needs the rig readable, not a QSO.
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.
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.
The same pair already sits in the status bar, where it is 10px and several
operators reported not being able to read it. Repeated under the compass at a
size that can be read across a shack.
Clickable, like the ones in the status bar: a heading you can see and not act
on would be a step backwards from what is already there. Disabled and dashed
when no station is entered, rather than showing a heading to nowhere.
The same answer the Details tab already gave, moved to where the county is now
typed — a badge in a tab nobody has open while working a station is a badge
that does not do its job.
Debounced at 300 ms and keyed on state + county together: the field is typed
into as well as filled by the lookup, and the check queries the log. A county
name means nothing without its state, so both have to settle before asking.
The looked-up values now sit directly under the geography line they continue —
QTH, State, locator, then county and zones — and the free-text fields close the
block. Reading order follows what the lookup fills, rather than interleaving it
with what the operator types.
Comment and Note moving onto one line left the right column two rows shorter
than the left, and this fills it: county, both zones and the ADIF entity
number. All four are filled by the lookup and were only visible after opening a
tab — the wrong place for values an operator wants to SEE are right before
pressing Log.
County is a name, not a code ("St. Tammany Parish"), so it takes the flexible
width and the three numbers get fixed narrow boxes.
Cleared numeric fields store undefined, not 0. Zero is a real DXCC number and a
real zone, so an empty box has to stay empty rather than assert something the
operator did not type.
State stays up on the geography line beside QTH and the locator, which is how
the lookup fills them and how they are read.
stateBlock was built and then not placed in the row — the field simply did not
exist on screen. Now between QTH and the locator, where it was meant to go.
Left column 210 → 230px, and Comment and Note move onto ONE line side by side:
two fields that are usually a few words each were taking two full rows of the
entry strip. That frees a row for whatever goes there next.
Name, Band, Mode and Country go from 300px to 210px, which moves QTH, Comment
and Note left by the same amount. The width is set in TWO places — the Name row
and the Band/Mode/Country stack below it — and they have to stay equal or the
two rows stop lining up, so both were changed together.
State goes between QTH and the locator: it is part of the same "where is this
station" reading, and it is what WAS and the US county work key off. Narrow,
since it holds two or three characters, and upper-cased on the way in like the
other coded fields.
The Name label in this layout was hardcoded English while its twin went through
t(); fixed while here.
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.
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.
Reported on BH2SWB and 4X9AA: both flagged NEW PFX, neither ever worked, both
vanished the moment "hide worked" went on.
The predicate hid a spot when worked_call OR status === 'worked' — and that
second one is the ENTITY's status, not the callsign's. So any spot in an entity
worked years ago disappeared, however new it was for something else.
The extra markers are orthogonal to the entity status; the band map already
says so in as many words. They now win: a new prefix, county, grid or park is
never hidden, whatever the entity says. A worked entity that is new for nothing
is still hidden, which is what the filter is for.
Extracted to spotIsWorked in lib/spotDisplay, beside the other rule the cluster
and band map share, rather than left inline in a predicate that has already
grown once.
This repo has no frontend test runner, so this is verified by build and reading
rather than by a test.
Replaces the four fixed rules with the model Logger32 uses and the operator
asked for: Worked / Confirmed / QSL sent / To be sent, and inside each, which
channels count — paper QSL, LoTW, eQSL, QRZ.com. No ticks means every channel,
because a rule the operator has not narrowed must not quietly become a rule
about nothing.
"To be sent" is FIRST in the order, and that is the substantive decision here.
A contact can be confirmed on LoTW and still owe a paper card; the colour an
operator scans for is the one meaning "something is still owed". Placed after
"confirmed" that row goes green and the card never gets printed.
"Worked" is the catch-all — nothing sent, nothing requested, nothing back — and
is off by default, since turning it on paints every remaining row.
R and Q both count as owed: ADIF says requested and queued, and both mean the
card has not gone out.
Two BandMap instances, and cleaning up a duplicated attribute took the prop off
the docked one as well as the copy. The multi-band tab drew the badge; the map
beside the entry form — the one actually in front of the operator — did not.
Logger32 draws a green square for a LoTW user and an X for a worked one. The
square is the part worth copying; the way it is drawn is not.
Colour on this map is already spoken for — it carries the entity status, and
the left edge of each pill stacks the new-park / new-county markers. Adding a
green fill for LoTW would put two unrelated meanings on one channel, and an
operator would have to work out which green meant what. So it is the same "L"
badge the cluster list draws, in the same muted blue, for the reason written
there: whether a station uploads to LoTW says nothing about whether the spot is
worth chasing.
Worked stations already read as worked here — the pill goes grey and the
status markers speak — so no X is needed.
Switchable in Settings → Appearance, and on by default: a `configured` flag
distinguishes "saved with this off" from "saved before the option existed", so
turning it off sticks instead of being undone by the next default.
The band map received the raw spot stream filtered by band alone, so none of
the cluster's filters reached it: switching on "LoTW users only" changed the
list and left the panadapter showing everyone. The same was true of hide
worked, the spotter continent, and the status and mode chips.
The predicate is extracted and shared rather than copied — two views of one
spot stream disagreeing about the same spot is the fault lib/spotDisplay
already exists to prevent, and a second copy would have drifted the first time
a filter was added.
The BAND filter is deliberately excluded from the shared part: a band map's
band is its filter, and applying the cluster's would empty every map but one.
The first version filled the whole row at 24%, and in a real log that meant
every row was painted: nearly every contact has SOME QSL state, so colour was
present everywhere and stopped being information — a striped background with
the data behind it.
The default is now a 3px stripe down the left edge. Same signal, nothing lost
to read it. A filled row is still offered, with a strength slider, for
operators who want the block — and the rule cards in Settings preview whichever
is chosen, so the decision is made by looking rather than by imagining.
Drawn as an inset shadow rather than a border: a border would shift the cells
three pixels on coloured rows only, and the columns would stop lining up.
Style and strength are normalised rather than rejected — a value out of range
is a slider that got away, not a reason to reset the operator's colours.
The "to send" rule only looked at qsl_sent, so a contact waiting to go to LoTW
— marked R, and the commonest not-gone-out-yet state in a digital log — matched
nothing and stayed uncoloured. It now covers any route still queued, and the
label says so.
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.
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.
Audit of every field a lookup returns against what OpsLog can store, prompted
by a report that HamQTH was not fetching the email.
The email was never broken: pinned now against a captured HamQTH answer, it
parses and reaches the QSO. The reported callsign simply has no public address
on HamQTH, which the empty field could not distinguish from a fault — so the
"[email protected]" placeholder is gone. A greyed-out sample address in the one
field an operator checks to see whether the lookup found one reads as a value.
Real gaps found and closed:
- web: the qso table has had the column since migration 0003 and no provider
mapping ever read it. HamQTH sends <web>.
- picture: Result.ImageURL was documented "QRZ only" because HamQTH's element
is <picture>, not <image>. It was there all along.
- zip: sent by both providers, read by neither.
- adr_name: used only as a fallback when a record carries neither nick nor
name. Deliberately NOT preferred over <nick> — a log wants the name the
operator goes by on the air, "Igor", not "Igor Vladimirovich Getman".
The parse tests use a real captured payload, including the stray <div> advert
the server injects into its own XML.
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.