Three things from reading it on a real screen.
Column rules. The grid alone was not enough to follow a line across: cells
now carry a right border and the row stretches, so the rules run unbroken
from the header to the bottom of the list. That is what turns rows of text
into a table.
Left-aligned. The previous pass centred the grid inside a maximum width,
which on a wide screen opened a dead margin down the left before the first
callsign - trading the hole in the middle for a bigger one at the edge. Now
it fills the width and the slack lands in the message column, which is the
one that can use it and the one bounded by rules on both sides, so it reads
as a cell rather than a gap.
"CQ CQ PE1NAO JO32" - a green CQ badge in front of a message whose own
first word is CQ. The badge is gone; the word already in the line is picked
out instead, which scans the same and stutters not at all.
First pass on the panel from operating feedback.
Columns are a grid template shared by the header row and every data row, so
the two cannot drift and the eye has a rail to follow. It is capped at
1500 px and centred: free-flowing, a 2500 px window put the country a foot
from the callsign it belonged to and left a hole in the middle of every
line.
"New" gets a COLUMN. It was only a coloured edge before, which says
something is special without saying what — and every one of these is a
reason to break off what you are doing and call. The entity verdict is a
solid badge, the orthogonal ones (park, grid, prefix, county) are outlined
in the colours markerColour already gives the cluster list and the band
map, so a new park is the same green in all three. Applied inline because
those are categorical --chart-* custom properties, which the theme does not
expose as Tailwind colour utilities: written as border-chart-7 the badge
would simply have had no colour.
Band and mode selectors now appear only when the feed actually carries more
than one of each. One MSHV is one band and one mode, so for most operators
they were furniture; they show up the day a second instance puts a second
band on the link, which is the only day they mean anything. Same rule for
continent, and a receiver count when more than one instance is feeding.
Added a LoTW-only filter, and raised the type throughout (call and message
to 14 px, secondary to 12 px, badges to 11 px) with more room per row.
The decode payload now carries the sending application's own id. It tells
two receivers apart on one multicast group — and it is the address a
WSJT-X Reply message would have to go back to, so it is carried now rather
than requiring another trip through the parser later.
Every FTx decode WSJT-X, JTDX or MSHV puts on the wire, grouped by T/R
period. Optional and closable, from Tools -> FT decodes; its open state is
remembered, because an operator running digital modes leaves it open for
the session rather than consulting and closing it.
The period is the point, and what separates this from the cluster list.
FT8 is a sequence of fifteen-second slots and a band is read by watching
them go by: who called CQ this slot, who answered, what I was sending while
they did. A flat list sorted by time loses exactly that, so the list is
grouped one section per period, newest first, with the operator's own
transmission shown inside the slot it went out in.
Three fields had to be carried up from the wire to make it possible:
- the decode's OWN timestamp, which the parser read and threw away. It is
what assigns a slot: a period's decodes arrive in one burst a second or
two after it closes, so arrival time piles a whole period into the next
one. Rebuilt to UTC from milliseconds-since-midnight, with the
day-boundary case handled - a decode stamped 23:59:58 arriving at
00:00:01 would otherwise be dated a day ahead and sit at the top of the
list for the rest of the session.
- the decoded line itself. The exchange is what says where a station is in
a QSO, and no set of extracted fields reads like "R-09" does.
- tx_message and transmitting from Status, which nothing parsed before.
Recorded once per message rather than on every Status, which repeats it
about once a second for the whole over.
Also picked up on the way: is_new, low_confidence, off_air, the operator's
own call and grid, and the T/R period itself - better authority on slot
length than the mode name, which says nothing about a custom period. The
Status tail is read defensively: those fields were appended over successive
schema versions and JTDX and MSHV each stop at their own point, so a short
packet is normal and keeps whatever parsed.
Status flags come from ClusterSpotStatuses, the resolver the cluster list
and band map already use, filling the same cache. One verdict per call:
"new band" in this panel and plain worked in the cluster two seconds later
would be worse than no flag at all. Clicking a call goes through the same
handler as a cluster spot, so answering a station is one gesture whether it
came off telnet or off the receiver.
Filters: CQ only, new-anything only, band, mode, continent, an SNR floor
and a free search. The band, mode and continent choices are built from what
is actually on the feed - offering 160 m to a station whose receivers are
all on 6 m is noise.
Decodes are held in the frontend and pruned to a rolling half hour: they
are a live view, not data, nothing outside the panel reads them, and a
night of FT8 on 20 m would otherwise grow a list no filter can rescue.
Arrivals are staged on a 300 ms timer so a period landing as fifty packets
costs one status lookup and one render.
Extends to frequency and mode what PTT already had. A missing FB is not a
missing command: the rig acts on the frame as it decodes it, and what
expires is our wait for the answer, on a bus shared with the rig's own
transceive updates. JTDX in "Split: Fake It" moves the dial and the mode
immediately before every key-down, so those acks queue behind each other.
Losing one is fatal to the client rather than merely untidy: rigctld answers
RPRT -9, JTDX reads that as losing rig control and tears the connection down
mid-over. An operator's log shows three set_freq failures and one set_mode,
each followed within 300 ms by a fresh rigctld client -- and shows the PTT
resend rescuing an over that would otherwise have ended there.
Opt-in per caller rather than folded into exec: only a command that says
"be in this state" can be repeated safely, and a relative one must never
come through here.
The acknowledgement loss itself is still unexplained. Every failure in that
log is preceded by a state read reporting SSB on a rig in DATA, which points
at CI-V frame desync rather than a slow rig, and needs a trace to pin down.
The trx handler stamped its PTT cache BEFORE commanding the radio and left
it in place when the command failed. An operator running JTDX over TCI with
an Icom on CI-V lost an un-key to a lost acknowledgement: the cache recorded
"off" regardless, and from then on every trx:0,false was dismissed as a
repeat of a state the radio had never reached. The cache is per-server, not
per-connection, so reconnecting JTDX changed nothing either -- the
transmitter stayed keyed into the amplifier, with no drive, until the radio
was switched off by hand.
The cache is now written only on success, and a failure clears "known"
outright so the next command reaches the radio whatever it is.
Second guard: releasePTT drops a PTT this server asserted when the client
disconnects, and when the server stops -- before the CAT backend goes down,
while the rig is still reachable. rigctld has had that since a K3 sat in
transmit for 29 s; the TCI server was written without it, so an operator
moving from Hamlib to TCI silently lost the protection. A later log shows
the rig keyed for 40 s across a JTDX reconnect for exactly that reason.
The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its
palette was fixed per theme. Settings -> Appearance now offers the six: the
four status fills, the never-worked fill, and the ring on the cell being
entered.
Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an
--mx-* ramp tuned to its own background, so an operator who only wants a
different green must not thereby freeze the other four to the theme they
happened to be using that day. An empty value means "whatever the theme
says"; the chosen ones are stamped inline on <html>, where they win over
every theme; switching the feature off hands the colours straight back.
The pickers are seeded from what the matrix is painting at that moment
rather than from a fixed palette, so the choice starts from the colours in
front of the operator. A new --mx-cur token carries the current-entry ring:
it follows --warning by default, so it stays theme-correct on all twelve,
but can be recoloured without dragging every other warning in the app along.
The legend under the matrix and its cell tooltips were hardcoded English.
They now go through t() with the same keys as the pickers, so the grid and
the settings cannot disagree about which green is which.
HTTPS to a relay board could not work. Nearly every board that offers it signs
its own certificate — there is no authority anywhere that could have signed it —
so the request failed verification before it left.
A checkbox, per board, off by default. Not a blanket switch, because the other
HTTPS case is real and opposite: a board reached from outside through a proxy
with a genuine certificate, where verification is the only thing standing
between an antenna switch and the internet. Same setting, two boards, different
answers.
Off by default is only safe if the failure explains itself, so a certificate
error now names the box to tick. Go's own "x509: certificate signed by unknown
authority" is accurate and tells an operator nothing about what to do next.
Shown only once an https:// URL is actually in the board's configuration. A
board on plain HTTP has no certificate to argue about, and an option that cannot
matter yet is one more thing to wonder about.
The flag joins the driver cache key: ticking it has to rebuild the driver, or
the cached one would go on refusing the certificate with the verifying client it
already holds.
The boards that take a bare host — WebSwitch, KMTronic — keep verification. An
https:// typed there is the proxy case by construction, since they default to
plain HTTP on the LAN.
Three tests against a real self-signed TLS server: accepted with the box,
refused with a message naming it without the box, and one board's setting not
leaking into another's.
Two faults in the combiner coupling, both reported from the operating position.
THE POWER LEVEL WAS NEVER COUPLED. ON, OFF and OPERATE fanned out to the group;
L/M/H did not — it was simply the command nobody had linked. Two combined
amplifiers left at different power levels feed the combiner unevenly, which is
the thing the coupling exists to prevent.
THE COMMANDS DID NOT LEAVE TOGETHER. The second amplifier was commanded only
once the first had answered, and an SPE answers over its own link in its own
time. The combiner heard power appear on one input before the other and beeped
about it, on every OFF and every ON.
Each target now gets a goroutine, all parked on one channel until every one is
ready; closing it releases them together. That is the difference between "start
one, then start the other" and "both leave at once" — they have separate clients
and separate connections, so nothing downstream re-serialises them.
It matters most on the power level, which is not one command at all: an SPE has
no "set level", so the driver taps the POWER key and waits for the amp to report
the new one before tapping again — up to three taps, up to two seconds each. One
after the other, the pair would sit at different levels for six seconds.
A single amplifier still runs inline: no goroutine, no barrier, nothing new to
go wrong for the operators who have one amp. The one that was clicked stays
first, because its failure is the one worth reporting.
Opening Station Control left the compass blank for a second or two while the
amplifiers, relay boards and power supply filled at once.
Nothing was slow. The status bar keeps the shared heading loop running, so when
the compass mounts there is already a tick pending — up to the full three-second
idle interval away — and the new subscriber simply waited out the rest of it.
The heading was known the whole time and had nowhere to be read from.
The last heading is now kept and handed to whoever subscribes next. That matters
more on the Alpha SPID this was reported on than it would elsewhere: every poll
is an open, a read at 600 baud and a close, so even fetching immediately on
mount would not have been immediate.
In a microtask, so a subscriber is never called back before subscribeRotorHeading
has returned to it.
The one field the editor would not let you touch, and the one that was wrong on
WAJA. Every other property of a reference — its name, pattern, entity list,
validity window — was editable; the code was rendered readOnly, so correcting a
number meant deleting all 47 references and importing a new list, throwing away
anything the operator had adjusted in it.
A rename in the store, not a delete plus an insert: everything the reference
carries travels with it, which is the whole point of correcting a number rather
than replacing an entry. A number already in use is refused — REPLACE INTO would
have let one reference silently swallow another, discovered much later as a
prefecture quietly missing from the list.
The typed code is held apart from the selection. The list and every field patch
key off the selected code, so editing it in place made the editor lose the
reference mid-edit.
SaveAwardReference now recomputes the log like Delete and Replace already did. A
reference's name is what the award column SHOWS for awards displaying by name,
and its pattern is part of what matches at all — so editing one changes rows,
and the grid was left showing the old label until something else happened to
trigger a pass.
Changelog: the three TCI-sharing lines are merged into one. The server and the
two fixes made to it while building are one unreleased feature, and an operator
only ever meets the finished thing. The TCI-client PTT line stays separate — it
is OpsLog driving a SunSDR, the other direction entirely.
The catalog carried Japan's civil prefecture code (JIS X 0401) — 01 Hokkaido,
02 Aomori, 03 Iwate, 04 Miyagi — where the award uses the JARL's own numbering.
The two agree on the first three prefectures and then part company on 35 of the
remaining 44: Tokyo is 10 to the JARL and 13 to the government, Niigata 08
against 15, Toyama 28 against 16.
The names were right throughout, which is why nothing looked wrong: the award
matches on the prefecture NAME in the QTH, so it counted exactly the right
contacts. Only the number against each one was wrong — and that number is what
an operator writes on a JARL claim.
Renumbered by name from the official JARL list, so everything else each entry
carried travels with it. That includes the Tokyo spelling pattern, which had to
move from 13 to 10; left where it was it would have been matching QTHs for
Saitama.
Two digits throughout, as the JARL prints them. Not cosmetic: the codes are
strings, so "1" sorts between "09" and "10" and the reference list appeared in
an order no published list uses.
Version 2 on the definition, so this actually reaches people. An operator who
has not edited WAJA gets it at startup; one who has is offered it, since their
work outranks ours. Nobody has to re-import by hand.
Reported with the official JARL chart alongside the exported award.
Audit prompted by "are we sure the commands are implemented — split, Fake It,
Split rig?". The rigctl server is complete and hardened; the TCI one, three days
old, had reintroduced a bug rigctld had already paid for.
A client working split says two things — where to transmit, and that split is
on — and nothing obliges it to say them in that order. A write to channel B
while the rig was still simplex was DISCARDED, on the sound principle that
preparing a transmit frequency is not a request to QSY. But then the split was
armed on whatever the transmit VFO held, which is the receive frequency: the
operator transmits straight onto the DX while their software shows exactly what
they asked for. The frequency is now remembered and used when the split arrives,
which is what rigctld does with set_split_vfo / set_split_freq.
Two more from the same source:
Asking for a split state the rig is already in touches nothing. A client in Fake
It uses no split but still says so to be sure, and answering an error to a
request that was already true is what made JTDX abandon a transmission a second
into the frame through the rigctl server.
A repeated PTT command is not re-sent. One client restated it sixteen times a
second, and the Flex's own "xmit 1" was overwritten between two of them inside a
millisecond. The same radio sits behind this server — the operator reporting
this is on the Flex API backend.
Fake It itself needs nothing but channel A, and now has a test saying so.
MSHV's PTT test does nothing against the TCI sharing server.
The initialisation block never carried TX_ENABLE. The document files it under
unidirectional control rather than initialisation, so it was missed when the
block was written from §4.1 — but its own note says it is "sent to the client
when connected", and that is the point: a client that models transmit
permission starts out assuming it may NOT transmit. Without it MSHV never even
tries, so nothing arrives to relay and there is nothing to see at either end.
Sent as true always. OpsLog is not what decides — the radio behind whichever
backend is connected does, and its refusal already travels back through SetPTT
into the log.
TX_FREQUENCY goes with it, at connect and whenever the transmit frequency
moves. It is the command a client showing "TX 14.200" reads; channel B alone
left that stale.
And every command a client sends is now logged. This is the only evidence there
will ever be about a program on someone else's machine: "the PTT test does
nothing" cannot be answered without knowing whether MSHV sent trx at all, and
in what form. Cheap — TCI is event-driven, a client speaks when the operator
does something — and capped at 200 lines per connection so one that does poll
cannot quietly fill the log.
If this was not the cause, the next report answers it in one line rather than
another round of guessing.
"PTT via CAT does nothing on TCI." What OpsLog sends is right — trx:0,true; is
the documented command and the same one the reference clients send — so the
command was going out and the radio was discarding it.
ExpertSDR announces transmit permission with TX_ENABLE: on connect, and again
whenever the band changes, "in case transmitter permission was changed" (§4.3).
While it is false the radio simply IGNORES trx. OpsLog never read that command,
so there was nothing in the log, nothing on screen, and a dead key.
Now the permission is tracked and SetPTT refuses out loud, naming where to look:
the frequency must be inside a transmit band and TX enabled in ExpertSDR. The
refusal travels the path that already exists — Manager.SetPTT to pttKey, which
logs it and hands it to the UI.
Silence is not a "no". A radio that never mentions TX_ENABLE — an older
ExpertSDR, or another program speaking TCI — is not treated as refusing: we key
and let it decide. Permission is also forgotten on connect, so a refusal
remembered from a band since left cannot block PTT until a restart.
This may not be the operator's own cause, and that is the other half of the
change: if it is not, the log now settles it in one line. cat: TCI: → trx:0,true;
present means the command left OpsLog and the radio ignored it for a reason it
has not told us; absent means the fault is on this side.
Wanted for the case where the answers are moving: an operator correcting their
own QRZ record, or chasing a DXpedition whose page changes during the operation,
otherwise waits out thirty days before OpsLog will ask again. Clearing the cache
by hand works once; this is the setting for a whole session.
Nothing is read from it and nothing is written to it — rows stored while it is
off would only sit there going stale, waiting for the day it comes back on.
Switching off is NOT clearing: what it already holds stays, and the Clear cache
button remains the way to throw that away.
Two distinctions the code now has to keep, both load-bearing:
An EXPLICIT stored zero is off; an ABSENT key is the thirty-day default. Every
operator who has never opened this setting has nothing stored, and reading that
blank as a zero would silently switch the cache off for all of them.
The CONSTRUCTOR's zero is still the default, not off. At startup the settings
have not been read yet, and beginning with no cache would hammer the provider
for the first seconds of every launch. Only SetTTL, called once the operator's
settings are known, can switch it off.
A negative lifetime is meaningless and is ignored rather than rounded into
either meaning.
The input had to change too: it derived its value from the stored number on
every keystroke, so the box could not be emptied — and 0 was unreachable
outright, since parseInt('0') || 30 is 30.
internal/rigctld exists because Windows gives a COM port to ONE process: the
moment OpsLog talks to the radio natively, nothing else can. It answers the
programs that speak Hamlib NET rigctl. This answers the ones built around Expert
Electronics' TCI instead — and it answers them whatever radio is connected,
because it sits on the same backend-agnostic Rig interface. An operator with an
Icom or a Yaesu can now hand a TCI-only program a working rig.
One server or the other, never both. They answer the same questions about the
same radio, nothing speaks both, and a second listener is only a second thing to
go wrong.
Written against the official TCI Protocol document (ExpertSDR3/TCI, 12 January
2024, MIT — downloaded and read, not recalled): the initialisation set of §4.1
in its documented order, and the argument order of every command from §4.2. A
client will not proceed past connect without that block, which is why it is
written out in full rather than stubbed.
The one dangerous detail is the VFO mapping. TCI's channel A is where you
LISTEN and channel B where you transmit — the opposite way round from OpsLog's
RigState, which follows ADIF. Getting that backwards would put a station on the
DX's own frequency, so it is pinned in both directions by a test, and RxFreq was
added to the adapter rather than inferred.
Writing channel B while the rig is simplex is ignored: the client asked to
prepare a split transmit frequency, not to QSY, and a logger doing that on every
spot click would drag the operator off the station they were listening to. A
backend that cannot split still refuses out loud.
Only changes are pushed. TCI clients redraw on each command, so re-sending an
unchanged frequency four times a second makes a VFO readout flicker and fights
the operator's own tuning.
Nine tests, no socket needed — the protocol is the decision, not the transport.
QRZ carries <iota> only for the operators who filled it in, and most have not.
But for 99 entities the reference follows from the entity alone — a station in
Ascension is on AF-003 whatever its callbook record says — and the entity is
known for every callsign from cty.dat, with no callbook at all.
So this reaches the operator with no QRZ subscription and the station that has
never touched a callbook, and it lands before the contact is logged, which is
when a reference is worth having.
Filled ONLY when the callbook gave nothing: an operator who typed a reference
knows something a table cannot — an IOTA-heavy entity, a rare island being
activated — and that still wins, as does one picked by hand on the entry.
The table is the IOTA programme's own dxcc_matches_one_iota.json, which lists
exactly the entities that map to ONE reference. France is not among them: a
French station is usually on the mainland and on no island, and guessing would
put a reference on hundreds of contacts that earn none.
Held as a table rather than a download — 3.5 kB that changes when an entity
appears, so fetching it daily would buy nothing and would fail exactly where a
portable station usually is. The header says where it came from and how to
refresh it.
Follows 747c2b9 and eab11db, which read the callbook's own tag.
Spot mirroring drew the same station two or three times, a few hertz apart.
Not a loop in the spot pipeline — one cluster line produces exactly one
SendSpot. It is that a popular DX station IS spotted two or three times, by
different operators inside the same minute, and no two of them agree on the
frequency to better than a few tens of hertz. This backend assumed ExpertSDR
replaced a spot bearing a callsign it already held, and said so in a comment;
it keys on the frequency too, so each of those became its own marker and stayed.
The FlexRadio backend has always done this properly (spot remove before spot
add, "one live spot per call"). TCI now does the same: the frequency drawn for
each call is remembered, a re-spot within 500 Hz sends nothing at all, and one
further away deletes the old marker before drawing the new one — so a station
that really moves still moves, instead of collecting markers.
SPOT_DELETE was not in the protocol document this backend was written from.
Confirmed before use against ars-ka0s/eesdr-tci, an independent TCI library that
lists SPOT with 5 arguments, SPOT_DELETE with 1 and SPOT_CLEAR with 0 — the
other two matching exactly what already works here, which is what makes the
third trustworthy.
The memory is cleared where Connect sends spot_clear. Left standing, it would
suppress the next spot for every remembered call as "already drawn" onto a
panorama the radio had just emptied — spots would quietly stop appearing after
a reconnect until each station changed frequency.
Five tests on the decision, which is split out from SendSpot so it can be
checked without a radio.
The three layers underneath were already there and unreachable: the change-log
format (7d664bd), the identity column (724e68b) and the no-backfill decision
(c48294b). This is the wiring that gives the operator a switch.
Settings, all profile-scoped, because each profile can point at its own logbook:
the folder, this PC's name, the machine id minted once from it, the per-peer read
offsets and the tie-break counter. Scoping the machine id is what keeps two
profiles sharing one folder from writing two logbooks into one file.
Three hooks. Add and update publish asynchronously, down with the rest of the
after-the-fact work — a folder on a network share can block for seconds and a
contact belongs on screen long before another machine hears about it. Deletion
publishes SYNCHRONOUSLY and BEFORE the row goes, for the same reason
deleteRemoteCopies does: once it is gone its identity is gone with it and the
tombstone names nothing.
The apply path uses the repository directly and never AddQSO/UpdateQSO/DeleteQSO
— those publish, and a change applied here would be written straight back out,
two machines echoing each other for ever.
Saving writes a probe file to the chosen folder rather than asking whether it
exists. A read-only cloud folder, or a share whose credentials expired, exists
perfectly well and would swallow every contact in silence; if the probe fails the
switch goes back off instead of sitting on while nothing is written.
The panel is mostly status, and deliberately: every part of this runs on another
machine and on a sync client OpsLog cannot see, so "it is not working" has to be
answerable from the settings page — which PCs are in the folder, when each last
logged, what is waiting unread.
Four tests on the apply path, the middle two being the ones that matter: an edit
made on the other PC lands on the copy already here, matched on the contact
itself, instead of becoming a second row — that is what makes the no-backfill
decision safe — and a contact with the same station on another band stays a
separate contact.
withIOTARef was applied inside fillFromLastQso, the backfill that runs when the
provider came up short. That function returns early when there is no previous
contact with the callsign — so the island reference appeared for stations
already worked and never for the new one calling from the island, which is the
entire point of reading QRZ's <iota>.
Moved to the provider result in runLookup, where the rest of the callbook fields
are applied. The precedence is unchanged: a reference the operator typed or
picked still wins over the callbook.
Reported on F5IRH, Le Palais, Belle-Île-en-Mer — EU-048 in the QRZ record, and
nothing on the IOTA award.
No changelog line: the feature has not shipped yet, so the entry already in the
0.25.7 block now describes what it does.
The four buttons on the left change WHICH settings database is in use; the two
on the right act on the file already there. Splitting them by that line makes
the row read as two groups instead of six equal choices, and it stops the pair
wrapping to a ragged second line under the others.
ml-auto rather than a second container, so they still wrap gracefully when the
panel is narrow.
Also removed: "OpsLog can copy the SQLite database to a folder of your choice
when you close it, once per day. Rotation keeps the last N copies…" — everyone
knows what a backup is, and the controls underneath say the same thing in their
own labels. Gone from both dictionaries, and the bilingual test passes.
Text only — every feature stays exactly as it was.
gen.mwDesc what a Most Wanted rank is
bk.hintMysql what the on-close backup does with a MySQL logbook
db.profileHint that the logbook follows the active profile
db.mysqlHint that several OpsLogs on one MySQL see each other live
They explained things an operator already knows, in a settings panel that has
plenty to read as it is. The backup header keeps its ordinary hint; only the
longer MySQL variant goes.
Each removed from BOTH dictionaries: a string left in one and dropped from the
other shows the bare key in the other language, which is what
TestEveryStringIsInBothLanguages exists to catch — and it passes.
Reported with the evidence side by side: a QRZ record plainly carrying
<iota>EU-048</iota>, and no IOTA on the entry.
Adding a field to the lookup cache leaves every row already in it without that
field — and the cache lasts thirty days. So the change worked perfectly for a
callsign never looked up before, and did nothing at all for one already cached,
which is every callsign an operator actually works.
A row written before the column existed has NULL there, and is now treated as
stale: one refetch per such callsign, the next time it is used.
NULL and "" had to be made to mean different things for that. Put no longer
passes the reference through nullable(), so an operator with NO island stores an
empty string — otherwise every ordinary callsign would look unwritten and
refetch for ever, turning a cache into a tax on every lookup. Both halves are
tested.
Same trap for web and zip, added the same way in 0026 and never noticed.
Correcting an over-design of my own. I had contacts given an identity in bulk
the first time synchronisation was switched on, and a full dedupe-key map of the
logbook rebuilt on every pass, both sized for 123 000 contacts.
Neither is needed, because the change log starts EMPTY. Contacts logged before
synchronisation was switched on are never in anyone's log and so are never
exchanged; an identity is stamped only on a contact that is actually logged,
edited or deleted from then on. Seeding a second machine with the existing
history is a one-time copy of the database or an ADIF import — not something
synchronisation should be doing, and not something it can do from a file that
starts empty.
One case survives, and it is why the dedupe key stays. Two machines can already
hold the SAME old contact, the second seeded by that copy or import, with
different row ids and no identity on either. The day one of them edits it, it
stamps an identity and sends a change naming it; the other has never seen that
identity and would insert a duplicate. Matching on the contact itself —
callsign, minute, band, mode, the importer's own key — recognises it.
So that became a targeted lookup through idx_qso_callsign, run only when an
identity is unknown, instead of a whole-table map rebuilt each pass. Rare work,
priced as rare work.
QRZ's XML carries <iota>EU-048</iota> for an operator on an island, and OpsLog
read past it. Wired end to end: the provider, the lookup cache (a column, like
web and zip before it), and the entry's award references.
It matters more here than the same field would for another award. There is no
live "who is on an island right now" feed anywhere — POTA has one and that is
what OpsLog matches spots against; SOTA has one behind conditions; IOTA
publishes only static lists. So the callbook record is the practical source, and
it is known BEFORE the contact is logged, which is when a reference is useful.
The reference goes in as an IOTA award reference, exactly as one picked by hand,
so the existing path carries it to the qso.iota column on save.
Two limits, both deliberate. A reference the operator typed or picked WINS: a
callbook entry can be years out of date, and the operator in front of the radio
has just been told where the station is. And the value must look like an IOTA
reference — two letters, a hyphen, three digits — because writing anything else
into the award makes a reference no list contains, which counts for nothing and
has to be found by hand later.
Two PCs exchanging changes through a folder must be able to name the SAME
contact in both logs. "the QSO with M0ABC at 14:32" is a guess and the two
machines can disagree about which row that is, so an edit or a deletion cannot
be addressed at all without an identity that travels with the record.
A real indexed column, not a key inside extras_json: the identity is resolved
once per incoming change, and scanning JSON for it would turn every sync into a
full read of a 120 000-QSO logbook. A column costs one migration; the JSON would
cost a scan every time. That was the decision to confirm, and it is confirmed.
It follows the award_refs pattern — read in selectCols, absent from columnList,
written only through its own methods. So an ordinary edit cannot clobber it,
which matters: another machine addresses the contact by that id, and losing it
makes the same QSO arrive again as a new one. Pinned by a test that saves an
edit with the field deliberately blanked.
Existing contacts are stamped in batches inside one transaction rather than one
commit each — on a remote MySQL the round trip dominates, and 120 000 commits is
the difference between a minute and an afternoon. And a dedupe-key map lets two
machines that already hold the same imported log recognise each other's contacts
instead of copying 120 000 of them across.
MySQL nearly lost its logbook to this. It cannot index a TEXT column without a
prefix length: the migration would fail with error 1170 and fail again on every
startup, with no way out from the interface. The translator only emits VARCHAR
for names listed by hand in varcharColumns. sync_uid is now listed — and a test
reads the migrations, works out which indexed columns are TEXT, and fails if the
list does not cover them, so the next one cannot reach a shared logbook.
The shape we agreed: each PC keeps its own local SQLite and they exchange
CHANGES through a folder the operator already has — Seafile, OneDrive, a NAS, a
USB stick. Not the database file: SQLite over SMB or NFS corrupts, and in WAL
mode the shared-memory index has no meaning across machines at all.
The rule that makes a shared folder safe is one writer per file, append only.
Each machine writes only its own <machine>.ndjson and never touches another's,
so a sync client that replicates whole files can never merge two writers into
one — there are never two. That is the exact opposite of putting the database
there, and it is why it works.
This is the half internal/offlineq deliberately refuses to be: its own doc says
"no mirror, no pull, no merge, no tombstones". All four are here.
Four decisions worth naming, each with a test:
Tombstones. A delete is a record. Without one, a QSO removed on the laptop comes
back on the next sync from the shack PC, for ever — and a contact logged again
after a deletion has to come back, which the ordering also has to allow.
Determinism. Last writer wins, ordered on (time, the writer's own counter, the
writer's id) — not on time alone. Two PCs' clocks are never equal, so a bare
timestamp is not even a total order: two machines merging the same pair in
different orders could reach different answers and disagree for ever. Skew still
decides WHICH edit wins and nothing can fix that; what this guarantees is that
every machine agrees on the winner.
Resumption. Readers resume at a byte offset, so a 40 000-contact file is read
once and thereafter only its tail — and the offset advances only past COMPLETE
lines, because a folder sync catches a file mid-upload sooner or later. A file
that SHRANK was replaced rather than appended to, and is re-read from the start.
Survivability. One unreadable line costs one record. A record stamped with a
FUTURE format version is skipped, never guessed at.
Core only: no UI and nothing wired to the logbook yet, so nothing is
user-visible and there is no changelog entry. Next is the sync_uid column, the
add/update/delete hooks and the settings panel.
Two faults behind one screenshot: 3Y0K, recorded in the log as Bouvet Island,
showing the Antarctica matrix.
SELECTING A QSO now shows what that QSO records. The panel asked WorkedBefore
with no DXCC hint, and a hint of zero makes the backend resolve the entity from
cty.dat and the ClubLog exceptions AS THEY ARE TODAY — right for a live contact,
wrong for one being looked at in the log. The repo's own "infer from past QSOs"
path never ran, because the hint was no longer zero by the time it got there.
Hence Antarctica, and "5 QSOs with this entity" against "11 with this call":
two different entities, one of them nobody had asked about. The selected row's
dxcc now travels with it and is passed as the hint.
Browsing the log shows what the log says, even where the log is wrong.
Correcting an entity stays a deliberate act — right-click, Update from ClubLog.
BACK-ENTERING A QSO now resolves the exception at the CONTACT'S date. An
exception carries a validity window and a DXpedition's window closes: 3Y0K typed
months later, with the activation's own date in the form, was resolved against
today, matched nothing, and fell back to cty.dat. Both callers pass the date
they already hold — the entry strip's, and in the editor the record's own.
The date is trusted to move the resolution BACKWARDS only. A half-typed
"2026-0" must not send the lookup to the year 20, and a mistyped future date
must not resolve against a window that has not opened; both fall back to now.
Midday rather than midnight, because a window given in whole days is inclusive
of its end date and 00:00 sits exactly on the boundary.
Reported from a photograph of a French screen: "Spot lifetime" and "Chase new
grids" still in English in the DX Cluster settings. Diffing the two dictionaries
turned up fifteen more — including the ENTIRE update panel, which is why an
operator who reads French had nothing in French to react to when a new version
appeared.
All seventeen translated. Both dictionaries now hold 2685 keys with no
difference either way.
Every user-visible string ships in both languages: that was a rule, and until
now only a rule. A test parses the two dictionary literals and compares their
keys, so the next one is caught by `go test` instead of by an operator
photographing their own screen. Verified it fails when a key is removed.
A key missing from one side is not a blank — it falls back to the key itself,
so the interface reads "clu.spotTtl" where a label belongs, or keeps the English
text, which looks deliberate and is not.
Reported from a screenshot: working ZA/OE8NDR, the worked-before list showed
ZA/IZ2DPX and ZA/IW2JOP beside him and the header counted all four as contacts
"with this call".
callMatch folded portable forms together by taking the part before the slash.
That is right when the slash carries a suffix — RK3DWA/3, RK3DWA/P are one
person — and exactly wrong when it carries a prefix: ZA/OE8NDR became the
station "ZA", and the predicate `callsign LIKE 'ZA/%'` then selected every other
visitor to Albania.
The operator's own call is now picked out properly: drop the known qualifiers
(P, M, MM, AM, QRP, a bare call-area digit) and of what remains take the longest
part — a country prefix is short by nature, ZA, F, KH6, VP2E, and a callsign is
not. The predicate matches the prefixed forms too, so ZA/OE8NDR and a plain
OE8NDR still find each other, which is the whole point of the fold.
A base under three characters falls back to an exact match: a malformed entry
must not produce a LIKE that selects half the logbook.
"In this award's scope but with no reference" needs a scope to be in. On a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every contact anywhere
would qualify, so the backend returns nothing and the window says it found
nothing. The button was there regardless, and could only ever open that.
It now appears only for an award scoped to a DXCC entity, which is what the
help text underneath used to have to explain. Better than what I did first,
which was to make a useless screen fast.
Kept from that: the assign dropdown is searched rather than scrolled above 300
references, and the reference list is not fetched until there are rows to
assign it to. Both still bite on a scoped award with a long list — Russian
districts, the bigger European ones.
Reported as POTA: missing references, and it hangs.
The Missing-refs modal builds a dropdown of every reference the award has, one
menu item each. A POTA log with the park list imported holds tens of thousands
of them, and putting that many items in the DOM stops the window answering.
Russian districts and the bigger European lists are the same shape.
Two things, and the second is the sharper one.
The list is now searched rather than scrolled: above 300 references a filter box
appears beside the dropdown, and the menu renders at most that many, saying how
many it is holding back. Nobody scrolls to K-4521 — they type it.
And the modal no longer fetches the references at all until there is something
to assign them to. Missing-reference detection needs a DXCC scope, so a
worldwide award like POTA always has zero rows here and the modal says so — it
was loading every park behind that message, for a dropdown that could not be
used for anything. The freeze happened on a screen with nothing to offer.
Whether this is the freeze that was reported I cannot say from here; it is a
freeze on exactly that screen, for exactly that award.
A field log showed three pulls of the same 123 615 QSOs inside ten seconds, the
Go heap going 725 MB → 2213 MB → 2539 MB. It is released after the idle TTL, so
not a leak — just the same work done three times with all three results alive at
once.
The cache lock was released before the logbook was read, so every caller that
arrived during a build missed the cache and started its own. Opening the Awards
panel does exactly that. One builder at a time now, with the usual re-check
after taking the lock: the second and third caller wait out the seconds they
were going to spend anyway, minus two round trips to the database.
The slice is also sized from the previous build. Growing to 123 000 structs by
doubling copies the whole thing a dozen times and holds the old and the new
array together at each step, on the largest object OpsLog keeps.
Also, the settings panel registry. The hook trap that broke the window when the
power-supply panel was opened was headed off by a comment, and the comment did
not survive contact with the next panel — so it is now structural. PanelHost is
a module-scope component that calls the selected panel and is keyed by section:
hooks inside a panel land in a component context that persists, section changes
remount it so each gets a fresh and consistent hook list, and no panel state
leaks into the next.
Rendering the panels as <Panel /> instead — the obvious refactor — would have
been wrong: they are nested inside SettingsModal and close over its state, so
each parent render makes a new component TYPE and React would unmount and
remount the panel on every keystroke.
v0.25.6 went out with its nine entries; the block is byte-identical to the
tagged one. The version constants are left alone — the release script is the one
source that bumps them, and touching them here would make its "release commit
already exists" check misfire.
Asked for the wiki to catch up, and to be explicit — examples rather than
descriptions. New page and six rewritten sections.
Recent QSOs and Filters, the page named in the request: the right-click menu is
now a table of what every entry does, and the filter builder has five worked
multi-criteria filters written out field by field, each ending in what you then
do with the result. Including the distinction that prompted it — "export
selected" takes the highlighted rows, "export filtered view" takes every QSO the
filter matched, and after building a filter it is the second one you want. Plus
the traps: ADIF date format, and "is empty" not being "equals N".
Connections (new): the page for a settings section that had none. What OpsLog
listens to and sends, which outbound row another logger actually wants — a
logger asking for "WSJT-X UDP" discards a bare ADIF record — the custom-message
triggers with their placeholder lists, three worked examples, and the port-clash
warning that produced this week's CAT-drop report.
Amplifiers and Switches: the generic HTTP relay in full — per-relay URLs, the
pattern form, {relay}, {relay-1} and {value} with what each one sends — relay
automatic control with a four-relay antenna-switch example, and the bench power
supply including why OpsLog writes only its output.
Rotators: the two new native types (SPID with the Rot1Prog/Rot2Prog choice,
DCU-1), two-rotor setups, and that Test only reads the heading on the types
where it does.
Awards: the US county database, and the difference between the automatic fill
(blanks only) and the right-click re-derive (replaces) — with the Connecticut
planning regions, which is the case that needs it.
QSL Management: QSL_VIA against QSL_SENT_VIA/QSL_RCVD_VIA, why an import shows E
on every Log4OM record, and that V counts as confirmed.
Troubleshooting: the three field reports of the last few days, by symptom.
Every [[link]] checked to resolve.
Asked whether the faster poll applies to all the rotator backends. It does —
GetRotatorHeading is one binding over PstRotator, Rotator Genius, GS-232/ARCO,
DCU-1 and SPID alike, and it reads only the ACTIVE rotor, so two towers do not
double it. But counting what that costs turned up two things worth fixing.
Every backend builds a fresh client per call — spid.New, gs232.NewSerial,
dcu1.NewSerial, rotgenius.New — so one poll is one OPEN and CLOSE of a serial
port or a TCP connection, not a read on a link already up. And the status bar
and the Station Control compass each ran their own interval against that same
binding, so with the tab open the controller was asked twice over. At the 700 ms
I had just set, that was nearly three port opens a second on a 600-baud line.
Both now share one loop, and it adapts: 500 ms while the position is changing,
3 s once it has been still for six seconds. These controllers do not report
"moving" — a SPID answers a position and nothing else — so it is inferred from
the position itself, and held briefly after the last change so the tail of a
movement stays smooth. Commanding a move, a stop, or switching rotor polls at
once, so the needle starts sweeping on the click.
A slow controller cannot stack requests behind itself either: at 600 baud a SPID
reply takes a fifth of a second on the wire alone, and a port still open from
the last poll cannot be opened again.
Field report from a tower: on a RAK/RAU in Rot1Prog, every commanded heading
made the antenna want to turn nearly a full circle ANTICLOCKWISE — 0°, 90°, any
of them — while the heading readout, the stop button and everything else worked.
BuildSet framed the four-digit Rot2Prog azimuth for both dialects. A Rot1Prog
reads three: its replies are three digits in a five-byte frame, and its command
field matches. So 90° went out as "0450" and was read as 045 — 45 − 360 = −315°.
Every target landed 360° low, which is why it was always anticlockwise and
always nearly a full turn. The operator's own guess, that OpsLog was in 720°
mode, was the right instinct in the wrong place: the fault is a decimal shift,
not a range.
The round-trip test added here is the one that would have caught it without a
tower — every degree of the circle through the command builder and back through
the reply parser, which must return the degree that went in. The frame tests
pinned the Rot2Prog form against the reference and said nothing about the other
dialect.
Two more from the same report. A rotator test that only READS the heading — SPID,
ARCO, DCU-1 — said "Packet sent, the antenna should swing to north, check
PstRotator's UDP listener", naming a program not in the path for a move never
commanded; it now says the controller answered and nothing was moved. And the
compass polled every three seconds, so a turning antenna moved the needle in
steps of about thirteen degrees; the heading now has its own 700 ms tick while
the relay boards and the antenna controller stay at three seconds.
It held a useState and a useEffect of its own, and the panels in that registry
are called as plain functions — PANELS[x]() — not rendered as elements. So its
hooks landed in SettingsModal's own hook list, and only while that section was
open: React counted more hooks than the render before and stopped drawing the
whole window (error #310).
The file says so three lines above the entry I added, on the two panels that DO
carry hooks and are therefore wrapped in JSX. Reading it would have been quicker
than the screenshot.
No hook here now. The COM ports come from the list SettingsModal already loads
when it opens, for the Winkeyer panel — one machine, one set of serial ports,
fetched once instead of twice.
The manufacturer's document arrived, so this is no longer guesswork: 9600 8N1,
function codes 03 and 06 only, and a register map with the output on/off at
0x0001, the measurements at 0x0010…0x0013 and the set points at 0x0030/0x0031.
ONE REGISTER IS WRITTEN — 0x0001, the output. The map also exposes the voltage
and current set points and the three protection trip levels as writable, and
none of them belong to a logbook: a wrong value there is 30 V where a radio
expected 13.8, or a trip level lifted on a supply feeding an amplifier. They are
read and displayed instead, next to the measured values, which is also how an
operator sees at a glance that the supply is on and the radio is drawing nothing.
The wire layer is tested where it can be. CRC-16/MODBUS is pinned against its
published check value — the CRC of "123456789" is 0x4B37 — which fixes the
polynomial, the initial value, the reflection and the absence of a final xor all
at once; the rest of the protocol is checked frame by frame against the manual,
including the byte order of the CRC, an exception reply told apart from a broken
line, and a reply from another slave on the bus refused. The write echo must
match the value sent: it is the only confirmation the output really switched,
and accepting the frame without it is how a radio ends up dark behind a green
light.
Framing follows the manual's own rules: 3.5 character times of silence between
frames (4 ms at 9600), and a frame is over when the line falls quiet — Modbus
RTU has no terminator, and a serial read that times out returns (0, nil) here,
so the reader is built on a deadline and a quiet-time rather than on an error
that never comes.
Untested against hardware — nobody here has the supply.
Reported as JTDX "Fake It" causing CAT disconnections. Fake It is not the
cause — it is what made an existing loop fire, and a regression of mine from
this morning is what closed that loop.
The station has an inbound remote-call row and an outbound N1MM RadioInfo row
on the same port, 2241, so every RadioInfo datagram OpsLog sends arrives
straight back on the loopback. The remote-call parser strips XML tags and takes
the last token as the callsign: for a RadioInfo that is
<ActiveRadioNr>1</ActiveRadioNr>, i.e. "1" — and <Freq> became a tune request
for the frequency the rig was already on.
That was harmless only while <Freq> was misread. It is in tens of Hz, the parser
assumed MHz, every echoed tune failed "out of the 11-digit CAT range", and the
loop died on the error. Teaching it the unit (331db58) closed the loop.
Fake It shifts the dial for each over rather than using split, so every
transmission changed the state twice — and each change published a RadioInfo,
which came back as a set, which changed the state again. The log shows the dial
oscillating 24915000/24915500 three times in 200 ms, then "timeout answering
IF;" and the shared link down. Every over, all morning.
Two guards, because one was not enough to be sure: a RadioInfo payload is never
a remote-call request, and a callsign has a letter in it — so the next program
to broadcast its state on that port cannot drive the rig either. Reload also
names an inbound and an outbound row sharing a port, which is the arrangement
that permitted this and which no settings panel shows.
Relay automatic control and the band-change outbound rows both hang off the rig
state, which is right when there IS a rig: changing Band in the entry strip
pushes a QSY, the new state comes back through the CAT callback, and the relays
follow from there — which is why this works for anyone with rig control.
Without a CAT connection nothing is pushed and nothing comes back, so a station
whose rig OpsLog does not control changed band and the antenna switch sat
exactly where it was. Reported as automatic control not working; it was never
told the band had changed.
The entry selector now says so itself, but only when the CAT push did not
happen, and never when the band or frequency lock is on: a lock means the entry
is deliberately decoupled from the rig, and moving an antenna to match a contact
logged from last year is worse than doing nothing.
The frequency is passed as unknown on that path — a band selector gives a band
and nothing else, and rules written on a frequency RANGE are left alone rather
than evaluated against a made-up dial reading.
The de-duplication of "is this a new band" is now shared by both sources, so a
station that has both does not command its switch twice for one QSY.
Also: the same credit line as the QSL e-mail now closes the default recording
e-mail body, on the same terms — a default in the template, so a stored one is
untouched.
"Designed & sent by OpsLog — https://github.com/GregTroar/OpsLog/releases/latest"
now closes the default body, so a fresh install sends it without being asked.
Put in the TEMPLATE, not appended at send time. Two consequences, both of them
the point: an operator who does not want it deletes the line once and it is
gone, and one who has already written their own body never sees it appear —
a stored template is returned verbatim and the default is only the fallback.
Appending it at send would have made it unremovable, which is not a thing to do
to somebody's outgoing mail.
The link is the human release page. updateCheckURL sits next to it in update.go
and answers JSON, which is the easy mistake here — a correspondent who clicks
the credit gets a download page, and a test says so.
Reported as delayed auto-upload breaking Recent QSOs. It does not — the grid was
waiting on the upload for its news.
Nothing reloaded the list on qso:logged. The entry form calls refresh() itself
after AddQSO, so a QSO TYPED in OpsLog always appeared; a QSO logged from
anywhere else — WSJT-X or MSHV over UDP, JTAlert, NET Control, an offline replay
— reached the grid only when something else reloaded it, and the something else
was extsvc:uploaded. With auto-upload set to immediate that arrives a second
later and hides the gap completely.
Set a service to delayed and the QSO appeared one to two minutes late; set it to
upload on close and not until the next launch. So the very thing deferring an
upload exists for — correcting a contact before it goes out — was impossible,
because there was nothing on screen to correct.
The list now follows the log, on the same debounced path the upload events
already use: qso:logged fires twice per contact (insert, then award refs) and a
contest run fires it every few seconds.