Commit Graph
960 Commits
Author SHA1 Message Date
rouggy 0bab7f05b9 feat(relays): accept a self-signed certificate on a generic HTTP board
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.
2026-08-17 10:57:29 +02:00
rouggy dc898ce2af fix(amps): combined amplifiers are commanded together, power level included
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.
2026-08-17 10:42:19 +02:00
rouggy e5c9ca1a7d fix(rotator): the compass shows the heading it already knows
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.
2026-08-17 10:32:06 +02:00
rouggy 21a0d560de feat(awards): a reference's number can be corrected in the editor
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.
2026-08-17 10:26:13 +02:00
rouggy 2941121f4b fix(awards): WAJA was numbered by the Japanese state, not by the JARL
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.
2026-08-17 10:17:28 +02:00
rouggy 6c75ff345b fix(tci server): split armed on the frequency asked for, in either order
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.
2026-08-17 10:00:57 +02:00
rouggy d5e25244ee fix(tci server): announce transmit permission, and log what the client sends
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.
2026-08-17 09:39:02 +02:00
rouggy 4095455e66 fix(tci): a refused PTT is reported instead of vanishing
"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.
2026-08-17 09:30:36 +02:00
rouggy 3f95763ca6 feat(lookup): a cache TTL of 0 switches the cache off
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.
2026-08-17 09:21:12 +02:00
rouggy 72ec3cbb97 feat(cat): share the rig over TCI as well as Hamlib — one or the other
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.
2026-08-17 02:35:21 +02:00
rouggy a8bca8c316 feat(awards): an entity that IS one island group fills its own IOTA reference
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.
2026-08-17 02:26:52 +02:00
rouggy 8b75166dbe chore: open 0.25.8
Version constants stay at 0.25.7 — the release script bumps them.
2026-08-17 02:20:56 +02:00
rouggy d9e03d9de3 chore: release v0.25.7 v0.25.7 2026-08-17 02:19:36 +02:00
rouggy 491546c7f4 fix(tci): one marker per callsign on the panorama, not one per spotter
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.
2026-08-17 01:57:11 +02:00
rouggy 91b21a4a36 feat(sync): the folder loop, the hooks and the panel — it can be switched on now
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.
2026-08-17 01:34:00 +02:00
rouggy eab11db766 fix(awards): the callbook island only reached stations already in the log
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.
2026-08-17 01:14:30 +02:00
rouggy 0c64bdcaf9 ui(settings): Open folder and Reset to default sit right, and the backup hint goes
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.
2026-08-17 01:01:26 +02:00
rouggy f0d7a07ed6 docs(settings): drop four explanatory paragraphs
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.
2026-08-17 00:55:24 +02:00
rouggy 2aceb99948 fix(lookup): a cached callsign kept its missing IOTA for thirty days
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.
2026-08-17 00:48:49 +02:00
rouggy c48294b0ca refactor(sync): no mass backfill — identities are stamped on what is touched
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.
2026-08-17 00:42:59 +02:00
rouggy 747c2b9105 feat(lookup): read QRZ's island reference and fill the IOTA award
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.
2026-08-16 23:22:37 +02:00
rouggy 724e68b38d feat(sync): a stable identity per contact, indexed
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.
2026-08-16 23:16:52 +02:00
rouggy 7d664bd1de feat(sync): the change-log core for one operator on several PCs
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.
2026-08-16 21:44:14 +02:00
rouggy 93732ee563 fix(entity): show the QSO's own entity, and date the ClubLog exception
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.
2026-08-16 21:34:15 +02:00
rouggy 5293bb18c5 fix(i18n): seventeen strings had drifted into English only
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.
2026-08-16 18:25:26 +02:00
rouggy 14ac73028e fix(worked-before): a prefixed call is one operator, not a whole country
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.
2026-08-16 18:08:28 +02:00
rouggy b5a88ee5a2 fix(awards): hide Missing refs where it cannot mean anything
"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.
2026-08-16 18:04:34 +02:00
rouggy e81500f809 fix(awards): Missing refs froze the window on a big reference list
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.
2026-08-16 17:18:45 +02:00
rouggy 37805fe3ed perf(awards): build the logbook snapshot once, not once per caller
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.
2026-08-16 14:40:18 +02:00
rouggy 7578e49573 chore(changelog): open 0.25.7
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.
2026-08-16 14:20:22 +02:00
rouggy 564d97b692 chore: release v0.25.6 v0.25.6 2026-08-16 14:19:31 +02:00
rouggy 99599458ff docs(wiki): cover the recent work, with worked examples
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.
2026-08-16 14:07:38 +02:00
rouggy 191e92ad62 perf(rotator): one adaptive heading poll for every controller type
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.
2026-08-16 13:39:23 +02:00
rouggy f405e71d7d fix(spid): a Rot1Prog takes three digits, so every target went the wrong way
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.
2026-08-16 13:31:00 +02:00
rouggy 0924062ced fix(settings): the power-supply panel crashed the window when opened
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.
2026-08-16 13:18:47 +02:00
rouggy 8683a450a7 feat(psu): switch a Modbus RTU bench supply from OpsLog
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.
2026-08-16 13:15:55 +02:00
rouggy 9f8e3c73d9 fix(udp): OpsLog was re-tuning its own rig from its own broadcasts
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.
2026-08-16 12:25:41 +02:00
rouggy 423cf1f998 feat(relays): follow the entry Band selector when there is no CAT link
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.
2026-08-16 11:15:56 +02:00
rouggy c0dc1bfcc4 feat(qsl): credit line at the foot of the default QSL e-mail
"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.
2026-08-16 11:07:06 +02:00
rouggy 46d6531cf5 fix(qso list): the grid followed the upload, not the log
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.
2026-08-16 10:48:38 +02:00
rouggy 3ed2d957e5 feat(counties): right-click to re-derive a US county from the ULS database
The automatic pass fills a blank county only, and deliberately: a value the
operator or QRZ supplied usually beats one derived from a ZIP code. That leaves
no way at all to correct a county already stored — and the Connecticut planning
regions are exactly that case. Every CT contact logged before that correction
holds a county the state abolished in 2022, and filling blanks never reaches
one of them.

So the new entry OVERWRITES, because it is asked for by hand on a chosen set of
rows precisely because the stored value is believed wrong. Only US entities are
touched, only callsigns the database holds, and only when the answer actually
differs — so re-running it on a mixed selection is safe and the count reported
is the number of counties that really changed. Award refs are re-materialised:
the county IS the reference for CQ USA-CA and the state for WAS.

The entry appears only once the database has been downloaded, and appears
without a restart when one finishes — an action that can only ever answer "no
database" is not worth a line in a menu this long.
2026-08-16 10:45:21 +02:00
rouggy 5770c40d89 test(adif): pin that an import invents no QSL routing
Reported as OpsLog defaulting QSL_SENT_VIA and QSL_RCVD_VIA to "E" on import.
It does not: recordToQSO reads the field and normalises it, so an absent, empty,
blank or unrecognised value gives an empty one. An "E" seen after an import came
from the file — Log4OM writes QSL_SENT_VIA:1>E on every record whether a card
was ever sent, which TestQSLSentViaDoesNotBecomeManager already pins.

Nothing to change, so this pins the half nothing covered: the ABSENCE case. The
opposite is an easy reach — electronic confirmation is the common case — and
defaulting to it would quietly rewrite the operator's own record of how their
cards travelled, which is the same class of fault as issue #16.

Compiles and vets clean; not executed here — this machine's Application Control
policy began blocking freshly built test binaries partway through the session.
2026-08-16 10:06:30 +02:00
rouggy 5e2c452753 chore(changelog): open 0.25.6
v0.25.5 went out with its seven 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.
2026-08-16 10:01:14 +02:00
rouggy f3f966f5b7 chore: release v0.25.5 v0.25.5 2026-08-16 01:04:54 +02:00
rouggy 38c838d232 chore(changelog): move today's work from the shipped 0.25.4 to 0.25.5
v0.25.4 was already released — tag v0.25.4 at 8113557, fourteen hours ago —
carrying exactly one entry, the WinKeyer probe. Everything since (cluster empty
state and timing, the generic HTTP relay, hardware following the profile, the
SteppIR read that never returned, the DXHunter tune unit) was written into that
block and would have been announced to operators as something they already have.

The 0.25.4 block is now byte-identical to the one that shipped, and the seven
later entries are under 0.25.5.
2026-08-16 00:46:15 +02:00
rouggy 7eb734d86f chore(changelog): open 0.25.5
0.25.4 is going out with eight entries. 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.
2026-08-16 00:35:30 +02:00
rouggy b49599150c fix(steppir): a silent controller wedged the driver for good
io.ReadFull cannot be used on a serial port, and it was.

On Windows a serial read that times out returns (0, nil) — on this transport a
timeout is not an error. io.ReadFull loops while err == nil, so a controller
that goes quiet for one poll, or answers with a truncated frame, spins it
forever. It holds ioMu throughout, and that is the whole failure the operator
sees:

  - the poll goroutine never returns, so nothing is ever logged about a fault
    and the cached status keeps the antenna looking connected;
  - every command blocks on the same mutex, and the trace line sat AFTER the
    lock, so even the attempt left no trace.

One dropped reply on a 4800-baud link therefore stopped the antenna responding
until OpsLog was restarted, with a log that showed the antenna starting, a few
status frames, and then nothing — which is exactly how it was reported.

Reads are now bounded: three seconds for an 11-byte frame that takes 23 ms on
the wire. Giving up returns an error, and the poll loop already knows what to do
with one — say so and reconnect. The command trace moved ahead of the lock, so
what the operator asked for is in the log even when the answer is not.

The SPE driver reads through a bufio.Reader, whose ErrNoProgress guard already
covers this; the ADIF parser reads a file. This was the only exposed one.
2026-08-16 00:33:32 +02:00
rouggy 331db58705 fix(antenna,udp): name the reason tracking is off; infer the remote-tune unit
The SteppIR report was not a SteppIR fault. In the log the antenna starts,
answers every poll, and sits at 21050 kHz while the rig works 21074 — and the
status line only prints when the frame CHANGES, so a link that is alive and
parked looks identical to one that died. The one line that explained it said
"ultrabeam: follow loop stopped", which covers two quite different situations
and, at startup where nothing was running, reads as a fault. Tracking was simply
switched off. It now says which of the two it is, names the antenna type, and
states the consequence — that the antenna will not follow the rig.

Same log, a second and unrelated fault: every launch failed a tune request from
DXHunter with "frequency 2107400000000 out of the 11-digit CAT range".
<FREQ> was read as MHz, but DXHunter had echoed back the value OpsLog itself
published in the N1MM RadioInfo broadcast, whose <Freq> is in tens of Hz. Both
units come from the same peer, so the unit is now inferred: try MHz, kHz, Hz,
tens of Hz, and keep the first that lands on an amateur band — anything a
station is asked to tune to is in one. Hz before tens of Hz because their only
overlap, 40 m against 4 m, is far more likely to be 40 m. Nothing plausible
tunes nothing and says so, rather than sending the rig to a wrong band.
2026-08-16 00:27:53 +02:00
rouggy 62e20de69c fix(profiles): station hardware must follow the active profile
Every setting is per profile, amplifiers included — that is the whole point of
one profile per station. But the amplifier clients were built once at startup
and again only when Settings was saved, so switching profile left them on the
previous profile's port. An operator with an SPE on COM9 for HF and another on
COM10 for 6 m had to open Settings and press Save after every switch. Save is
not a connect button; switching profile is what asks for that hardware.

The amplifier is what was reported, but it was never alone: the motorized
antenna, the Antenna Genius and the Tuner Genius are configured the same way and
were started in the same two places. All four now restart on a profile switch,
asynchronously, exactly as a save does.

Rotators were already correct — their client is built per command rather than
held open, so they read the active profile every time.

Unconditional, like a save: comparing each device's configuration across
profiles to skip a reconnect would trade a second of downtime for the chance of
missing one. A test now keeps the two lists in lockstep — anything started at
boot must be re-applied on a profile switch or be named, with its reason, as
something that must not be.
2026-08-16 00:18:11 +02:00
rouggy 480d24384b fix(relays): {value} is the relay's label, not a per-relay value
It was built the other way round on a misreading of two screenshots: the pattern
held {value} and the per-relay boxes held numbers to drop into it. The ask was
simpler and better — {value} is the name typed in Relay labels, so a switch
addressed by antenna name is one pattern instead of eight URLs:

  http://10.10.10.100/relay?on={value}   relay 1 named Ant1  →  ?on=Ant1

Renaming the antenna re-addresses it, and the name on the button and the name on
the wire cannot drift apart because they are the same string. It works in the
per-relay URLs and in the patterns alike, so the per-relay boxes go back to
holding URLs and nothing about them changes meaning any more.

The label is percent-encoded with %20 rather than "+" for a space: "+" is a
space only in a query string and a literal plus in a path, and this can land in
either half of a URL.

{value} on a relay with no label would send "?on=", an empty parameter that most
boards answer with a cheerful 200 and no movement. The driver refuses it and
names the label as what is missing; the editor warns while it is being typed,
beside the empty box rather than after an antenna fails to switch. The labels
also join the driver's cache key — they are part of the wire format now.
2026-08-16 00:09:18 +02:00