Commit Graph
100 Commits
Author SHA1 Message Date
rouggy 72696a5c0c feat(sat): the uplink keeps the correction the operator makes
A transponder does not translate by exactly the published difference —
the oscillator on board is decades old on some birds and a kilohertz or
two out. So an operator who sounds right to themselves comes back off
frequency, corrects it on the transmit VFO, and the tracker put it back
one second later, every second, for the rest of the pass. Reported on an
IC-9700 against HRD, which keeps the shift the operator sets.

The tracker already worked this way for the RECEIVER: it reads the dial
back and treats a move as the operator choosing a new station. The
transmitter had no equivalent — its comment even said so, "derived from
the nominal and never argued with". Now it is read back too, and the
difference becomes a standing trim on the nominal uplink.

Applied to the nominal rather than the corrected frequency, because a
translation error is a fixed offset in the uplink band and not something
that scales with the Doppler. Read only while not transmitting: mid-over
nobody is turning the knob, and on an Icom this read switches to the SUB
band and back, which is the same path TuneSatellite already uses to
write the uplink and not something to do under a carrier.

Kept per satellite AND per transponder, because that is what it belongs
to: the error is a property of the hardware in orbit, stable from one
pass to the next. Capped at 20 kHz so a bad stored value cannot put the
station outside the passband for ever, and shown in the tune panel with
a reset — an offset taken silently from the VFO has to be visible, and
the VFO alone cannot bring it back to zero once the operator has drifted
somewhere wrong.

SatTuner gains SatTransmitHz, implemented for the native Icom satellite
mode and for the Flex uplink slice; anything else reports nothing and
the uplink is left to the arithmetic, as before.
2026-09-10 18:53:03 +02:00
rouggy d0d29659cb fix(maps): the ground track stops cutting straight across the map
splitAtAntimeridian works from which COPY of the world each longitude is
in, so it needs a series that runs past ±180 rather than jumping back.
greatCirclePoints produces one; a satellite ground track does not — SGP4
reports every longitude inside (−180, 180]. A track leaving Kamchatka at
+179.9 and arriving in Alaska at −179.9 therefore looked like a single
359.8° step inside one world, no split was made, and the polyline drew
the chord: a straight dashed line clean across the map on every crossing.

The series is now unwrapped first. A jump of more than 180° between
consecutive points is that wrap and nothing else — no real path steps
half the globe between samples — and the unwrap is idempotent, so the
great-circle caller is unaffected.

Checked on three series: a normalised crossing now yields two segments
meeting exactly at ±180 where it used to yield one chord; an already
continuous 170→183 still splits at 180; a track that never crosses comes
back untouched.
2026-09-10 17:48:39 +02:00
rouggy 6f9b996db8 fix(sat): both antennas on both satellite slices
A slice has an rxant and a txant, and each pair belongs to that slice's
own band. Only two of the four were being set — the downlink's receive
antenna and the uplink's transmit one — so the downlink slice was left
with an empty txant. It never keys, so nothing was wrong on the air, but
the slice was half-configured: move transmit focus to it and the radio
uses whatever antenna it happened to be left on. Reported with XVTA on
2 m and XVTB on 70 cm, where the 70 cm slice showed one antenna and the
2 m slice showed two.

SatAntennas now takes all four, and the late-slice replay in
adoptSatSlice hands the uplink its own pair instead of putting the
transmit antenna on both of its ports.
2026-09-10 17:48:28 +02:00
rouggy 9dcab0568b docs(changelog): entries cut to one or two sentences
CLAUDE.md says one or two sentences with the rationale in the commit,
and I had been writing paragraphs: 0.27.23 ran to about 7 700 characters
over fifteen entries and 0.27.24 to 3 500 over eight. Release notes
nobody can scroll through are release notes nobody reads.

Same subjects, same order, outcome only — 1 950 and 955 characters.
Every "because", every measured number and every internal mechanism is
already in the commit that made the change, which is where they belong.
2026-09-10 15:43:53 +02:00
rouggy a8e870e098 fix(update): the relaunched OpsLog was starting invisibly
The relaunch after an update called hideConsole on the command that
starts the new build. That sets SysProcAttr{HideWindow: true}, which on
Windows becomes SW_HIDE in the STARTUPINFO handed to CreateProcess — and
Windows applies it to the first top-level window the new process shows.
So the updated OpsLog started correctly, took the single-instance mutex,
opened the logbook and connected the rig, and never appeared.

That is the report, in full: a process in the task manager, no window,
no autostart programs, and ending it then launching OpsLog by hand
working every time. Two operators, both on 0.27.23.

Why 0.27.19 did not fix it: that commit fixed the other half of the same
symptom — the new instance being less patient than the old one is slow —
which was real and is still fixed. The window was never part of it.

Where it came from: removing the PowerShell helper. Start-Process
launched the exe with a normal show; the direct exec.Command that
replaced it borrowed hideConsole from the console tools next to it,
where hiding a console window is exactly right and where the three
remaining callers (tasklist, taskkill, the deferred-swap PowerShell)
still belong.

The proof it was this and not the waiting: OpsLog relaunches itself in
two places, and RestartApp — the database switch — was byte-identical
except that it never called hideConsole. It has never been reported
broken. Both now go through relaunchCmd, so the rule lives in one place
with the reason written down, rather than in two call sites that differed
by one line.

Two tests: relaunchCmd leaves SysProcAttr nil, and update.go does not
call hideConsole at all.
2026-09-10 15:40:02 +02:00
rouggy 7e6e1335e3 fix(sat,decodes): the left column holds position too, and drift stops lying
Two things.

The sky plot and the position share the left column now, and the button
that opens it says so the way the right one does — it was a radar icon,
left over from when it toggled a plot inside the readout column, and the
gesture is the same one on both sides. The plot and the numbers are the
same answer at two precisions: azimuth and elevation drawn, then written
out to the digit. Having them at opposite ends of the window meant
reading a bearing off one side and finding it on the other.

And the band-drift warning, reported by W4TE. It compared the decoder's
announced band against RigState.Band, which is the TRANSMIT band — so
with slice A on 20 m running its own WSJT-X, slice B on 40 m, and
transmit focus on B, the 20 m decoder was told the rig was on 40 m while
the slice it listens to had been on 20 m throughout. The panel's own
comment had accepted this as a line that setup could read past; it is
worse than that, because the warning names a band and asserts something
false about the radio.

RigState now carries RxBands: every band the rig has a receiver on. One
entry on a single-VFO rig, one per slice on a Flex, the transmit band
always included so it cannot come back empty while the rig is on a
frequency. The warning fires only when the decoder announces a band
NOTHING on the radio is on, which is what it was always for and what a
lost CAT link actually looks like.
2026-09-10 15:25:54 +02:00
rouggy 3fe14c2c77 feat(sat): the Satellites tab laid out again, and a visible ground track
Everything asked for and not delivered last time.

The sky plot moves to its own column on the LEFT of the map. It was
stacked in the readout column, where a plot that wants to be square
competed for width with the numbers and pushed the pass table off the
bottom of the screen — while the margin on the other side of the map sat
empty throughout. Its own width, dragged from its right edge, with
double-click to reset.

Every block of the readout column gets a heading and a chevron: the
pass, the position, what to tune, the pass list. Each remembers its own
state, because an operator working FM birds never looks at the linear
passband and one watching a schedule never looks at the range rate.
There were no headings at all before, which cost twice — nothing said
what a group of numbers was, and there was nowhere to put the control
that shuts it. A shut block keeps the one number it exists for in its
heading, and drops that badge again when open rather than repeating the
body a line below.

The tune panel now leads with the CENTRE of the passband. The
Doppler-corrected figure was the wrong number for a reference panel: it
moves every second, it is different for every operator, and it is not
what the frequency plan, the AMSAT tables or anybody on the air calls
the satellite's frequency. What the radio is actually on belongs beside
Tracking, where the radio is — it is already there — so this keeps the
correction only as the offset that explains the difference.

Badges rather than rows of grey words, in the app's own status tones, on
the things that decide something: the band of each frequency, the
Doppler offset, an inverting transponder, the passband width, and the
peak elevation of a pass.

And the ground track. It was `color: 'var(--info)'` on a map that
renders with preferCanvas, and a CSS variable is not a colour a canvas
strokeStyle can take — so the browser kept whatever was set last and the
track came out a pale near-white that vanished over the imagery and the
deserts alike. This is the same trap internal to GridSquareMap's own
comment: every other map in this app passes hex. Now a real colour over
a dark casing, the way a road is drawn, because one line cannot hold up
over both a pale sea and a dark continent but a line with an outline
can.
2026-09-10 15:09:27 +02:00
rouggy e9816241e8 docs(changelog): 0.27.23 describes what ships, not how it was built
Eighteen entries down to fifteen. Three subjects were written as they
were built — announced, then corrected, then corrected again — and a
block is read top to bottom by an operator who saw none of the states in
between. The reverse FT-map layer was the worst of it: one entry
promised dashed cyan arcs, the next said the arcs were gone, a third
filled the marks and gave them a colour. Nobody outside this machine
ever had the version being corrected.

- The wind fix and the blinking fix are one change to one test: strict
  on the way into movement, loose on the way out.
- The who-hears-me layer, its diamonds and its colour are one feature.
- The control layout says where things are, without the detour through
  where they were first put.

Also: the decode colour no longer claims to sit next to the basemap
buttons, which is where it was for two commits.
2026-09-10 14:54:01 +02:00
rouggy 707f0bc848 chore: release v0.27.23 2026-09-10 14:52:29 +02:00
rouggy b2e93e9164 feat(ftmap): the who-hears-me marks are filled and take their own colour
Filled, with a hairline white edge — the same trick the home marker
uses. A ring is an outline drawn over whatever is beneath it, and eight
pixels of one over the satellite imagery was barely visible, which is
the whole reason the layer exists to be looked at.

Its own colour, in its own key, beside the switch that turns the layer
on. Separate from the decode colour deliberately: that one exists
because the band palette disappears over some basemaps, and cyan over a
pale sea has exactly the same problem for exactly the same reason. One
control for both would have forced the two layers into a single colour,
which is the distinction it took a shape to draw in the first place.

The picker only appears while the layer is on, since it configures that
layer and nothing else, and it is a portable UI pref like the other map
colours, so it travels with data/.
2026-09-10 14:49:52 +02:00
rouggy eb06cc29b0 fix(ftmap): who-hears-me as marks, and controls off the map
Two things the first version got wrong.

The arcs. One line per reporting station made a fan out of a single
square, and with a few dozen receivers it buried the decode arcs it was
drawn beside. They earned nothing either: an arc's job on the decode
layer is to say WHICH station a path belongs to, and here every path
starts in the same place. So marks only, and the mark is a hollow
diamond faded by age over the window. Shape rather than colour, because
the decode dots are filled circles and the arcs already use fourteen
colours — a fifteenth reads as another band. A divIcon, canvas being
able to draw circles and nothing else; affordable here at a few dozen
nodes where it would not be against three hundred arcs.

The controls. The basemap row was at left-12, which is 48 px, against a
zoom control that is 30 px of buttons plus its 10 px margin and a
border — so it overlapped, and the minus button took clicks meant for
Street. Then the colour input and the reverse-layer switch were added to
the same row, which grew until it reached the middle of a world map:
covering the Atlantic to spare a top-right corner that was empty
throughout, Leaflet putting nothing there but an attribution that lives
at the bottom. Basemaps stay left at left-16, the other two move right.
2026-09-10 14:42:26 +02:00
rouggy d25edd114c feat(ftmap): a layer for who hears me
The map drew one direction of every path on it: what this receiver
decoded. The reverse — which stations are reporting our own
transmissions — is the half an operator cannot see from their own
radio, and on FT8 it is the half that decides whether calling is worth
the cycle.

It is the narrowest slice of the PSK Reporter feed there is. The v2
topic is

	pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/…

so putting the operator's callsign in the TRANSMIT level makes the
broker send nothing else. For scale, from internal/pskr's own measured
numbers: four bands unfiltered is 83 messages a second, filtered on the
receiver's square 0.2 to 1.2 a second — one callsign in the transmit
level is a handful per FT8 cycle however open the band is. Both grids
are in the payload, so the arc is arithmetic and there is no lookup.

internal/pskrme, with its own connection, for the same reason
internal/pskrtgt has its own: the three want slices of the feed that
cannot be filtered out of one another. It also means this keeps working
with the band-opening watch off — hanging it off that feed's lifecycle
would have made it fail silently for anyone not chasing openings.

Nothing is persisted. One entry per STATION inside a fifteen-minute
window, carrying its freshest report: PSK Reporter's uploaders batch,
many every five minutes, so a tighter window would show a fraction of
who actually heard the last few calls. Stop clears the window, or
switching the layer back on would redraw who heard us before it was on.

On the map the layer is dashed and single-coloured. Solid is what we
decoded, dashed is somebody decoding us; colour alone could not carry
that distinction next to fourteen band colours. The receivers are rings
rather than filled dots for the same reason. Per profile, since the
callsign IS the subscription — a switch resubscribes rather than going
on reporting who hears the previous station.
2026-09-10 14:01:39 +02:00
rouggy f090e845ff data(sat): the plan checked against AMSAT's live lists
Every FM bird's uplink, downlink and tone compared against N9KT's Live
FM Satellites table, and the image list beside it. Two corrections and
ten additions:

- AO-123 (ASRTU-1) had no CTCSS. Its 145.850 uplink wants 67.0 Hz, and
  without the tone the transponder simply does not answer.
- SO-50 keeps 67.0 Hz, which is the tone that keys it, but its label now
  says 74.4 Hz arms the ten-minute timer first. That is a one-off
  two-second carrier, not a tone to key every transmission with, so it
  belongs where it will be read rather than in a field the radio would
  apply throughout.
- Eight SSTV satellites added as receive-only entries, no uplink field
  at all, the way the ISS SSTV row was already written: HC1PX
  (GALAPAGOS-UTE-SWSU), RS18S (SakhaCube-Cholbon), RS27S (UTMN-2),
  RS38S (VIZARD-meteo), RS40S (UmKA-1), RS57S (Monitor-4), RS58S
  (Monitor-3), RS83S (Lobachevsky). SSTV downlinks also added to
  SONATE-2 (145.880, Martin M1) and QMR-KWT-2 (436.950, Robot 36), and
  the ISS row now names its mode.

The NORAD numbers were read out of the element feed by name, not
guessed: five of the eight are in it and carry an exact catalogue
number, and the three that are not (HC1PX, RS18S, RS83S) are joined by
name and alias instead, so they read as having no elements until a feed
carries them rather than pointing an antenna somewhere arbitrary.

AO-91 was left alone. The table shows no tone in its uplink cell but
Fox-1B does require 67.0 Hz, so ours stands and the table is the
incomplete one.
2026-09-10 13:54:09 +02:00
rouggy d2e62debe8 fix(rotor): Stop no longer blinks through a rotation
The direction test that kept the wind out was applied to staying in as
well, and there it was wrong. Its unit is a four-degree step, which is
four seconds of travel at a degree a second — far longer than the 1600 ms
settle window — so between two accepted steps nothing said the mast was
still turning, the timer expired, and Stop went dark and lit again the
whole way round.

Entry stays strict: two consecutive steps of four degrees the same way,
which is what weather cannot produce. Staying in is now sensitive
instead — any continued progress in the direction already established
re-arms the timer, down to a single degree, because one degree the same
way is not a gust when the rotor is demonstrably under power. That
demonstration is exactly what the strict entry provides, so the loose
exit costs nothing: wind still cannot start it, and once stopped the
sensitive path is disarmed with the direction.

The settle window goes to 3000 ms so it outlasts one degree of progress
on a slow mast rather than four degrees on a fast one. Measuring
progress needs the PREVIOUS reading, which movementReferenceRef is not:
it deliberately holds still through sub-threshold steps so they can
accumulate into one.
2026-09-10 13:47:59 +02:00
rouggy 8359caab16 feat(update): Later asks how much later
"Later" set updateInfo to null and nothing else, while the check behind
it runs on a five-minute interval — so the notice came back four times
an hour, all evening, for a version the operator had already declined.
The button was doing exactly what it said and was useless anyway.

It now offers 1, 4, 12 or 24 hours, and the deferral is recorded with
the VERSION it applies to. That scoping is the part that matters: a
release newer than the one put off is different news and appears at
once, so a snooze can defer an update but never bury one.

The cross hides it for an hour — the shortest of the four — rather than
for the five minutes until the next poll, which is what made the notice
feel broken. The background check honours the deferral; checkUpdateNow,
behind About, deliberately does not, because asking is asking.

Kept out of the portable UI prefs: a machine told to wait four hours has
said nothing about the operator's other machines.
2026-09-10 13:43:06 +02:00
rouggy f998023a28 feat(ftmap): one colour for every decode, over the band palette
The band palette is calibrated against a plain basemap. Over the Esri
imagery the 60m navy and the 70cm olive all but disappear, and 20m
yellow goes over the deserts — and the palette only earns its keep for
somebody watching several bands at once, which most operators are not.

So a colour input beside the basemap buttons, where it belongs: the two
are one decision, since the palette that reads well on the plain map is
the one that vanishes over the ground. Empty means per band, which stays
the default, and the reset arrow appears only once something is set. It
opens on the crimson the home marker already uses, that being chosen to
hold up on any basemap.

With a colour forced the legend keeps the band NAMES and drops the
swatches: which bands are up is still worth knowing, a colour key that
maps to nothing is not.

Stored as a portable UI pref, like the grid map's own fills, so it
travels with data/.
2026-09-10 13:43:06 +02:00
rouggy 3b6269978c fix(sat): saving the settings refreshes the Satellites tab
The tab read the followed list, the horizon and the locator once, when it
was mounted. It is normally open BEHIND the settings window while those
are edited, so un-following QO-100 left it in the pass table and in the
dropdown until the tab was reopened — indistinguishable, from the
outside, from a setting that had not saved at all.

SaveSatSettings now emits sat:settings, and the panel reloads the birds
and the passes on it, the same way it already does on sat:tle. One event
for every key: they all change what the tab should be showing. The
selection needs nothing new — a satellite that has stopped being
followed drops out of the dropdown, and the effect that keeps the
selection inside that list moves to the first one remaining.
2026-09-10 13:36:19 +02:00
rouggy 052fc4cb80 feat(grids): the square map filters by mode, band and satellite
The FTx button is gone. It was the wrong grain in both directions: the
Digital class already sat a contest RTTY square next to an FT8 one, and
FTx then sat FT8 next to FT4, when what this map answers is where ONE
mode has been heard. The four classes stay as buttons and a dropdown
beside them offers a single mode.

Its contents come from the log, not from a list in the code. That is
what settles FT2: it is not a registered ADIF mode yet, so a hardcoded
list meant either leaving out the operators already using it or shipping
a mode that does not officially exist. A query does neither, and needs
no change here the day it is registered. The mode offered is the SUBMODE
where there is one, because ADIF files PSK63 under PSK and "PSK" is not
the name anybody is looking for.

The band list is the station's own, unioned with anything worked outside
it so nothing in the log is unreachable, ordered by frequency through
the band plan's index — sorting the names puts 10m between 1.25m and
12m. The satellite list is drawn from SAT_NAME on the squares
themselves and the control is absent altogether on a terrestrial log: a
VHF square worked through AO-91 and one worked line-of-sight are not the
same achievement, and until now nothing separated them.

The mode dropdown shares the scope with the class buttons rather than
narrowing on top of them — mode is one question, and two controls both
answering it is how a map ends up showing PHONE ∩ FT8 and nothing else.
A stored FTX preference is read as Digital, so it cannot leave the map
filtered by something no control shows as selected.
2026-09-10 11:07:25 +02:00
rouggy 2615365684 fix(sat): the per-band antennas reach the satellite slices
Settings ▸ FlexRadio stores the band→antenna map keyed by the band name
in capitals — that is what the panel writes and what the entry form reads
back. applySatRadio looked its two bands up through bandForHz, which
returns the band plan's own spelling ("70cm"), so both lookups missed,
both antennas came back empty, and the early return left the pass on
whichever antenna the radio was last used on. An operator with XVTA on
2 m and XVTB on 70 cm had configured exactly the thing being ignored, and
nothing said so: the downlink ran through the wrong transverter in
silence.

The key is now computed by flexBandAntKey, which exists so the next
caller cannot make the same mistake, and a test pins the case in the
direction that broke. Both outcomes are logged — the resolved antennas,
or the bands nothing was configured for — because from the outside a
setting never made and a lookup that missed look identical.

Alongside, three things the same pass made obvious:

- Tracking shows the satellite's own azimuth, elevation, distance and
  altitude beside the frequencies. The strip held where the ANTENNA was
  pointing but not where the bird was, which is what says whether a pass
  is worth calling on. Elevation dims below the horizon so a satellite
  followed before its lever cannot be read as workable.

- Both satellite lists in the settings are sorted by name with the
  numbers taken as numbers. The available column followed the order
  birds.json happens to be written in and the followed column the order
  of the clicks, so finding one bird among sixteen meant reading all
  sixteen.

- A followed satellite that has since been renamed is resolved through
  the plan's aliases. LILACSAT-2 became LO-90, and the followed list is
  stored as plain text, so the bird the operator had chosen appeared as
  having no elements while the same satellite sat in the available
  column under its new name. Resolved in GetSatSettings rather than
  satSettings, which is read at startup before the plan is loaded.
2026-09-10 11:00:24 +02:00
rouggyandClaude Opus 5 580e5782f8 fix(sat): a frequency we shipped wrong can now be mended
"la lilacsat je le vois en DATA ???!" — and the LO-90 fix could not have reached
him. The satellite file is copied out on the first run and was the operator's
from then on, so the merge could only ADD birds, never repair one. LilacSat-2
went out with an APRS digipeater and no FM transponder, and that mistake had
become his data, permanently.

The merge now distinguishes three cases, and the middle one is the whole point:

  - a satellite he does not have is added;
  - one he has, UNCHANGED from the plan he was given, is replaced — he never
    edited it, so it is not his to keep: it is our data and ours was wrong;
  - one he EDITED is left exactly alone, and named in the log. A frequency
    somebody corrected by hand outranks anything shipped; they were on the air
    and we were not.

"Unchanged" is decided against a baseline — satellites.shipped.json, the plan
this station was last handed — so the comparison is with what THEY were given
rather than with whatever ships today. Their edits survive every future release,
not just the next one.

The first run after this has no baseline, and there an edit of theirs and a
mistake of ours are indistinguishable. The shipped plan wins, once, with the
whole file copied to satellites.json.bak first and every replacement named. The
safe-looking alternative was the wrong one: standing down would have written a
baseline recording their entry as "edited" and frozen a known-wrong frequency
for the life of the install.

Verified against his own file: LILACSAT-2 becomes LO-90 with the FM transponder
first, and the twelve curated entries that were missing their catalog numbers
get them — which also closes the NORAD gap left open when the exact join went in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 10:41:16 +02:00
rouggyandClaude Opus 5 3ce74ef8d7 fix(sat): LilacSat-2 keeps its FM transponder
Shipped with nothing but an APRS digipeater on 144.390, which is not what
anybody works that satellite on. The FM transponder — 144.350 up, 437.200 down —
was dropped by the generator, correctly by its own rule: SatNOGS marks that
transmitter inactive.

And SatNOGS is not wrong. LilacSat-2's transponder is switched on to an
announced schedule rather than left running, and from a database a scheduled
transponder looks exactly like a dead one. The judgement belongs to whoever
reads the diff, not to the filter.

So the transponder goes back in, labelled "(scheduled)" the way PO-101 already
is, and the satellite takes its OSCAR name, LO-90, with LILACSAT-2 and CAS-3H as
aliases so the element feeds still meet it.

The generator now reports what it refused on that ground and kept nothing else
for — one line per satellite, phrased as a question. Reviving every inactive
transponder would fill the list with birds that answer nothing; saying nothing
is how this one shipped wrong. There is no third option that a filter can decide
on its own.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 10:27:15 +02:00
rouggyandClaude Opus 5 3af6299f32 fix(rotor): wind is not rotation
"I thought we had fixed this" — and we had not, we had only raised the number.
The antenna moves a few degrees in the wind and the Stop button lights up and
goes out, all evening, on a mast that has not turned.

A threshold cannot tell the two apart at any setting. A gust pushes a beam past
four degrees and brings it back — 100°, 105°, 100°, 106° — and every one of those
excursions cleared the threshold. Raising it only raises the wind speed it takes,
and blunts the detection of a rotor genuinely creeping.

What separates a rotation from the weather is not amplitude, it is SIGN. A rotor
under power advances; a gust reverses. So a step is only movement when the
previous step went the same way: wind gives +5, −5, +5 and never two in a row,
while a rotor gives +4, +4, +4 and is announced on the second — one poll, about
a second, on a mast that takes half a minute to cross a pass.

The threshold stays, doing the job it is actually good at: ignoring the degree
or two of potentiometer noise that is not a step at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 10:16:07 +02:00
rouggyandClaude Opus 5 136c5d5b6b docs(changelog): the last two entries belong to 0.27.23
v0.27.22 was cut at ce7b368, and the SteppIR Calibrate button and the rotor-dial
fix landed after it. Their entries were appended to a block that had already
shipped, so an operator reading the What's new for the version they are running
would have been told about two things it does not contain.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 10:04:41 +02:00
rouggyandClaude Opus 5 d8c9f05d10 fix(rotor): the beam stops taking the long way round north
A G-2800 sitting at 020° turned anticlockwise reports 020, 010, 000, 359, 358 …
340. The dial fed those straight into a CSS rotate, so the transition travelled
from 20deg to 359deg the long way: a complete revolution on screen while the
mast moved forty degrees in the opposite direction. On a rotator with an overlap
that happens on every pass through north, which is where the antenna spends much
of its time.

unwrapRotation was already there and already did the right thing — it was only
wired to the HOVER beam, where the mouse crossing north had made it obvious.
The antenna's own beam used the raw azimuth.

So the antenna angle is accumulated across renders too: 020 → 000 → −001 →
−020, which is the way the mast is moving. Both lobes of a bidirectional antenna
get their own accumulator, since they cross north at different moments, and the
ref only advances when the input changes so a re-render for any other reason
cannot make the beam creep.

The classic dial is unaffected: it draws its needle from SVG coordinates, with
no transition to send anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 09:53:30 +02:00
rouggyandClaude Opus 5 a88e871640 feat(steppir): a Calibrate button, and Retract explains itself
Calibrate was in the protocol notes at the top of the package ('V') and nowhere
else. It drives every element to its end stop so the controller re-learns where
zero is, and it is the cure for the one SteppIR fault an operator cannot reason
about: an antenna that tunes to the wrong length. The controller counts steps
from a remembered position, so once that memory is wrong — a power cut in
mid-move, elements pushed by hand, a motor that slipped — every frequency after
it is wrong by the same amount, silently.

It asks before it runs. Minutes, every element travelling its full length, and
the antenna unusable until it finishes: right answer to a mistuned beam, wrong
answer to a stray click during a contest.

It sits beside Retract as a two-column row rather than a second full-width
button. The docked widget is narrow and already tall, and the pair reads as what
it is — the two commands that move every element at once.

Retract (Home) was already there and worked on a SteppIR; it now carries a
tooltip saying what it is for, because "retract elements" does not tell an
operator that this is the storage position and that the next tune brings them
back out unaided.

An Ultrabeam controller has no calibration command, and the adapter says so
instead of pretending — the antenna interface is shared, and a button that
silently does nothing is worse than one that explains itself.

Untested on hardware: the byte is from SteppIR's own Transceiver Interface note,
which is the same source the rest of this package was cross-checked against.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 08:00:22 +02:00
rouggy ce7b3686f5 chore: release v0.27.22 2026-09-10 00:32:03 +02:00
rouggyandClaude Opus 5 b0a973d390 fix(db): stop losing the database pointer, and never lose it silently
An operator spent three hours setting up, accepted the update, and reopened a
program that had forgotten everything. It is the second such report.

The updater is not the culprit — it touches its own exe and nothing else. The
pointer is. config.json is the only record of WHERE the database is, and it was
written with os.WriteFile: truncate, then fill. A process that stops between
those two steps — a crash, a power cut, an update's watchdog force-exiting the
old instance — leaves the file empty. readBootstrap then swallowed the parse
error, returned an empty pointer, and startup read that as "no database chosen":
it created a NEW one at the default path and opened it. Three hours of work
still on disk, and an application presenting itself as freshly installed.

Three changes, in the order they defend:

  - The write is atomic. A temporary file, fsync'd, renamed into place — and a
    rename within a volume cannot publish half a file. The previous contents are
    kept as config.json.bak, because a pointer is a few dozen bytes and an
    evening of configuration is not.
  - A pointer that EXISTS and cannot be read is no longer treated as no pointer.
    It is restored from the backup, and when there is nothing to restore from
    the broken file is KEPT as config.json.broken — it is evidence, and it may
    still be readable by hand.
  - Creating a new, empty settings database in a folder that already holds a
    full one is now said out loud, in the startup log and on screen. Nothing is
    deleted and nothing is guessed — guessing which file is theirs is how the
    wrong one gets opened — but the message names the other file, which is where
    their settings still are.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 00:14:08 +02:00
rouggyandClaude Opus 5 e2fe406445 fix(flex): the satellite uplink slice was never really armed
Reported with two screenshots: both slices in USB on an inverting transponder,
slice B sitting at exactly 435.100000, and the red TX badge on the 2 m DOWNLINK.
The log named the cause in one line:

    flex: satellite armed (rx slice 0, tx slice -1)

Creating a slice is asynchronous — "slice create" is answered later with the
index — and SetSatellite returned without waiting. Everything downstream then
ran against an uplink of -1 and silently did nothing: no antenna, no CTCSS tone,
no sideband, never tuned, and never sent "tx=1". So the radio went on
transmitting on the downlink, which is the one failure here that puts a signal
where it must not go, and the frequency and mode on screen were simply the ones
the slice had been created with.

Three fixes, because the ordering can fail in more than one way:

  - Arming waits for both indices before reporting the pair armed, and says so
    plainly when the radio does not produce them.
  - The uplink is adopted from the SLICE STATUS as well as from the create
    reply. The status needs no sequence-number correlation: if satellite mode is
    armed, the uplink is unknown, and a slice is in use that is not the
    downlink, that is it — the radio is saying so.
  - What the uplink is owed is remembered — its mode, its antenna, its tone —
    and given to it when it appears. Those three are sent ONCE; only the
    frequency is re-sent every tick, so a slice that arrived late kept nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 00:08:43 +02:00
rouggyandClaude Opus 5 3c93684b2b fix(rotator): the Rotator Genius is a 360° controller, and OpsLog says so
Correcting the previous commit, which offered a 450° rotator range for the
Rotator Genius. It cannot do it.

The evidence is the operator's own box and 4O3A's manual together. His Rotator
Configuration reads "Limits: 5 to 4" — that is where the mechanical stop sits
within ONE turn, a dead zone at four and a half degrees, not a range of travel.
The manual is unambiguous about what happens past it: "you will not be able to
give it a target beyond the limits". A 450° mast on a Rotator Genius is a 450°
mast used as a 360° one, and that limit belongs to the controller.

So the setting goes. Offering an operator a 450° option that the box can only
ever refuse is worse than not offering one — it spends their evening proving
that the software was wrong about their station.

What stays is the half that was genuinely ours: GoTo no longer clamps to 360
before sending, and the limits the Genius reports on every heading query are now
read instead of skipped over. The overlap branch is driven entirely by what the
device answers — no setting, no assumption — so a controller that one day
reports a range past 360 is driven through it without a line changing here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:58:49 +02:00
rouggyandClaude Opus 5 3dad00f8ad feat(rotator): drive a Rotator Genius through the overlap
An operator with a 450° mast watched the Genius stop at 359 and had to press
"clockwise" by hand to get through north. Half of that was ours: GoTo clamped
every target to 360 before sending it, although the wire command carries three
digits and always could have said 370.

So the clamp goes to 450, the rotator-range setting is offered for the Rotator
Genius like the other backends it applies to, and when a bearing can be reached
two ways the nearer one is taken — 010° sent as 370° when the antenna is already
at 350°, which is the entire point of having an overlap.

THE GENIUS DECIDES WHAT IS REACHABLE. It reports the limits it is configured
with, and they are the truth about what is bolted to the tower. This particular
station's box says "5 to 4" — the factory 360° range — and would refuse 370,
turning a working command into a rejected one. So the overlap is used only when
the Genius itself says it has one, and when OpsLog is set to 450 while the Genius
is not, the log says so once a minute and names the dialog to change: the setting
lives in the Genius's own Rotator Configuration, and nothing here can reach past
its limits.

The |h reply always carried those limits and they were being skipped over.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:46:04 +02:00
rouggyandClaude Opus 5 b8491f3038 fix(sat): the antenna stops flickering, and the pass is readable from the header
THE FLICKER was a real bug and not a rotator problem. The rotator is asked where
it is at most every three seconds — a controller query binds a socket and waits —
but satTrackStep built a fresh status every second and carried over only Radio
and Error. So the antenna readout was filled in on one tick in three and blank
on the other two, which on screen is a rotator that keeps disconnecting. The
status is now rebuilt FROM the previous one, so a field nobody wrote this tick
keeps the value somebody wrote last tick.

THE HEADER now carries the two frequencies and the antenna bearing, beside the
button that started tracking. During a pass an operator watches the radio and
the antenna, not a column on the far side of the window — and that column is the
first thing they hide to get the map full width, which until now took the
numbers with it. The compass spins while the antenna is still on its way: a mast
takes tens of seconds to cross a pass, and "moving" against "stuck" is the whole
reason to look at it, which a number alone cannot show.

THE PRECISION drops from one hertz to a hundred. The Doppler moves about sixty
hertz a second on 70 cm, so the last two digits changed on every tick and the
display was a blur that could not be read and did not need to be. The radio
still gets the whole figure — the correction is computed and sent to the hertz —
this is only how much of it is worth putting in front of somebody. The shift
beside it says "+9.7 kHz" rather than "+9741 Hz", which is how it is read aloud.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:30:57 +02:00
rouggy 5ddc5e38a9 chore: release v0.27.21 2026-09-09 23:23:03 +02:00
rouggyandClaude Opus 5 8f643b5b67 fix(winkeyer): wake a keyer that will not open, instead of giving up
An operator with a WinKey2 USB had to run K1EL's WKdemo and close it again
before OpsLog could talk to the keyer at all. That workaround is the diagnosis:
closing another program does something to the keyer that opening the port does
not, and whatever state it was stuck in survives a failed connect.

So the second handshake attempt now does what closing WKdemo does, in an order
that survives each step failing:

  - Host Close, in case the keyer is still in host mode from a session that
    ended without one — a crash, a cable pulled, a machine switched off. It has
    been waiting ever since for a host that went away.
  - Admin Reset, which returns it to its power-up state. A parser stuck
    part-way through a command whose parameters will never arrive cannot be
    talked out of it any other way.
  - A DTR pulse, which is what closing a program actually does to the line. On
    a WKUSB and on every Arduino-based clone, DTR runs to the processor's reset:
    it is a power-on reset in all but name.

RTS is left alone throughout — on a serial WinKeyer it is the negative rail the
RS-232 swing comes from, and driving it starves the chip.

A keyer that answers the echo and then refuses to open is the same leftover
session seen from the other side, so that case sends Host Close and asks again
rather than reporting a keyer that demonstrably just spoke to us as absent.

And a port already known to need the slow path gets the wake-up on the FIRST
attempt from then on: making the operator sit through a failure to earn it again
doubles the connect time for no new information.

The handshake bytes are already logged on every connect, so the next report of
this shape says where it stopped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:11:39 +02:00
rouggyandClaude Opus 5 37b798e9f5 fix(sat): changing satellite mid-pass moves the radio to it
Two birds are often up at once, and switching between them left the frequencies
on the first. The selection on the page is the DISPLAY's; the tracker held its
own name and went on following whatever it was started with. The way through was
to stop tracking and start it again, which is how it was found — and which is
also how a Flex throws away and rebuilds both its slices for no reason.

RetargetSatelliteTracking changes what is being followed without letting go of
the radio or the rotator. Everything derived from the old satellite is cleared so
the next step sets it afresh: the frequencies, the mode on both slices (set once
per satellite, not per tick), the antennas and the CTCSS tone — the new bird may
be U/V where the old one was V/U, which swaps which slice sits on which band.

And it happens at once rather than at the next tick. The loop gained a wake
channel: a second of the previous satellite's frequencies is a second of the
wrong pass, and the antenna would otherwise wait for the new bird to drift a
step away from where the old one happened to be.

Selecting a satellite with the radio idle is unchanged — a look, not a command.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:05:07 +02:00
rouggyandClaude Opus 5 3ac6f7e49c fix(sat): the Flex slices get their antenna, their sideband and their tone
Three things the tracker was leaving to chance on a FlexRadio, all reported from
a real pass.

ANTENNAS. Settings ▸ FlexRadio holds a per-band RX/TX antenna map, and it was
applied in exactly one place: the entry form, on a band change, to the active
slice. A pass never goes through that path — the tracker arms two slices itself.
So both were left on whatever the radio last used, and a station with
transverters (XVTA on 2 m, XVTB on 70 cm) heard nothing at all, having
configured precisely the thing being ignored. The two slices are on two
different bands, so they cannot share one setting: the downlink takes the
receive antenna for ITS band, the uplink the transmit antenna for its. Per
slice, not through sendSlice, which addresses whichever slice is active — during
a pass that is the downlink, so the uplink would never have been set.

SIDEBAND. satMode forced USB above 30 MHz on both sides. An inverting
transponder turns the passband over, so lower sideband up comes back as upper
sideband down: FO-29, RS-44 and AO-73 were being worked with the operator's own
audio going through upside down. The tracker now decides both sidebands from the
transponder's inverting flag and passes them separately; a bare "SSB" still
means USB, so nothing else changes.

CTCSS. Nothing set it, on any bird. The frequency plan has carried the tone all
along — 67.0 on SO-50 and AO-91, 141.3 on PO-101 — and an FM repeater does not
answer without it, which is indistinguishable from a satellite that is not
there. It goes on the uplink slice, value before mode so the radio cannot
transmit the previous tone in the gap between two commands.

Written against the SmartSDR slice API and UNTESTED on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 20:20:41 +02:00
rouggyandClaude Opus 5 0add56fb2d fix(sat): the Doppler correction was 250 times too big, and backwards
Reported as "the Doppler moves the frequency enormously", and it did: a 2 m
downlink was being shifted two megahertz across a pass instead of three
kilohertz, in the wrong direction.

The propagator library reports a range rate that is not one. Measured against
the range it is meant to be the derivative of, on this station's own cached
elements:

    PO-101   library -1457.3 km/s    measured +6.227 km/s
    ISS      library +2036.8 km/s    measured -5.522 km/s

Wrong by a factor of some 250 and of the wrong sign, so the correction both
overshot and pushed the operator away from the station they could hear. Nothing
else was affected — the elevation and the passes come from the look angle, which
is right — which is why this survived: the satellite was in the correct place on
the map while the radio was told to go megahertz away from it.

So OpsLog computes it itself, as the difference between two ranges a second
apart. That cannot be wrong in either magnitude or sign: it differentiates the
very number the panel displays. Two extra propagations per call, which is
microseconds.

Two tests pin it, and both fail against the old behaviour: a range rate faster
than orbital velocity is a units mistake, and the Doppler on the two bands
satellites are worked on has a textbook size — about ±3.5 kHz on 2 m, ±10 kHz on
70 cm.

cmd/satdiag is the throwaway that found it, kept because the next report of this
shape ("the frequency moves oddly", "that pass is not real") is answered by the
same three numbers: which elements the satellite resolved to, the range rate
reported against the range rate measured, and the Doppler each transponder gets.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 20:07:27 +02:00
rouggy 20b4431502 chore: release v0.27.20 2026-09-09 19:53:59 +02:00
rouggyandClaude Opus 5 660b1bfa1f feat(sat): the pass table is the whole list
Asked why the satellite dropdown exists when the passes are on the right, and
the honest answer was that the table was incomplete. Three kinds of satellite
never appeared in it: QO-100, which has no pass because it never sets; one whose
elements have not arrived, which cannot be predicted at all; and one whose next
pass falls beyond the prediction window. Following a satellite and not finding it
in the list reads as OpsLog having lost it.

They are listed at the end now, each saying which of the three it is, and
clicking one selects it exactly like a pass row. The table stops being "what is
coming" and becomes "what you follow, soonest first" — which is what an operator
was scanning it for anyway.

The dropdown stays, and now for one reason rather than by default: it is the
only selector left when the readout column is hidden, which is what an operator
does when they want the map full width during a pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 12:01:08 +02:00
rouggyandClaude Opus 5 b15055ba4a feat(sat): follow the azimuth only
A satellite tracker that insists on an elevation motor is a tracker switched off
for nearly everybody. A pass at the edge of the footprint — which is most of
them — never climbs above ten or fifteen degrees for its whole length, and a
yagi's beamwidth swallows that: the bearing alone is enough, and it is how most
stations that work satellites are actually built. The same switch rescues an
az/el station whose elevation motor has failed.

So it is an option, not a silent fallback, because it does cost something: a
bird straight overhead is a moving azimuth and a bearing that means nothing, and
whether to accept that is the operator's call. With it on, any rotor in the list
can be chosen — the PstRotator, the Rotator Genius, the ARCO, the tower already
turned for HF.

That works because the per-backend command dispatch moved out of the three
RotatorGoTo/Stop/Heading methods into linkGoTo/linkStop/linkHeading, so the
satellite tracker drives any of the seven backends through the same code the
compass uses instead of a second implementation of each. GetRotatorHeading loses
sixty lines of near-duplicate switch in the process, and a rotor with no
elevation axis now says so (HasElevation) rather than reporting a zero that
looks like a real bearing.

One trap, with a test on it: the step check compared both axes, so with the
elevation never commanded its difference stayed above the step for the whole
pass and every tick ordered the antenna to the bearing it was already on. A mast
has a finite number of turns in it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 11:35:23 +02:00
rouggyandClaude Opus 5 8f17416eca feat(sat): the mode and the CTCSS tone where they can be seen
Both were already on screen, in a grey eleven-pixel footnote under the
frequencies, which is not where either of them belongs.

FM and SSB are two different evenings. One is a channel, a tone and a handheld;
the other is a passband, a beam and a VFO walked as the Doppler moves. So the
mode is a coloured badge — in the readout, in the transponder list where a
satellite offering both makes the choice matter, and in the header so it
survives hiding the readout column, which is exactly what an operator does when
they want the map full width during a pass.

The tone gets the weight of a frequency because that is what it is worth: a
repeater called without it does not answer, and what the operator hears is an
empty channel and concludes the satellite is not up. And when a bird needs no
tone it now says so — a blank line could not tell "no tone" from "OpsLog does
not know", and the difference is a pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 11:27:01 +02:00
rouggyandClaude Opus 5 86fd03fd6b feat(sat): generate the frequency plan, and join on the catalog number
25 satellites, typed by hand and never revisited. Eight of them were the
first-generation Tevel constellation, which re-entered in 2024; four more had
come down too; and the nine Tevel-2 satellites that replaced them, the Chinese
space station, AO-27, AO-123 and twenty others were simply absent. So: 44
satellites now, and a generator instead of a memory.

cmd/satgen joins three public sources on the NORAD catalog number — Celestrak's
amateur group and PE0SAT's mirror for which birds OpsLog can actually get
elements for, and SatNOGS DB for the transmitters. It is a one-shot tool, run by
hand, the same arrangement as cmd/cntygen, and it is deliberately conservative:

  - It never destroys a curated entry. The hand-written plans hold things
    SatNOGS does not reliably carry — a CTCSS tone, the QO-100 passband as
    operators describe it — so an existing bird keeps its data and only gains
    its catalog number.
  - It prunes on the re-entry date, which is a fact SatNOGS publishes rather
    than a judgement about which of the missing satellites are missing for good.
  - It refuses a digital uplink that does not say what it is. A GMSK uplink is a
    command channel far more often than a digipeater, and shipping the wrong one
    invites somebody to transmit on a control frequency. An analog uplink with
    both ends is a contact by construction, which is what catches the repeaters
    that describe themselves only as "Mode V/U FM".
  - Its output is deterministic. One satellite can hold two catalog entries —
    GreenCube is 53106 in one feed and 53109 in the other — and iterating a map
    picked a different one each run.

A bird now carries its NORAD number, and that is how its elements are found.
Names were the only join before, and they are written differently by every party
involved: "TIANYAN 01" and "TO-108" are one satellite that had never once met,
so TO-108 tracked nothing at all.

And the plan now reaches a station that has already run OpsLog. The editable
copy was written on the first launch and was the operator's list for ever after,
so a release adding nine satellites reached nobody who had opened the tab. It is
merged on each load instead: a satellite they already have is untouched, edits
and corrections included, and only the ones they have never seen are added.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 11:20:22 +02:00
rouggyandClaude Opus 5 fcf00e04f4 fix(sat): draw the footprint for the selected satellite only
A footprint is thousands of kilometres across. A dozen of them overlap into a
wash of translucent circles that hides the coastline, the ground track and the
satellites themselves — and the question a footprint answers, "can I hear it",
is only ever asked about the bird being worked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 11:20:05 +02:00
rouggyandClaude Opus 5 543550c716 feat(sat): the map says what a pass is worth
An unselected satellite was a four-pixel dot with a one-pixel white edge, and
the basemap decides whether that is visible at all: a pale marker vanishes into
pale terrain, a grey one into a dark ocean, and the operator can switch between
the two in one session. So: a dark halo under a white ring around a larger dot.
Two rings, because no single colour reads on both.

The ones above the horizon now carry their name. Not all of them — a dozen
labels is a map nobody can read — but the two or three an operator is choosing
between right now, which is what saves hovering each grey dot in turn to find
them.

Hovering said "name · elevation · altitude", none of which decides anything.
What decides whether to reach for the radio is how long is left, how high it
will get and where to point, so the tooltip now carries the pass: elevation and
azimuth with its compass point, distance with an arrow for closing or receding,
rise and set with a countdown and a direction, and the peak. A bird already in
view shows its SET countdown instead of its rise — that is the number that
matters at that moment. No pass in the window says so, because a blank reads as
a fault. It costs no extra prediction: the pass list on screen is indexed by
name, and the first entry for a name is its next pass.

The tooltip also had to stop closing itself. The layer is rebuilt every five
seconds as the birds move, and a rebuilt marker is a new marker, so the detail
being read disappeared mid-sentence. The map now tracks the pointer and reopens
the tooltip of the dot it is still on — that one only, so nothing hangs open
once the mouse has moved away.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 11:04:26 +02:00
rouggyandClaude Opus 5 ca81d4fc68 feat(rotator): one list of rotator interfaces, and ERC-M
The satellite page configured its own EasyComm or PstRotator link while five
other backends were configured in the rotator list. An operator with one az/el
mast therefore described it twice, and could describe it differently the second
time — a station that works on HF and not on a pass, for no reason visible
anywhere on screen.

Now every interface lives in Settings ▸ Rotator, once, and the satellite page
stores only a KEY into that list plus the tracking policy that is genuinely its
own (minimum elevation, step, park). The key and not the index: deleting the
first rotor must not silently point the tracker at a different mast.
migrateSatRotator() turns an existing satellite link into a real entry in the
list, selects it, and clears the old keys so it cannot run twice.

Which rotors have an elevation axis is now a question with one answer, in Go:
rotatorTypes plus rotorHasElevation, exposed to the panel by GetRotatorTypes.
The dropdown, the labels, each backend's default port and default baud all come
from there, so TypeScript no longer keeps a second copy of the same knowledge to
drift out of step. Three cases do not follow from the type alone and are treated
as such: PstRotator forwards elevation to a mast that may not have any, so the
operator says; a SPID's dialect decides (Rot1Prog has no elevation in its reply
format); and an ARCO and an ERC-M speak the same GS-232 while only one of them
lifts.

Each interface carries an Az / Az+El badge beside it. The satellite rotor
dropdown LISTS the azimuth-only ones, disabled, rather than hiding them: an
operator who owns one rotator and does not see it concludes OpsLog cannot find
it, where a greyed row saying "azimuth only" teaches the actual thing.

ERC-M by DF9GR is new — the az/el interface for a Yaesu G-5500. It emulates
GS-232, so internal/rotator/gs232 grew the elevation half: W for a two-axis
move, C2 to read both, falling back to C+B for the firmware that answers C2 with
the azimuth alone. That fallback is the point of the parser tests: reading such
a reply as "elevation zero" would put the antenna on the horizon, which is the
one wrong answer that looks plausible.

EasyComm II is promoted to an ordinary rotator interface, so it can also turn
the antenna from the compass and from a spot click.

The ERC-M is UNTESTED on hardware. Its Test button reads BOTH axes rather than
just the azimuth, so a controller wired for azimuth alone says so there instead
of during a pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 11:04:04 +02:00
rouggyandClaude Opus 5 8b1dff581b feat(linux): the Go half of OpsLog builds for Linux
Measured rather than guessed: the whole repository was cross-compiled for
linux/amd64 and the gaps closed one by one. There were fewer than expected.

Flex and TCI were never Windows-specific — they carried //go:build windows by
inheritance and import nothing but net and gorilla/websocket. Untagged, no code
change. The two backends a Linux operator is most likely to own were already
portable.

Audio was 560 lines, not 2287: only devices.go and engine.go touch WASAPI, while
manager.go, recorder.go, wav.go and mp3.go were pure Go wearing the tag by
association. The whole platform surface is seven functions, now implemented a
second time on PulseAudio through github.com/jfreymuth/pulse — pure Go over the
server socket, so the no-cgo rule survives, and PipeWire answers the same
protocol. The fixed 16 kHz mono format and the server-side resampling mirror
what AUTOCONVERTPCM does on Windows, for the same reason.

OmniRig is the only real loss, and its backend still EXISTS off Windows rather
than being compiled out of app.go: a settings database is portable, so an
operator moving a profile across keeps "omnirig" saved and must be told to pick
a native backend instead of meeting a nil one.

The parts where Linux is not Windows, and where a compile-only stub would have
been a silent bug:

  - data dir: still beside the binary, but ~/.local/share/OpsLog/data when that
    folder belongs to the system — decided by trying the write, because /opt and
    /usr/local are writable on some stations and not others.
  - single instance: an flock, not a pid file. The kernel drops it however the
    process dies, so a crash leaves nothing to delete by hand. This is the guard
    that stops two instances fighting over the rig frequency.
  - update: simpler here. Unix renames over a running binary, so the deferred
    swap the Windows path needs a detached helper for is unreachable.
  - tasklist/taskkill become /proc and SIGTERM; the boot log moves out of /tmp,
    which is wiped exactly when the evidence is wanted.
  - serial ports sorted naturally: /dev/ttyUSB10 was landing between USB1 and
    USB2, the same trap COM10 fell into.

release.ps1 now cross-builds and vets for linux before it builds the exe, and
refuses the release if that fails — a port rots one unguarded x/sys/windows call
at a time.

Nothing has been executed on Linux yet: Wails needs webkit2gtk and cgo there, so
the binary must be built on Linux. scripts/linux-setup.sh checks the machine and
does it; BUILDING-LINUX.md is the manual version.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 10:21:27 +02:00
rouggy f05e6290df chore: release v0.27.19 2026-09-08 22:08:20 +02:00
rouggyandClaude Opus 5 a89be4b86e fix(rotor): a disc on the panel, not a black tile in it
The dial painted its background as a full-bleed square, so inside the
rotor widget — which is already a card — it read as a hole punched in the
panel rather than an instrument sitting on it. It is a circle now, at the
radius the map already used, and the corners are left to whatever it is
drawn on. The wrapper loses its own border and background for the same
reason: one card, not two.

And the continents were barely there. At #202832 on a #0B1015 ground the
land was some eight per cent brighter than the sea — technically a map,
practically a dark square with a suggestion in it. The new shades read as
coastlines while staying well under the beams, which are what the dial is
actually for.

The palette stays deliberately unthemed. That was never the problem: a
map that repaints itself in four colour schemes stops being readable, and
the beams' green, orange and yellow have to mean the same thing in every
one of them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 22:00:39 +02:00
rouggyandClaude Opus 5 dbeabc1bae feat: my rig and my antenna as dropdowns, and a ceiling on the decodes
The rigs and antennas are already declared once, in Settings ▸ Operating
conditions — a station per rig with its antennas hanging off it — and
then typed again into every contact. That is work, and it is a source of
spellings that do not match: "IC-7610", "IC 7610" and "ic7610" are three
different rigs to an award, to a filter, and to anyone reading the log
later.

Both fields now offer that list, in the entry form and in the QSO editor,
and the antenna field offers the antennas of the rig that was picked
because that is the structure the tree already has. It falls back to all
of them for a rig the tree does not know, so an operator typing a
borrowed rig is still offered their own antennas rather than nothing.
Free text stays allowed throughout — a contact made from somebody else's
station, or imported from another logger, carries a rig that was never in
this tree and must still be loggable. Same rule the satellite-name field
follows.

And the decodes list gains a ceiling of two thousand rows. The rolling
half hour was never a limit on a crowded evening — three decoders put
several thousand rows inside it — and the panel slows down long before
the age cut removes any of them, because each row is a layout, a status
lookup and a distance. Past the ceiling the oldest go: what has already
been scrolled past, rather than the period being read.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 21:52:38 +02:00
rouggyandClaude Opus 5 6f1c998a26 fix(cat): choosing a radio switches CAT on, and the log stops lying
A Flex 6700 owner's log: no connection attempt of any kind, and six
saves each answered with

    cat: settings saved, link unchanged - staying connected

His settings were right — FlexRadio selected, 192.168.1.154, port 4992,
and the detector had found and named his radio back at him. What was off
was the master CAT switch, which sits above the radio dropdown.

Three things, because each of them failed him on its own.

The log line was false on both counts: a disabled CAT has the signature
"off", so every save took the unchanged branch and announced a link that
was staying connected when nothing was connected at all. It now says CAT
is switched off, which is the fact he needed and the only place it could
have reached him.

The panel says it too, where he was actually looking: a notice under the
switch while it is off, because everything below it can be perfectly
right and still connect to nothing.

And choosing a radio now ticks the switch. Picking a brand, typing an
address, running a detector and clicking the radio it found are all one
gesture — "connect to this" — and making the operator find a separate
master switch afterwards is a trap. Nothing here ever turns CAT off on
its own; they can still untick it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 18:52:19 +02:00
rouggyandClaude Opus 5 d185b10559 docs(wiki): the WSJT-X relay was missing from the outbound list
Six outbound services, five documented. The missing one is the answer to
the problem the digital-modes page had just finished describing: WSJT-X
sends to one address, so without a relay the choice is between OpsLog and
GridTracker rather than both — and OpsLog has been able to be that relay
all along.

Connections now lists it and explains the loop guard (pointing it at one
of OpsLog's own ports would feed the stream back into itself, and it
refuses) and that datagrams are passed on verbatim, with no origin header
of the kind the receiving side has had to write code to survive.

Digital Modes and GridTracker gains it as the unicast answer, which is
better than the one it gave: OpsLog relaying to GridTracker on 2238 needs
nothing from GridTracker and no multicast anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 13:44:40 +02:00
rouggyandClaude Opus 5 41a2a38025 chore(wiki): publish the wiki on its own, without cutting a release
Documentation and releases move at different speeds. A page corrected on
a Sunday should not have to wait for the next version, and it should
certainly not be a reason to cut one.

The wiki step moves out of release.ps1 into publish-wiki.ps1, and the
release calls that same file — one implementation rather than two that
drift. Run it from VS Code (Tasks: Run Task -> Publish wiki) or directly;
it clones the wiki repo, copies the pages in and pushes only if something
changed, listing what it is about to commit so the wiki's own history
says which pages moved rather than a column of identical lines.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 13:32:45 +02:00
rouggyandClaude Opus 5 f8f8de4a30 feat(omnirig): say which of its two CW bits means CW on this rig
Reported on an IC-7610 shared through OmniRig: clicking a CW spot put
the radio in CW-R every time, and the only way out was to edit the
OmniRig rig file by hand.

OmniRig's model has two CW modes, PM_CW_U and PM_CW_L, and nothing in it
says which one an .ini calls plain CW. Icom rig files disagree with each
other — on some PM_CW_U is CI-V mode 0x03 (CW), on others 0x07 (CW-R) —
so asking for "CW" is a question with two right answers and OpsLog was
only ever giving one of them.

Settings ▸ CAT ▸ OmniRig now carries the answer for THIS rig, and it is
pushed to a radio that is already connected rather than waiting for a
reconnect: which bit a mode maps to is not worth dropping the link, and
with it WSJT-X's rigctl session. Same shape as the Yaesu RTTY sideband,
for the same reason.

Nothing changes for anyone whose rig file already agrees with us.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 12:05:31 +02:00
rouggyandClaude Opus 5 82cd5c5d0b fix(qsl): every confirmation service has a default, and the wiki explains them
HAMLOG.online was added after most profiles were configured, so it had no
entry in the shipped defaults and no stored value either: it came back
blank, and blank is not a status anybody chose. Every service now starts
the same way — the sent side at R, the received side at N — and a blank
left by a service that did not exist when the operator last saved is
filled in from that. A status they chose themselves is untouched.

Two tests hold the line: no sent side may default to Y, and no field may
be left without a default. Y means "already sent", so it makes the
uploader skip the contact for ever — an operator with eQSL Sent at Y had
a logbook that never reached eQSL, and the only trace was one line in the
application log.

Wiki, both from operator reports:

QSL Management opens with Confirmations — what the page actually is (the
status stamped on every new QSO, not an action), what each status does,
and the warning about Y in the plainest words available, because it fails
silently and by design.

Digital Modes and GridTracker is new. Unicast and multicast explained
from the operating problem rather than the networking: one letterbox that
two programs watch, against a broadcast everyone can tune to. It carries
the real evidence — two starts of one station an hour apart, decodes in
the second and none in the first, the only difference being whether
GridTracker or OpsLog reached port 2237 first — then the settings for
WSJT-X, JTDX, MSHV, GridTracker and OpsLog, the 127.0.0.1-in-the-group-box
mistake, what to do if unicast is unavoidable, and how to check it from
the log.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 09:37:37 +02:00
rouggy 612e265837 fix(udp): a "multicast" row on an address that is not one still listens
Reported from a station whose two WSJT-X rows had been dead for weeks:
both were ticked multicast with 127.0.0.1 in the group box, and both
failed the join on every interface with

    setsockopt: l'adresse demandée n'est pas valide dans son contexte

which names nothing the operator typed and does not say what is wrong
with it. A multicast group runs 224.0.0.0 to 239.255.255.255; 127.0.0.1
is loopback unicast, and it is an understandable thing to type — it is
the address every other field in every other program wants.

The address is now checked before the join. When it is not a multicast
one the row listens on unicast instead, which is what such an address
means, and the log says why it is not multicast. The row works, and the
reason is in a sentence rather than in a kernel error code.
2026-09-08 09:27:09 +02:00
rouggy 3415e12363 docs(changelog): the relaunch fix opens 0.27.19
0.27.18 went out before it was committed, so it does not belong in that
block — and it is the one entry an operator on 0.27.18 needs, since it is
their update that will not come back.
2026-09-08 09:23:32 +02:00
rouggy e0b110392a fix(update): wait for the old process, not for a fixed window
The relaunch after an update stopped working, and the regression is mine:
removing the PowerShell helper — which is what Defender was reading as a
dropper — also removed the wait it was doing. Nothing took over the job.

The numbers made it certain rather than unlucky. The instance being
replaced is allowed THIRTY seconds to shut down (armExitWatchdog forces
it out at that point) because it closes a remote logbook, a CAT session
and sometimes a backup. The new instance was patient with the
single-instance mutex for TWENTY. On any station where shutting down ran
past that, the new process gave up and exited in silence: no window after
an update, and the previous OpsLog still in the task manager. Exactly the
report.

Both relaunch paths now pass --wait-pid, and the new process waits on
that process's handle — a plain kernel wait, which ends the instant the
old one ends, however long or short that is, and looks nothing like a
script starting another program. The mutex retry stays as a backstop and
goes to forty-five seconds, so it is longer than the wait it exists for
rather than shorter.

And when the old process really has not gone, the message says that
instead of "OpsLog is already running" — after an update the operator did
not start a second copy, and what they need to know is which one to
close.

A test keeps the two spawn sites honest: a relaunch added without
--wait-pid is this bug again.
2026-09-08 09:18:06 +02:00
rouggy 38da904f4b chore: release v0.27.18 2026-09-08 00:25:27 +02:00
rouggy b7d1eddfa0 fix(entry): the two report boxes stop moving as well
Once the callsign stopped giving up its width, the RST boxes were the
next widest things in the row and became the ones that shifted when the
date field appeared. Both are now a notch narrower and shrink-0, like the
callsign; "59+20" is the longest report either ever holds and still fits.
2026-09-08 00:24:15 +02:00
rouggy 7208efbba6 fix(entry): the callsign box stops moving when the padlock closes
Its row gains a date field for a manual entry, and a flex row makes room
by shrinking its children — so the widest box, which is the one the eye
is on while typing, was the one that visibly narrowed.

A notch narrower and shrink-0: the size it will always be, with or
without the date, and the slack now comes from the boxes beside it.
2026-09-08 00:21:12 +02:00
rouggy 2414b2077f chore(awards): log a successful bulk recompute, not only a failed one
"I changed an award and the column stayed empty" cannot be answered
without knowing whether the recompute ran at all, how long it took, and
whether it changed anything — and zero rows changed is a real answer
(the definition matches nothing in the log) that looks exactly like a
recompute which never happened.
2026-09-08 00:19:12 +02:00
rouggy 1cbf3ecc4b perf(settings): every field in Preferences types into itself first
Preferences is one component holding two hundred pieces of state, and its
biggest panels are eight hundred lines of form — external services, CAT.
A plain controlled input sends every keystroke into that state, so every
character re-rendered the whole dialog, and the letter arrived after the
finger had left the key.

BufferedInput keeps the text where it is typed and hands it up 120 ms
later. A drop-in for Input, so the change is one import rather than a
hundred call sites — which means it has to be right in every shape those
call sites take:

  • Blur flushes at once, so clicking Save cannot lose the last word, and
    so does unmounting: a panel changed mid-word still hands up what was
    there.
  • A value that comes back DIFFERENT from what was sent up is adopted,
    even with the focus in the field. That is what keeps the fields that
    normalise as you type working — the callsign box that upper-cases,
    the port box that drops everything but digits. They echo a corrected
    value, and the correction wins.
  • A value changed from outside while the field is idle wins too: that
    is how loading the settings, or switching profile, refills the form.
  • Checkboxes, colour pickers and file fields pass straight through.
2026-09-08 00:15:44 +02:00
rouggy ccae8599eb docs(changelog): the padlock and what followed it are 0.27.18
0.27.17 went out, so everything committed after its release commit needs
a block of its own — the padlock, the duplicated section name, the
digital row of the matrix and the MQTT chip.

The backdrop-blur fix goes with them rather than staying in 0.27.17. It
had been folded into that version's typing-lag entry, but it is not in
the binary 0.27.17 shipped: what that release actually carries is the
cluster-macro half, which is what its entry now says again.
2026-09-08 00:12:18 +02:00
rouggy cdd91ab7e6 fix(ui): one section name, a chosen digital row, no MQTT chip
Three things reported together.

Preferences said the section name twice: a small line above every panel
repeated the heading right underneath it — "GENERAL" over "General" —
while the sidebar next to them already shows which section is open,
highlighted. Two lines and a highlight for one fact; the small line goes.

The band matrix opens on the digital row the operator chooses. Its
digital row has always rotated — DIGI, then each digital mode in their
own list — but it always STARTED on DIGI, so somebody who only works FT8
clicked through to their own mode on every callsign. Settings ▸ General
now says where the rotation starts, and the dropdown offers exactly the
modes the matrix rotates through (the phone-mode rule is now shared
rather than copied, so the two cannot drift). DIGI stays the default: it
is the right answer for anyone working several digital modes.

And the MQTT chip is gone from the status bar. That is the name of a
message protocol, not of anything an operator has — a chip in the status
bar has to say what it is about, and this one told nobody anything. What
it carried is shown in the Chase New panel, which is the place that uses
it; its poller went with it.
2026-09-08 00:10:06 +02:00
rouggy 5b7469ae44 feat(entry): one padlock instead of five
Logging a contact from a piece of paper — a contest sheet, a friend's
report, a QSO worked on another radio — means the frequency, the band,
the mode, the date and both times all have to stop following the rig and
the clock at once. That is a single decision, and it was five clicks in
five different places, each of which had to be found first.

The padlock beside Start UTC now holds all five and releases all five,
with the same behaviour as before: locking pre-fills the times so the
fields are not empty, unlocking hands them back to the clock and snaps a
QSO already in progress to now.

The five per-field locks stay underneath. Everything downstream reads
them, and individually they say the right thing — "this value is
decoupled from the rig". Only the control is one.
2026-09-08 00:04:56 +02:00
rouggy 3c7ea2d894 perf(ui): the dialogs you type in no longer sit on a blurred backdrop
A backdrop-filter covers the whole window and is recomputed every time
anything above it repaints. Behind these dialogs is an application that
never stops moving — CAT polls four times a second, spots arrive, meters
sweep, maps redraw — so the filter was being recomputed continuously, and
each keystroke's repaint dragged a full-window blur with it. That is why
the lag was felt in Preferences and nowhere else, and why fixing the
cluster macros did not end it.

Worse in one place: the cluster server editor opens FROM Preferences, so
its overlay was the SECOND full-window filter stacked over the first.
That is exactly where the delay was first reported.

The dialogs an operator types in for minutes — Preferences, the cluster
editor, the QSO editor, bulk edit, alert rules, award definitions — now
dim the background harder instead of blurring it. Everything else keeps
the blur: a confirmation you click through in a second costs nothing.
2026-09-08 00:00:47 +02:00
rouggy 55fbfa4499 chore: release v0.27.17 2026-09-07 23:56:40 +02:00
rouggy b655cd9631 feat(station): the radio and both keyers on the Station Control tab
This tab began as the relay and rotator dashboard and stopped there: the
three things an operator touches most — the radio, the CW keyer, the
voice keyer — were the ones missing from the page that claims to show the
station.

The radio card carries the frequency and the mode large, because that is
what gets glanced at, and the split pair only when there IS a split: a
second frequency shown at all times is one more number to read past. When
CAT is down it says which kind of down — switched off, or on and not
answering.

The CW keyer card carries the speed, which is the control an operator
reaches for mid-QSO when a station comes back faster than expected, and
Stop beside it because a message going to the wrong callsign has to end
now. The voice keyer card carries the recorded messages themselves: a
card that only said "idle" would be a light, not a control.

Each polls its own binding and holds its own state, like the supply card
above them, so they drop into the grid and reorder with everything else.
The two keyers appear only when there is something behind them — a port
configured, a message actually recorded — because an operator who works
neither should not be handed two dead cards. That question is asked once
on opening the tab: a keyer is bought and wired, not something that
appears mid-session.
2026-09-07 23:48:10 +02:00
rouggy bc880ef6bd chore: release v0.27.17 2026-09-07 23:38:41 +02:00
rouggy 7ff0c2ac69 fix(settings): Preferences stopped lagging behind the keyboard
Typing a cluster macro cost two things per character. The state lived on
SettingsModal, so every keystroke in one of those twenty-four boxes
re-rendered the WHOLE preferences dialog — every list, every form, every
panel. And the save wrote through to Go and into the database on each
one: a round trip per character.

The editor is now its own module-scoped component with its own state, so
a keystroke re-renders twelve rows. And writeUiPrefDebounced holds the
database write until the typing stops, while the local cache — which is
what everything reads back — is still written at once. Pending values are
flushed when the page goes away, so typing and immediately closing does
not lose the last word.

The behaviour is unchanged: still saved as you type, still no Save
button, because a text box whose contents only take effect on some other
button is how work gets lost.

Also folds the satellite changelog into one [NEW] entry. Satellites are
new in this version — nobody reading the notes has seen any of it — so a
running account of how it was built, tab then tracking then rotator then
where the settings moved to, is the wrong shape. One entry saying what it
does.
2026-09-07 23:25:46 +02:00
rouggy fa6e30545a feat(cluster): three more nodes in the list — F5LEN, F5MZN, KM3T
dxcluster.f5len.org:7373, f5mzn.org:9000 and dxcc.km3t.net:7373, all
general DX clusters. Nine to pick from now.
2026-09-07 23:21:25 +02:00
rouggy 3e206268b4 feat(cluster): start from a known node instead of a blank form
Setting up a telnet cluster is the step operators get stuck on. The
address and the port are two pieces of information nobody has to hand,
and a typo in either looks exactly like a node that is down — there is
nothing to read, only a connection that never comes.

So the editor offers a list, and choosing one fills the fields. Six to
begin with: F4BPO, DXFun, SOTA, POTA, and the two Reverse Beacon feeds —
listed separately and named for what they carry, because they are one
network on two ports where 7000 is CW and RTTY and 7001 is FT8 and FT4,
and no amount of trying will tell you which is which.

The preset then gets out of the way. Everything stays editable, the name
is only filled when the operator has not chosen one of their own, and a
node typed in by hand behaves exactly the same. Reopening a node created
from a preset shows it selected, so the list also answers "which one is
this".
2026-09-07 23:17:20 +02:00
rouggy 659e33676a feat(decodes): sort a slot by SNR, frequency, distance, country or status
Click the heading. Within each PERIOD and never across them: the slots
are what this panel is — what was on the air in one fifteen-second
window — and a list sorted end to end would mix three minutes of decodes
into one column of numbers with no way to tell which window any of them
came from.

One click sorts the way that column is worth reading — strongest signal,
lowest frequency, furthest DX, A to Z, most wanted — the second reverses
it, and the third gives arrival order back. Arrival order stays the
default and stays one click away, because it mirrors the decoder's own
window line for line, which is what makes the two screens comparable at a
glance.

Status ranks by the cluster's own order, so the two views rank the same
things the same way, with the markers that are orthogonal to the entity —
a new county on a worked country — sorted above the plain duplicates.

A station that never sent a grid cannot be placed, and an unresolved
country is not a name: both sort to the end whichever way the column
goes, rather than pretending to a distance of zero and heading the list
under "nearest first".
2026-09-07 23:09:03 +02:00
rouggy cf44b37bf4 feat(sat): a sky plot — the pass seen from underneath it
The map answers "where is the satellite over the earth". This answers
"where do I look", which during a pass is the question that matters.

The projection is the one every tracker uses and every operator already
reads: the centre is the zenith, the rim is the horizon, north is up. So
the radius is (90 − elevation), not the elevation — a bird overhead is a
dot in the middle, and a pass that hugs the rim is one that never rises.
Whether it comes over the roof or along the treeline is something no
amount of azimuth and elevation digits conveys, and one glance settles.

The whole pass is drawn: a dashed track with arrowheads for the direction
of travel, a hollow circle where it rises, a filled one where it sets,
and a cross where the satellite is now — green above the horizon, grey
below, because the numbers are still right down there and nothing can be
worked through the earth.

The track is fetched once a minute, not once a second: the SHAPE of a
pass does not change while it happens. Only the marker moves, and that
rides on the tuning poll that was already running.
2026-09-07 22:50:29 +02:00
rouggy 37dadeda84 feat(sat): the pass list becomes a table worth reading
Headings, because a column of numbers with nothing above it makes an
operator work out what they are looking at every time. A real table, so
the satellite column takes the width the longest name needs — "ZHUHAI-1
OVS-1A" was cut to eight characters in a fixed one.

Colour where it carries meaning, and nowhere else. The maximum elevation
is the quality of the pass, so it is coloured like one: a bird 70°
overhead and one scraping 12° along the horizon are not the same evening,
and the table should say so without the operator reading every number. A
pass in progress is green, one starting within five minutes is amber. And
a dot for the mode: FM and SSB call for a completely different set-up,
and which the next pass is decides whether you reach for a handheld or
for the whole station.

The rotator's baud rate is a dropdown, like every other one in OpsLog.
2026-09-07 22:43:55 +02:00
rouggy b478cbfd2a feat(sat): a wider frequency list, a resizable readout, one locator
Three things reported together from the tab.

The shipped frequency plan went from eleven satellites to twenty-five:
the eight Tevel FM cubesats, EO-88, AO-109, CAS-4A and 4B, TO-108,
GreenCube's single-frequency digipeater, and QO-100's wideband
transponder beside its narrowband one. It remains a starting point in a
file the operator can correct — a transponder gets switched and no
release should be needed to follow it — and the picker still lists every
bird in the element set when the "with a plan" filter is unticked.

The readout column drags to any width between 240 and 720 pixels,
double-clicks back to its default, and folds away entirely. How much map
against how much detail is the operator's call: watching a footprint
cross an ocean and working a pass want opposite things.

And the locator is no longer asked for twice. Passes are predicted from
the station locator, which is set once in Station information; the field
here was only ever for an antenna at another site, so it says so and sits
folded away. Nobody should have to wonder which of two locators is in
use.

Also: "Driven by" is two columns wide. "OpsLog (EasyComm II)" did not fit
in a third of the row, and a truncated choice is a choice that cannot be
read.
2026-09-07 22:36:48 +02:00
rouggy 76022ff91c fix(udp): several FT8 programs no longer fight over the callsign field
Reported from a station running MSHV, WSJT-X and JTDX together: click a
call in MSHV and the entry field filled, emptied, refilled — once a
second — with the map zooming in and out to match.

Two causes, both about reading one program's statement as another's.

The clear was tracked per LISTENER. Several decoders commonly share one
multicast group, so an idle WSJT-X reporting no DX Call — which is simply
true, and which it repeats every second — was read as MSHV abandoning the
station it was calling. "The operator cleared the DX Call" is a statement
about one program, never about a socket, so it is now tracked per
program, and a clear carries the id of whoever made it.

And nothing arbitrated between them. The program that announces a station
now holds the entry field, and the others cannot touch it until it lets
go: it clears its own call, it stops sending (closed), or the QSO is
logged. That is the operator's own suggestion, and it is the right one —
between overs there is no way to tell "I have nothing" from "I am not the
one you are working" except by remembering who was.

Refusing another program's callsign is logged once per focus, not once a
second: an operator whose second decoder "stopped filling the call" needs
something to read.
2026-09-07 21:23:11 +02:00
rouggy b918a8395b fix(maps): one imagery choice per map, not one for two of them
The world map and the grid-square map shared a single key, so picking
satellite imagery to look at grids repainted the main map too, and there
was no way to have terrain on one and streets on the other. They are
different maps answering different questions, and the imagery that suits
one is not the imagery that suits the next.

Four keys now, one per map, in lib/mapBase beside the remembered views —
named in one place so a rename cannot silently orphan somebody's choice —
and portable, so a copied data folder brings them along. The grid map
inherits whatever was set under the old shared key rather than being
reset to the default: an operator who chose imagery there keeps it.
2026-09-07 21:06:10 +02:00
rouggy 2283734210 feat(sat): PstRotator can point the antenna too
It handles azimuth and elevation, and a great many stations already run
it in front of a controller OpsLog has never heard of. For those,
OpsLog talking to the controller itself would be a second program
fighting PstRotator over the same cable — so it hands over the bearing
instead, and lets PstRotator turn the mast.

Both kinds sit behind one small interface, chosen in Settings. Neither is
more correct than the other: the right one is whichever the station
already has working.

The 450° overlap is deliberately NOT applied on the PstRotator path.
PstRotator knows which machine is on the other end and does its own; two
programs each deciding to go the long way round is exactly how an antenna
unwinds in the middle of a pass.

Position queries are asked at most every three seconds rather than on
every tick. A PstRotator query binds a socket and waits up to a second
and a half, and many setups answer nothing at all — so one silence is
enough and it stops asking, reporting the commanded position instead and
saying that is what it is.
2026-09-07 17:11:23 +02:00
rouggy 9dfa6f7d39 refactor(sat): one satellite list, and out of Hardware
Settings ▸ Lists ▸ Satellites is gone. It was a text box an operator
typed their birds into by hand, and it had nothing to do with the
satellites the tracker knew: the same station kept two lists of the same
satellites and they drifted apart. The SAT_NAME box on the entry form now
offers the followed set — or every satellite with a frequency plan when
none is followed — merged with whatever that old list still holds, read
and never written, because what it holds is somebody's past work.

And the satellite section moved out of Hardware, which it never was.
Which birds you chase, where your antenna stands and how old your
elements are is operating; the rotator inside it is the only hardware
there, and one block does not make a section a device.
2026-09-07 15:41:56 +02:00
rouggy 3ed48336af feat(sat): set it up in Settings, work the pass in the tab
Two things belong in different places, and they were in one.

Settings → Satellites now holds the setup: which satellites to follow —
the same two-column shape as the awards, for the same reason, since a
feed carries two hundred birds and an operator works six — and the
orbital elements, their age, the fetch, and pasting your own. Following
none still means every satellite with both elements and a frequency plan,
so somebody who has not chosen yet is not handed an empty tab.

The panel keeps only what a pass needs. A countdown to AOS, or to LOS
once it is up, because that is the number that decides whether you sit
down; a bar for where in the pass you are, since mid-pass the useful
question is not the clock but whether you are past the peak; rise, peak
and set with compass directions, because "rises SW" is a direction to
look in and 213° is arithmetic. Distance, altitude and footprint. And
approaching or receding, which is the sign of the whole Doppler
correction and the only thing that explains why the frequencies are
moving the way they are.

The countdowns run in the browser from two timestamps. Predicting a pass
steps the orbit across a day thirty seconds at a time, which is not
something to do once a second for a clock the page can keep itself.
2026-09-07 15:34:26 +02:00
rouggy 7f03b046ab fix(sat): the locator was already set, and the map was on top of everything
Two things reported together on the new tab.

The locator: the station's is on the ACTIVE PROFILE, not in a settings
key. keyStationMyGrid is a legacy key that EnsureDefault migrated into
the profile long ago and nothing writes any more, so reading it told an
operator with a perfectly good locator on screen that he had not set one
— and refused every pass prediction on the strength of it.

The map: Leaflet stacks its panes and controls up to z-index 1000, which
without a stacking context of their own float over the whole application
— Preferences opened behind the map, its Save and Close buttons under it.
The other three maps in OpsLog each carry `isolate` for exactly this
reason; this one was missing it.
2026-09-07 12:07:12 +02:00
rouggy 90e363f49e feat(sat): point the antenna — EasyComm II az/el rotator
EasyComm is what satellite rotator controllers agreed on, so a box that
works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its
own settings rather than the HF rotator's: an az/el pair is a different
machine on a different port, and an operator who has both must not have
to choose.

A great many EasyComm controllers — the Arduino trackers above all —
accept commands and never say a word back. That is legal and common, so a
silent controller is not treated as a broken one: it is still driven, and
the last commanded position is reported in its place, marked as commanded
rather than read. A stuck rotator must not be able to hide behind an
order it never carried out, which is why the panel shows the antenna's
position beside the satellite's.

The 450° overlap is the reason a satellite rotator is worth having, so it
is used: a pass crossing north continues past 360 instead of unwinding
three quarters of a turn with the antenna sweeping the ground. Below the
configured elevation the mast is left alone — the numbers are right all
the way round the orbit, but a rotator that chases a satellite through
the far side of the earth spends the night turning, and a mast has a
finite number of turns in it.
2026-09-07 11:34:35 +02:00
rouggy 465481f8f1 feat(sat): Doppler tracking on the radio
The hard part of satellite tuning is not the arithmetic, it is deciding
who owns the dial. A tracker that forces both frequencies fights the
operator every time they turn the knob to follow a station across a
linear transponder; one that never touches the receiver leaves them
chasing a signal that slides nine kilohertz across a 70 cm pass.

So the operator owns the receiver and the tracker follows them. Every
second it asks the radio where the receiver actually is. Where it put it,
nothing has changed. Further than a dial-turn's tolerance, and the
operator has chosen a station: what they landed on is converted back into
a nominal frequency, and the transmitter is derived from that. Which is
the division of labour on a linear bird — the operator listens, the radio
does the sums.

Three ways to reach the radio, because a satellite pair is a shape of
operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is
asked for its OWN satellite mode: it pairs main and sub, gives full
duplex, and keeps the dials linked the way its designers meant, which is
always better than an imitation built out of split. A Flex gets two
slices, A the downlink and B the uplink, created when missing, because
"slice B does not exist" is not something to make an operator fix at the
start of a ten-minute pass. Everything else gets the downlink, and is
told so — half the job announced beats half the job hidden.

What goes in the log is the NOMINAL pair. Two stations working each other
through a transponder read different numbers off their dials at the same
instant; the only figure they can both agree on is the transponder's own.
FREQ is the uplink and FREQ_RX the downlink — the one place a satellite
QSO differs from every other kind, and the reason FREQ alone cannot
describe one.
2026-09-07 11:27:06 +02:00
rouggy 680bf410fe feat(sat): the Satellites tab
Three questions answered at once, because on a pass there is no time to
go looking for any of them: where the bird is, when the next one comes,
and what to tune. The map draws each satellite's footprint — the honest
answer to "can I hear it", since everything inside the circle has the
satellite above its horizon — and the selected one's path over the
ground. The pass list is every favourite in time order, the one in
progress in green.

The readout shows the corrected frequency large and the nominal one
beneath it. Only one of them, and an operator cannot tell a Doppler
correction from a mistuned transponder.

The map opens on the station rather than the Atlantic, and remembers
where it was left like the others. The panel is mounted only while its
tab is visible: it asks for the tuning once a second, and there is no
reason to compute an orbit nobody is looking at.
2026-09-07 10:56:28 +02:00
rouggy 1009d06a4c feat(sat): the station side — elements, plan, passes and tuning
What internal/sat could not know: where the antenna is, which birds the
operator cares about, and where the files live.

Startup reads the cached elements and the frequency plan from disk and
nothing else — one file and a few hundred parses, so the tab is full the
moment it is opened, on a shack PC with no internet as much as on one
with. Fetching is the slow, optional half and never blocks a launch; it
happens on its own only when the set is stale and the operator asked for
it.

Elements pasted in by hand go in their own file. The feed cache is
replaced wholesale on every refresh, so a freshly launched satellite —
whose elements circulate on a mailing list days before any feed carries
it, which is exactly the week everybody wants to hear it — would
otherwise be wiped by the first automatic update.

The list joins both halves and shows what is missing on either side. A
bird with elements and no plan is one the operator can still track; a
bird with a plan and no elements is the visible symptom of an element set
that is too old. Dropping either turns a fixable configuration problem
into a satellite that "does not exist".

GetSatelliteTuning is the working answer, and everything that will later
drive a radio is built on top of it rather than beside it, so the display
and the rig can never disagree. It keeps the operator's frequency
nominal and applies Doppler only on the way out: on a linear pass the
station being answered stays put on the dial while both radios chase the
shift. A geostationary bird is corrected by nothing at all.
2026-09-07 10:51:16 +02:00
rouggy 7a84f00060 feat(sat): element feeds and the frequency plan
Two things the tracker cannot work without, both kept apart from the
orbital maths on purpose.

The elements come from Celestrak's amateur group, with PE0SAT as the
fallback for the hour when Celestrak is rate-limiting a hundred trackers
at once. A malformed satellite is skipped rather than fatal — a feed of
two hundred birds with one bad checksum must still give the operator the
other hundred and ninety-nine — and the count is returned so the app can
say so. The cache is plain TLE text in the data directory, written
beside and renamed, and only replaced once a feed has produced usable
elements: a captive portal must not take away the set the station
already had. Loading it first is what makes the satellite tab full on a
shack PC with no internet.

The frequency plan is separate because it changes for different reasons:
elements every few days from a feed, a transponder when the satellite is
commanded into another mode. The shipped list is a starting point, copied
to the data directory on first use and read from there afterwards, so an
operator can correct a frequency without waiting for a release and keep
the correction across updates — and a file they have broken is reported,
not overwritten.

UplinkFor is the part that matters on the air. On an inverting linear
transponder, tuning up the downlink means going down the uplink; get it
backwards and you transmit at the far end of the passband from the
station you can hear, which is the classic first evening on a linear
bird.

Names are matched on letters and digits alone. Celestrak says
"RADFXSAT (FOX-1B)" where every operator says AO-91, and nobody spells
Es'hail the same way twice.
2026-09-07 10:45:50 +02:00
rouggy 2d71351080 feat(sat): the sky engine — elements, look angles, passes, Doppler
The foundation of the satellite branch, and nothing above it yet: where a
satellite is (SGP4 from akhenakh/sgp4, Apache-2.0 and pure Go, so the
no-cgo rule holds), where it will be (passes with an elevation floor,
because a three-degree scrape is a line in a table that will never be a
QSO), and what its motion does to a frequency.

The Doppler pair is the part worth being careful about: the two
corrections go in OPPOSITE directions. The downlink arrives shifted and
we tune to meet it; the uplink must LEAVE shifted the other way to land
on the transponder's nominal input. A test pins the signs and the size —
7 km/s on 2 m is about 3.4 kHz.

Elements keep their raw lines beside the parsed form: that is what the
cache stores and what an operator pastes by hand for a bird no feed
carries yet, which is exactly when everyone wants to hear it.
2026-09-07 10:36:56 +02:00
rouggy b0f76a8ba1 fix(yaesu): the RTTY sideband reaches the rig that is already connected
Ticking "RTTY on USB" changed nothing: the flag is deliberately absent
from catLinkSig — none of these preferences is worth dropping the CAT
link, and with it WSJT-X's rigctl session, to apply — so saving the
settings left the link alone and the running client kept its old answer
until the next launch.

Preferences that the link does not depend on are now pushed to the
connected rig when the settings are saved, through the Yaesu escape on
the manager. SetRTTYUpper joins the controller interface for that: it is
a preference rather than a command, but it has to be reachable on a rig
that is already talking.
2026-09-07 09:51:13 +02:00
rouggy ed062a040c fix(rotor widget): several rotors no longer run off the bottom
The selector row appears above the dial when there is more than one
rotor, and the widget's height is not its own to take — it sits in a
strip sized by the entry form beside it. The row was simply added, so the
SP/LP pair and half the Stop button went off the end.

The dial (24 px), the three button rows (8 px each) and the padding
(4 px) now give that height back between them, which is a selector row
almost exactly. The dial and the controls column stay the same height as
each other, so the two columns still line up.
2026-09-07 09:42:48 +02:00
rouggy 0430aab78e fix(update): relaunch without a hidden PowerShell
Windows Defender removed 0.27.14 from a station as
Trojan:Script/Wacatac.H!ml. That detection is machine-learning, not a
signature, and the behaviour it scored is ours: an unsigned binary
replaces itself on disk, clears the mark-of-the-web, and spawns a
windowless PowerShell that waits for its own process to die before
starting another executable. Byte for byte, that is a dropper; the model
reads the shape, not the intention, and "Script/" names the PowerShell.

The wait it was written for is not needed. --post-update already makes
the new instance patient with the single-instance mutex — twenty seconds
of it — so the new exe can be started directly while this one is still
shutting down and simply wait its turn.

The deferred-swap fallback keeps its helper: nothing else on a stock
Windows can wait for a pid and then move a file over an image that is
still running. It is reached only when the rename failed, never on the
ordinary path.
2026-09-07 09:36:02 +02:00
rouggy 5d526d29db chore: release v0.27.16 2026-09-06 23:29:46 +02:00
rouggy 09c4358626 chore(changelog): the Yaesu work opens 0.27.16
The eight-digit rigs, the typed watering hole and the RTTY sideband all
landed after the 0.27.15 release commit, so they were sitting in a block
that had already shipped. 0.27.15 says what was released again.
2026-09-06 23:29:31 +02:00
rouggy 9ce7cf3b69 feat: a typed watering hole carries its mode; Yaesu RTTY sideband
His log settles the 28.074 case: "SetCATFrequency 28.074 MHz" and the
state still reads mode=USB — nothing sent a mode, so the rig simply
stayed where it was. A spot click has always carried one; a frequency
typed by hand carried none. It now uses the same table and the same
tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), and only
towards the digital modes: tuning AWAY from one leaves the mode alone,
because there the frequency says nothing about what the operator means.

RTTY on Yaesu is a choice the log cannot make: ADIF records "RTTY" and
the rig has MD06 (RTTY-L) and MD09 (RTTY-U). The older lower sideband
stays the default and a station whose FSK controller wants the upper one
says so once in Settings → CAT.
2026-09-06 23:01:08 +02:00
rouggy 3745d23339 feat(yaesu): eight-digit rigs — the width comes from the radio
An FTDX3000 rejects every FA command: the FTDX10 family writes a
frequency in nine digits and everything before it — FTDX3000, FTDX5000,
FTDX1200, FT-2000, FT-950, FT-450 — writes eight, answering the longer
form with "?;". The rig would not follow and nothing said why.

The width is LEARNED from the rig's own replies rather than tabulated: it
announces the format in every answer to FA;, so it comes from the radio
in front of the operator instead of from a model list that is always one
release behind — and a Yaesu this backend has never heard of is right on
the first read. Nine until the first reply lands, which is what the
modern rigs use and what this backend was written against.

The five older ID codes are named too, so the console says FTDX3000
rather than a bare number.
2026-09-06 21:11:19 +02:00
rouggy 23f324e606 chore: release v0.27.15 2026-09-06 20:25:56 +02:00
rouggy 44a18ec799 chore(ui): shorter WSJT-X highlight wording
The main hint named three fixed colours, which stopped being true the
moment the operator could choose them. And the paragraph under "Mark
stations already worked" explained a trade-off nobody reads standing at
the radio; the switch says what it does.
2026-09-06 20:15:56 +02:00
rouggy 1e003bbdb7 chore(changelog): 0.27.15 reads novelties first
Same order as the last block: what is new leads, each fix follows the
thing it belongs to — the antenna pair, then the Icom network run, then
the rest.
2026-09-06 20:13:46 +02:00
rouggy 421bc372ee fix(steppir): the same fast poll, and motion reported at the command
The shortened transmit gag was written for an Ultrabeam that is now polled
four times a second while it moves. The SteppIR was still on two seconds
and reported nothing at all until its own poll came round, so the gap
between the 900 ms grace and the first poll that would have seen the
movement was a hole in "block TX while the elements travel" — the
transmitter released in the middle of a move.

It now reports a commanded move at once (bounded, so an antenna that
never answers cannot latch the inhibit on), on a COPY of the cached
status so the flag cannot leak into what the poll goroutine owns, and
follows the motors with its poll rate exactly as the Ultrabeam does.
2026-09-06 20:10:39 +02:00
rouggy c8c48d408f feat(motor antenna): amber at the order, not at the answer
The transmit gag starts when the move is commanded; the indicator waited
for a status poll to say the elements were travelling, so the two
disagreed by a second or more — and on an automatic follow (band change
with tracking on) there was no sign at all until a poll landed. The
backend now announces the order as it goes out, the interface shows it at
once and re-reads the antenna immediately, and the real status takes over
the moment it arrives.

The WSJT-X "grey out stations already worked" switch is renamed "Mark
stations already worked": with the colour now chosen by the operator the
old name described the wrong thing, but the switch still answers a
question the palette cannot — WHETHER dupes should be marked at all. On a
well-filled log they are most of a period, and a window where nearly
every line is coloured has stopped saying anything.
2026-09-06 20:06:05 +02:00
rouggy d001616767 fix: release TX when the elements stop; WSJT-X colours, and worked beats the watch list
The motorised antenna gagged the transmitter for a second or two after it
had finished moving. Three delays were stacked: the antenna polled every
two seconds, the gag held for three after the command whatever the
antenna said, and the widget refreshed every three. The antenna is now
asked four times a second WHILE IT MOVES — an idle one has nothing to say
and stays at two seconds — the gag only bridges the command itself
(900 ms), and the widget follows at half a second while moving.

WSJT-X highlighting:

- A watch-list station already worked on this band and mode is no longer
  painted as one to call. The list is a statement of intent, not of what
  is left to do, and its pink outranked every other verdict including the
  log's, so a worked station stayed pink for the session with nothing to
  tell it from one still needed.
- The four colours are the operator's to choose (Settings → UDP). Only
  the background: the text colour is derived by luma, so a dark blue
  cannot come back as black-on-black in somebody else's window. Changing
  one clears the installed highlights, or the de-duplication would keep
  showing yesterday's colour until a callsign changed verdict.
2026-09-06 18:43:08 +02:00