Compare commits

..
54 Commits
Author SHA1 Message Date
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
120 changed files with 12470 additions and 650 deletions
+112
View File
@@ -0,0 +1,112 @@
# Building OpsLog on Linux
OpsLog is developed on Windows. The Linux build shares every line of the
frontend and all but a handful of Go files; what differs is listed at the bottom
of this page.
**It cannot be cross-compiled from Windows.** Wails links against the system
WebKit on Linux, which needs cgo and the GTK/WebKit headers, so the binary has
to be produced on a Linux machine (or a container). What *can* be checked from
Windows — and is, at every release — is that the Go half still compiles:
```bash
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./...
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go vet ./...
```
## The short way
```bash
./scripts/linux-setup.sh
```
It checks everything below, prints the one install command your distribution
needs if something is missing, and builds when nothing is. The rest of this page
is what it checks, for when you would rather do it by hand.
## Dependencies
```bash
# Debian / Ubuntu
sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev nodejs npm
# Fedora
sudo dnf install gcc-c++ pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel nodejs npm
# Arch
sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm
```
**Go and node do not come from the package manager.** No current distribution
ships a Go new enough for `go.mod` (Ubuntu 24.04 / Mint 22 have 1.22, Ubuntu
22.04 / Mint 21 have 1.18), and Ubuntu 22.04 / Mint 21 ship node 12 where Vite
needs 18. Both are the usual reason a first build fails with an error that
points somewhere else entirely:
```bash
# Go, from go.dev
wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$HOME/go/bin:$PATH' >> ~/.profile # log out and back in
# node 20, only if `node -v` is below 18
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs
```
Then the Wails CLI:
```bash
go install github.com/wailsapp/wails/v2/cmd/[email protected]
wails doctor # says what is still missing
```
`libwebkit2gtk-4.0` also works; pass `-tags webkit2_40` to `wails build` if your
distribution only has the older one.
## Build
```bash
wails build # → build/bin/OpsLog
./build/bin/OpsLog
```
`wails dev` works the same as on Windows.
## Runtime requirements
- **PulseAudio or PipeWire** for the voice keyer, the QSO recorder and the CW
decoder. PipeWire is fine — OpsLog speaks the PulseAudio protocol, which
`pipewire-pulse` answers. Without a sound server those three features report
"cannot reach the sound server" and everything else works normally.
- **Serial port access** for CAT, keyers, rotators and amplifiers. Ports appear
as `/dev/ttyUSB0`, `/dev/ttyACM0`… and on most distributions belong to the
`dialout` group:
```bash
sudo usermod -aG dialout $USER # log out and back in
```
This is the single most common reason a rig that works in WSJT-X shows
"permission denied" in OpsLog.
- **TrustedQSL** (`tqsl`) for LoTW uploads, from your package manager. OpsLog
finds it on `PATH`.
## Where OpsLog keeps its data
Next to the binary, in `data/` — the same portable layout as on Windows, so a
folder in your home directory carries the logbook with it.
If the binary sits somewhere you cannot write (`/usr/bin`, `/opt`), OpsLog uses
`~/.local/share/OpsLog/data` instead and says so in `startup.log`. The startup
log itself lives in `~/.cache/OpsLog/startup.log`.
## What is different from the Windows build
| | |
|---|---|
| **OmniRig** | Not available — it is Windows COM automation. Use a native backend instead: Icom CI-V (USB and network), Yaesu, Kenwood/Elecraft, FlexRadio, TCI, Xiegu. |
| **Denkovi USB relay** | Not available — it needs FTDI's `ftd2xx.dll`. The other relay backends work. |
| **Audio** | PulseAudio/PipeWire instead of WASAPI. Same devices, same fixed 16 kHz mono format. |
| **Auto-update** | Works, and is simpler: Linux lets a running binary be replaced, so none of the Windows deferred-swap machinery is needed. |
| **Window placement** | OpsLog cannot read the monitor layout, so a saved window position is always trusted rather than clamped onto a visible screen. |
| **Single instance** | An `flock` on `$XDG_RUNTIME_DIR/OpsLog/instance.lock` instead of a named mutex. It cannot raise the existing window, only refuse to start a second one. |
+520 -113
View File
@@ -63,10 +63,12 @@ import (
"hamlog/internal/relaydev"
"hamlog/internal/rigctld"
"hamlog/internal/rotator/dcu1"
"hamlog/internal/rotator/easycomm"
"hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotator/spid"
"hamlog/internal/rotgenius"
"hamlog/internal/sat"
"hamlog/internal/scp"
"hamlog/internal/settings"
"hamlog/internal/solar"
@@ -120,6 +122,12 @@ const (
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
// OmniRig's model has two CW modes — PM_CW_U and PM_CW_L — and nothing says
// which one an .ini file calls plain CW. Icom files disagree with each other:
// on some, PM_CW_U is CI-V mode 0x03 (CW) and on others it is 0x07 (CW-R).
// An operator clicking a CW spot on an IC-7610 landed in CW-R and had to edit
// the rig file to get out of it. This says which bit means CW on THIS rig.
keyCATOmniRigCWLower = "cat.omnirig.cw_lower" // "1" → send PM_CW_L for CW
// Put the RADIO in USB for a digital mode instead of asking for the mode by
// name. Every backend, because the reason is the radio, not the link.
keyCATDigiUSB = "cat.digi_usb"
@@ -482,6 +490,9 @@ type CATSettings struct {
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
// operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
// OmniRigCWLower sends PM_CW_L instead of PM_CW_U when the mode is CW — for
// the rig files whose PM_CW_U is the REVERSE sideband. See the key comment.
OmniRigCWLower bool `json:"omnirig_cw_lower"`
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
// than naming the mode. What the QSO is LOGGED as never changes.
DigiAsUSB bool `json:"digi_as_usb"`
@@ -884,6 +895,23 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
// udpFocus arbitrates the entry field between several decoders running at
// once — see app_udp_focus.go.
udpFocus udpFocus
// Satellites. The elements (where the birds are) and the frequency plan
// (what to do with the radio) are held apart because they come from
// different places and change for different reasons — a feed every few
// days, an operator's correction when a transponder is switched.
satMu sync.Mutex
satStore *sat.Store // orbital elements, by satellite name
satBirds *sat.Birds // uplink/downlink plan
satFetch *sat.Fetcher // element feeds + the on-disk cache
// satTrack is the live tracker: the goroutine that walks the radio through a
// pass. nil when nothing is being tracked.
satTrackMu sync.Mutex
satTrack *satTracker
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
@@ -1643,6 +1671,10 @@ func (a *App) startup(ctx context.Context) {
a.alertStore = as
}
// Satellites: the cached elements and the frequency plan. Local files only —
// any element fetch it decides to make goes to the network on its own.
a.startSatellites()
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
@@ -1946,6 +1978,10 @@ func (a *App) shutdown(ctx context.Context) {
applog.Printf("shutdown: closing autostart programs")
a.CloseAutostartPrograms()
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
// Before CAT goes down: disarming satellite mode takes the radio out of full
// duplex and puts the transmitter back where the operator is listening, and
// that has to happen while the link is still up.
a.StopSatelliteTracking()
applog.Printf("shutdown: stopping UDP")
if a.udp != nil {
a.udp.StopAll()
@@ -2000,15 +2036,24 @@ func (a *App) shutdown(ctx context.Context) {
applog.Printf("shutdown: teardown done")
}
// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
// userDataDir returns the OpsLog data directory: "<exe dir>/data".
// All data (database, settings, cty.dat, logs) travels with the executable,
// making OpsLog fully portable for USB sticks and PC migrations.
//
// systemInstallDataDir is the one exception, and it never fires on Windows: a
// Linux build installed system-wide (/usr/bin, /opt) sits in a folder no user
// may write, so there the data moves to ~/.local/share/OpsLog. See
// datadir_linux.go for why that is decided by trying rather than by path.
func userDataDir() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("cannot locate executable: %w", err)
}
return filepath.Join(filepath.Dir(exe), "data"), nil
beside := filepath.Join(filepath.Dir(exe), "data")
if alt, ok := systemInstallDataDir(beside); ok {
return alt, nil
}
return beside, nil
}
// fileExists reports whether path exists and is a regular file.
@@ -2953,8 +2998,11 @@ func (a *App) RestartApp() error {
return fmt.Errorf("locate executable: %w", err)
}
// --relaunch: the child waits for OUR mutex instead of declaring us a
// duplicate — this instance is quitting, just not always fast enough.
cmd := exec.Command(exe, "--relaunch")
// duplicate — this instance is quitting, just not always fast enough. And
// --wait-pid, so it waits for this process to actually END rather than for a
// window of time to pass: shutting down closes a logbook and a CAT session,
// and on a remote database that takes as long as it takes.
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err)
@@ -3072,6 +3120,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
}()
a.applyStationDefaults(&q, true)
// Before fillRXDefaults, which copies the transmit frequency into the receive
// one: on a satellite the two are on different bands, and letting that copy
// happen first would bury the downlink under the uplink.
a.applySatellite(&q)
fillRXDefaults(&q)
fillDistance(&q)
a.applyDXCCNumber(&q)
@@ -3106,6 +3158,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
// The contact is over, so no decoder holds the entry field any more: the
// next station may come from whichever one hears it first.
a.udpFocus.release("QSO logged")
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
@@ -5579,11 +5634,19 @@ func (a *App) recomputeAwardRefsForCodesAsync(codes []string) {
// definition / reference-list change, or a bulk ADIF import.
func (a *App) recomputeAwardRefsAsync() {
go func() {
t0 := time.Now()
n, err := a.RecomputeAllAwardRefs()
if err != nil {
applog.Printf("award_refs: bulk recompute failed: %v", err)
return
}
// Logged on SUCCESS too, and this is why: "I changed an award and the
// column stayed empty" is unanswerable without knowing whether the
// recompute ran at all, how long it took, and whether it changed
// anything. Zero rows changed is a real answer — the definition matches
// nothing in the log — and it looks exactly like a recompute that never
// happened.
applog.Printf("award_refs: bulk recompute done — %d rows changed in %s", n, time.Since(t0).Round(time.Millisecond))
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
}
@@ -8322,7 +8385,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATOmniRigCWLower, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
if err != nil {
return CATSettings{}, err
}
@@ -8428,6 +8491,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if out.DigitalDefault == "" {
out.DigitalDefault = "FT8"
}
out.OmniRigCWLower = m[keyCATOmniRigCWLower] == "1"
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v
}
@@ -8544,6 +8608,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATOmniRigCWLower: boolStr(s.OmniRigCWLower),
keyCATDigiUSB: boolStr(s.DigiAsUSB),
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
@@ -11095,6 +11160,19 @@ func (a *App) UILog(msg string) {
// that hasn't customised them: request the online confirmations (eQSL/LoTW/
// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and
// "N" for paper and everything received.
// defaultQSLDefaults is the status stamped on a new QSO when the operator has
// not chosen otherwise.
//
// One rule, for every service: the SENT side starts at R (requested — this
// contact is waiting to go out) and the RECEIVED side at N (nothing has come
// back yet). Paper QSL is the exception on the sent side: a card is only
// "requested" once somebody asks for one.
//
// The sent side must NEVER default to Y. Y means "already sent", so the
// uploader skips the contact for ever — which is exactly how an operator ends
// up with a logbook that never reaches eQSL and no error to explain it. Every
// service added here has to appear in this list for that reason; a service left
// out gets an empty status, which is a state nobody chose.
func defaultQSLDefaults() QSLDefaults {
return QSLDefaults{
QSLSent: "N", QSLRcvd: "N",
@@ -11102,10 +11180,40 @@ func defaultQSLDefaults() QSLDefaults {
LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
HamqthStatus: "R",
HamlogStatus: "R", HamlogCfm: "N",
QRZComStatus: "R", QRZComCfm: "N",
}
}
// fillMissingQSLDefaults replaces an empty status with the shipped one.
//
// A service added to OpsLog after an operator last saved their confirmations
// has no stored value, so it came back blank — and blank is not one of the
// statuses anything acts on, it is the absence of one. HAMLOG.online arrived
// that way and every existing profile got nothing for it.
//
// An operator who genuinely wants a column left alone has "N" for that, which
// says the same thing and is a status. So an empty value here means "this was
// never asked", and the answer is what a fresh profile would have got.
func fillMissingQSLDefaults(d QSLDefaults) QSLDefaults {
def := defaultQSLDefaults()
for _, f := range []struct{ cur, def *string }{
{&d.QSLSent, &def.QSLSent}, {&d.QSLRcvd, &def.QSLRcvd},
{&d.LOTWSent, &def.LOTWSent}, {&d.LOTWRcvd, &def.LOTWRcvd},
{&d.EQSLSent, &def.EQSLSent}, {&d.EQSLRcvd, &def.EQSLRcvd},
{&d.ClublogStatus, &def.ClublogStatus}, {&d.ClublogCfm, &def.ClublogCfm},
{&d.HRDLogStatus, &def.HRDLogStatus},
{&d.QRZComStatus, &def.QRZComStatus}, {&d.QRZComCfm, &def.QRZComCfm},
{&d.HamlogStatus, &def.HamlogStatus}, {&d.HamlogCfm, &def.HamlogCfm},
{&d.HamqthStatus, &def.HamqthStatus},
} {
if strings.TrimSpace(*f.cur) == "" {
*f.cur = *f.def
}
}
return d
}
func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out := QSLDefaults{}
if a.settings == nil {
@@ -11142,7 +11250,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
return out, nil
return fillMissingQSLDefaults(out), nil
}
// SaveQSLDefaults persists the configured defaults. Future QSO inserts
@@ -14590,6 +14698,13 @@ func (a *App) consumeUDPEvents() {
"adif": ev.LoggedADIF,
})
case ev.ClearCall:
// Only from the program the entry field belongs to. An idle decoder
// alongside the one being worked clears its own DX Call for reasons
// of its own, and that must not empty a field somebody else filled.
if !a.udpFocus.holds(ev.ProgramID) {
break
}
a.udpFocus.release("DX Call cleared")
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
"service": string(ev.Service),
@@ -14614,6 +14729,12 @@ func (a *App) consumeUDPEvents() {
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
}
case ev.DXCall != "":
// With two or three decoders running, the one announcing a station
// takes the entry field and keeps it until it lets go. See udpFocus.
if !a.udpFocus.claim(ev.ProgramID) {
a.udpFocus.noteIgnored(ev.ProgramID, ev.DXCall)
break
}
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
"call": ev.DXCall,
@@ -16230,9 +16351,33 @@ func (a *App) reloadCAT() {
go a.startQSORecorderIfEnabled()
}
a.reloadCATShare(s)
// Nothing about the link changed → leave it connected. See catLinkSig.
// Preferences that the LINK does not depend on, pushed to the rig that is
// already connected. They are deliberately absent from catLinkSig — none of
// them is worth dropping the CAT link (and with it WSJT-X's rigctl session)
// to apply — so without this they waited for the next launch, and the
// operator ticking "RTTY on USB" watched the rig go on choosing LSB.
if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return nil })
}
if s.Enabled && s.Backend == "omnirig" && a.cat != nil {
_ = a.cat.OmniRigDo(func(o cat.OmniRigController) error { o.SetCWLower(s.OmniRigCWLower); return nil })
}
// Nothing about the link changed → leave it as it is. See catLinkSig.
if sig := catLinkSig(s); sig == a.catSig {
applog.Printf("cat: settings saved, link unchanged — staying connected")
// Which is NOT the same as staying connected.
//
// A disabled CAT has the signature "off", so every save while the master
// switch is off took this branch and announced that the link was
// unchanged and connected — when nothing was connected at all. A Flex
// 6700 owner saved six times, with the right radio, the right address
// and the detector naming his radio back at him, and this line told him
// each time that all was well. It was the one line that could have said
// otherwise.
if !s.Enabled {
applog.Printf("cat: settings saved, but CAT is switched OFF — nothing will connect until you tick it in Settings ▸ CAT")
} else {
applog.Printf("cat: settings saved, link unchanged — staying connected")
}
return
} else {
a.catSig = sig
@@ -16254,7 +16399,7 @@ func (a *App) reloadCAT() {
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
// reloadCAT raised the existing instance's window to the front,
// which is what Log4OM avoids by relying entirely on COM activation.
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO, s.OmniRigCWLower))
case "flex":
// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
@@ -16767,6 +16912,9 @@ func (a *App) reloadAfterProfileSwitch() {
// of the process.
a.disarmAutoCall("profile switch")
a.autoCallEngine().Reset()
// Same reasoning for the satellite tracker: it transmits, and the new profile
// may be a different station on a different antenna.
a.StopSatelliteTracking()
}
// DuplicateProfile clones an existing profile under newName. Useful when
@@ -16808,11 +16956,13 @@ const keyRotatorsList = "rotators.json"
// contributes TWO logical rotors — Name/Motorized for the first, Name2/Motorized2
// for the second.
type RotatorDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (GS-232A)
ID string `json:"id"`
Name string `json:"name"`
// Type is the backend. See rotatorTypes for the list and what each one can
// do; normRotorType clamps anything unknown to "pst".
Type string `json:"type"`
Host string `json:"host"` // default 127.0.0.1
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco)
Port int `json:"port"` // per-backend default, see rotatorDefaultPort
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
RotatorNum int `json:"rotator_num"` // Rotator Genius internal index (1/2) when not Dual
Dual bool `json:"dual"` // Rotator Genius: drive both ports → two logical rotors
@@ -16826,36 +16976,125 @@ type RotatorDevice struct {
// azimuth + elevation) or "rot1prog" (the older azimuth-only controller).
// They differ in reply length and baud rate, so guessing is not an option.
SpidModel string `json:"spid_model,omitempty"`
// MaxAz is the azimuth range of the mast: 360 or 450. It matters only when
// OpsLog drives the controller itself — an overlap rotator reached at 350°
// through 10° unwinds the cable, and the choice between going the short way
// and the long way is ours to make. Through PstRotator it is deliberately
// ignored: PstRotator knows which controller is on the other end and does
// its own overlap, and two programs each deciding to go the long way round
// is how an antenna unwinds in the middle of a satellite pass.
MaxAz int `json:"max_az,omitempty"`
}
// rotatorTypes is the one place that says what each backend is and what it can
// do. The settings panel renders its dropdown from this — labels, the "Az + El"
// badge, which transports to offer — instead of keeping a second, drifting copy
// of the same knowledge in TypeScript.
//
// Elevation here means the backend has an elevation AXIS, which is what the
// satellite tracker needs. It is not the same question as whether a given
// station's mast has an elevation motor: a PstRotator setup answers "it
// depends", which is why pst carries the per-device HasElevation switch and is
// the only type whose capability is decided by rotorHasElevation rather than by
// this table.
var rotatorTypes = []RotatorTypeInfo{
{ID: "pst", Label: "PstRotator (UDP)", Elevation: false, Optional: true, Network: true, DefaultPort: 12000},
{ID: "rotgenius", Label: "Rotator Genius (4O3A, native)", Network: true, DefaultPort: 9006},
{ID: "arco", Label: "GS-232 azimuth controller (microHAM ARCO, ERC)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 9600},
{ID: "erc", Label: "ERC-M by DF9GR (Yaesu G-5500 az/el)", Elevation: true, Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 19200},
{ID: "dcu1", Label: "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 4800},
{ID: "spid", Label: "SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)", Elevation: true, Serial: true, DefaultBaud: 600},
{ID: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600},
}
// RotatorTypeInfo describes one rotator backend to the settings panel.
type RotatorTypeInfo struct {
ID string `json:"id"`
Label string `json:"label"`
// Elevation: this backend drives an elevation axis, so a satellite pass can
// be followed with it.
Elevation bool `json:"elevation"`
// Optional: the elevation axis depends on the station rather than on the
// backend, and the operator says so per device (PstRotator).
Optional bool `json:"elevation_optional"`
Serial bool `json:"serial"`
Network bool `json:"network"`
DefaultPort int `json:"default_port"`
DefaultBaud int `json:"default_baud"`
}
// GetRotatorTypes lists the rotator backends for the settings panel.
func (a *App) GetRotatorTypes() []RotatorTypeInfo {
out := make([]RotatorTypeInfo, len(rotatorTypes))
copy(out, rotatorTypes)
return out
}
// rotorTypeInfo looks a backend up, falling back to PstRotator like
// normRotorType does.
func rotorTypeInfo(typ string) RotatorTypeInfo {
typ = normRotorType(typ)
for _, t := range rotatorTypes {
if t.ID == typ {
return t
}
}
return rotatorTypes[0]
}
// rotorHasElevation reports whether this configured rotor can be pointed in
// elevation — the question the satellite tracker asks before offering a rotor.
func rotorHasElevation(d RotatorDevice) bool {
t := rotorTypeInfo(d.Type)
if t.Optional {
// PstRotator: the elevation is the station's, not the protocol's.
return d.HasElevation
}
if t.ID == "spid" {
// Rot1Prog is the azimuth-only controller. Offering it for a satellite
// pass would mean sending elevation commands into a reply format that
// has no room for them.
return d.SpidModel != "rot1prog"
}
return t.Elevation
}
// logicalRotor is one addressable rotor. Flattening the device list expands a
// Dual Rotator Genius into two.
type logicalRotor struct {
// Key addresses this rotor from elsewhere in the app — the satellite
// tracker stores one. It is the device id, with "#2" for the second port of
// a Dual Rotator Genius, and NOT the list index: an operator who deletes the
// first rotor must not silently have the satellite follow a different mast.
Key string
Name string
Motorized bool
HasEl bool
Link rotorLink
}
// normRotorType clamps a rotor type to a known backend.
func normRotorType(t string) string {
if t == "rotgenius" || t == "arco" || t == "dcu1" || t == "spid" {
return t
for _, k := range rotatorTypes {
if k.ID == t {
return t
}
}
return "pst"
}
// rotatorDefaultPort is each backend's default network port.
//
// Two of them are placeholders rather than standards, and the difference
// matters when a link fails: 4001 for a GS-232 or DCU-1 controller is whatever
// the operator typed into its own LAN menu (or into the serial-over-IP bridge),
// so a refused connection there means "that is not the number you set", not
// "the controller is off".
func rotatorDefaultPort(typ string) int {
switch typ {
case "rotgenius":
return 9006 // 4O3A native default
case "arco":
return 4001 // placeholder — the real number is set in ARCO's LAN menu
case "dcu1":
return 4001 // only used with a serial-over-IP bridge; DCU-1 has no standard
default:
return 12000 // PstRotator UDP
if p := rotorTypeInfo(typ).DefaultPort; p > 0 {
return p
}
return 12000 // PstRotator UDP
}
// deviceLink builds the connection params for a device's rotor. sub selects the
@@ -16863,8 +17102,11 @@ func rotatorDefaultPort(typ string) int {
func deviceLink(d RotatorDevice, sub int) rotorLink {
l := rotorLink{
Type: normRotorType(d.Type), Host: d.Host, Port: d.Port,
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: d.HasElevation,
SpidModel: d.SpidModel,
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: rotorHasElevation(d),
SpidModel: d.SpidModel, MaxAz: d.MaxAz,
}
if l.MaxAz != 450 {
l.MaxAz = 360
}
if l.Host == "" {
l.Host = "127.0.0.1"
@@ -16903,18 +17145,39 @@ func deviceLink(d RotatorDevice, sub int) rotorLink {
func flattenRotors(devs []RotatorDevice) []logicalRotor {
var out []logicalRotor
for _, d := range devs {
el := rotorHasElevation(d)
if normRotorType(d.Type) == "rotgenius" && d.Dual {
out = append(out,
logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)},
logicalRotor{Name: d.Name2, Motorized: d.Motorized2, Link: deviceLink(d, 2)},
logicalRotor{Key: d.ID, Name: d.Name, Motorized: d.Motorized, HasEl: el, Link: deviceLink(d, 1)},
logicalRotor{Key: d.ID + "#2", Name: d.Name2, Motorized: d.Motorized2, HasEl: el, Link: deviceLink(d, 2)},
)
continue
}
out = append(out, logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)})
out = append(out, logicalRotor{Key: d.ID, Name: d.Name, Motorized: d.Motorized, HasEl: el, Link: deviceLink(d, 1)})
}
return out
}
// rotorByKey finds a logical rotor by the key flattenRotors gave it. Used by
// anything that stores a choice of rotor rather than driving the active one —
// the satellite tracker, so far.
func (a *App) rotorByKey(key string) (logicalRotor, bool) {
key = strings.TrimSpace(key)
if key == "" {
return logicalRotor{}, false
}
devs, err := a.GetRotators()
if err != nil {
return logicalRotor{}, false
}
for _, r := range flattenRotors(devs) {
if r.Key == key {
return r, true
}
}
return logicalRotor{}, false
}
// GetRotators returns the configured rotor list, migrating the legacy single-
// rotor flat settings into the list on first read (persisted on next save).
func (a *App) GetRotators() ([]RotatorDevice, error) {
@@ -16993,7 +17256,16 @@ func (a *App) SaveRotators(list []RotatorDevice) error {
d.Transport = "tcp"
}
if d.Baud <= 0 {
d.Baud = 9600
// Per backend, not a blanket 9600: a SPID at 9600 is silent (it runs
// at 600 or 1200), and an ERC-M ships at 19200. A wrong baud rate
// reads exactly like a dead controller.
d.Baud = rotorTypeInfo(d.Type).DefaultBaud
if d.Baud <= 0 {
d.Baud = 9600
}
}
if d.MaxAz != 450 {
d.MaxAz = 360
}
}
b, err := json.Marshal(list)
@@ -17015,6 +17287,7 @@ type rotorLink struct {
Baud int
HasElevation bool
SpidModel string // SPID: "rot2prog" (default) | "rot1prog"
MaxAz int // 360 or 450, for the backends OpsLog drives directly
}
// activeRotorIndex returns the compass-selected rotor index, clamped to the
@@ -17052,6 +17325,34 @@ func arcoClient(l rotorLink) *gs232.Client {
return gs232.New(l.Host, l.Port)
}
// ercClient builds the GS-232 client for an ERC-M (Easy Rotor Control, DF9GR).
//
// Same wire protocol as the ARCO above, and a separate rotor type all the same:
// the ERC-M drives BOTH axes of a Yaesu G-5500, and "does this rotor have an
// elevation motor" is the question the satellite tracker asks. Folding it into
// "arco" would have made every ARCO owner appear in the satellite rotor list
// with an elevation axis they do not have.
func ercClient(l rotorLink) *gs232.Client {
if l.Transport == "serial" {
return gs232.NewSerial(l.ComPort, l.Baud)
}
return gs232.New(l.Host, l.Port)
}
// easycommClient builds the EasyComm II client for a rotor.
//
// EasyComm is what SatPC32, Gpredict and K3NG's firmware speak, so it is the
// common tongue of home-built az/el controllers. It lives in the rotator list
// like every other backend now: it used to be configured inside the satellite
// settings, which meant an operator with one mast described it twice and could
// describe it differently the second time.
func easycommClient(l rotorLink) *easycomm.Client {
if l.Transport == "serial" {
return easycomm.NewSerial(l.ComPort, l.Baud, l.MaxAz)
}
return easycomm.New(l.Host, l.Port, l.MaxAz)
}
// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
// or a serial-over-IP bridge on TCP.
@@ -17084,6 +17385,11 @@ type RotatorHeading struct {
OK bool `json:"ok"`
Azimuth int `json:"azimuth"`
Raw string `json:"raw"`
// Elevation is only meaningful when HasElevation is set. The two travel
// together so an az-only rotor cannot be drawn pointing at the horizon,
// which is a real elevation and not the absence of one.
Elevation int `json:"elevation"`
HasElevation bool `json:"has_elevation"`
// The compass renders a rotor selector from these — one entry per logical
// rotor — without an extra roundtrip.
Rotors []string `json:"rotors"` // names of every logical rotor (may be empty strings)
@@ -17106,6 +17412,126 @@ func (a *App) activeRotor() (lr logicalRotor, rotors []logicalRotor, idx int, ok
return rotors[idx], rotors, idx, true
}
// linkHeading asks one rotor where it is.
//
// The per-backend switch lives here, once, so the compass and the satellite
// tracker read a rotor the same way. hasEl says whether the elevation returned
// means anything: an azimuth controller answers the azimuth question perfectly
// well and has nothing to say about the other axis, and reporting a zero there
// would draw an antenna lying on the horizon.
//
// raw is the controller's own reply, kept for the log — it is what tells a
// baffled operator whether the port is silent or answering something we did not
// expect.
func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error) {
switch l.Type {
case "rotgenius":
st, r, herr := rotgenius.New(l.Host, l.Port).Heading(l.Num)
if herr != nil {
return 0, 0, false, "", herr
}
if !st.Connected {
return 0, 0, false, "sensor not connected (999)", fmt.Errorf("sensor not connected")
}
return float64(st.Azimuth), 0, false, r, nil
case "arco":
v, r, herr := arcoClient(l).Heading()
return float64(v), 0, false, r, herr
case "erc":
aa, ee, r, herr := ercClient(l).Position()
return float64(aa), float64(ee), herr == nil, r, herr
case "easycomm":
aa, ee, live, herr := easycommClient(l).Heading()
if herr != nil {
return 0, 0, false, "", herr
}
r := fmt.Sprintf("AZ %.0f° EL %.0f°", aa, ee)
if !live {
// The controller answered nothing and this is the last COMMANDED
// position. Say so: a stuck rotator must not be able to hide behind
// an order it never carried out.
r += " (commanded)"
}
return aa, ee, true, r, nil
case "spid":
aa, ee, herr := spidClient(l).Heading()
if herr != nil {
return 0, 0, false, "", herr
}
if l.HasElevation {
return float64(aa), float64(ee), true, fmt.Sprintf("AZ %d° EL %d°", aa, ee), nil
}
return float64(aa), 0, false, fmt.Sprintf("%d°", aa), nil
case "dcu1":
v, r, herr := dcu1Client(l).Heading()
return float64(v), 0, false, r, herr
default:
v, r, herr := pst.New(l.Host, l.Port).Heading()
if herr != nil {
// PstRotator's own text is more useful than the transport error.
return 0, 0, false, r, herr
}
if l.HasElevation {
if e, _, eerr := pst.New(l.Host, l.Port).Elevation(); eerr == nil {
return float64(v), float64(e), true, r, nil
}
}
return float64(v), 0, false, r, nil
}
}
// linkGoTo points one rotor. An elevation below zero is the callers' "no
// opinion" — a spot click, a compass drag — and leaves the elevation axis where
// it is rather than swinging a dish to the horizon.
func linkGoTo(l rotorLink, az, el int) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
case "arco":
return arcoClient(l).GoTo(az)
case "erc":
if el < 0 {
return ercClient(l).GoTo(az)
}
return ercClient(l).GoToAzEl(az, el)
case "easycomm":
if el < 0 {
if _, cur, _, err := easycommClient(l).Heading(); err == nil {
el = int(math.Round(cur))
} else {
el = 0
}
}
return easycommClient(l).Point(float64(az), float64(el))
case "spid":
return spidClient(l).GoTo(az, el)
case "dcu1":
return dcu1Client(l).GoTo(az)
default:
return pst.New(l.Host, l.Port).GoTo(az, l.HasElevation, el)
}
}
// linkStop interrupts one rotor.
func linkStop(l rotorLink) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).Stop()
case "arco":
return arcoClient(l).Stop()
case "erc":
return ercClient(l).Stop()
case "easycomm":
return easycommClient(l).Stop()
case "spid":
return spidClient(l).Stop()
case "dcu1":
return dcu1Client(l).Stop()
default:
return pst.New(l.Host, l.Port).Stop()
}
}
// GetRotatorHeading queries the active rotor for its azimuth. Returns
// Enabled=false when no rotator is configured. Polled by the status bar.
func (a *App) GetRotatorHeading() RotatorHeading {
@@ -17118,63 +17544,19 @@ func (a *App) GetRotatorHeading() RotatorHeading {
names[i] = r.Name
}
base := RotatorHeading{Enabled: true, Rotors: names, Active: idx, Motorized: lr.Motorized}
link := lr.Link
switch link.Type {
case "rotgenius":
st, raw, herr := rotgenius.New(link.Host, link.Port).Heading(link.Num)
if herr != nil {
base.Raw = herr.Error()
return base
}
if !st.Connected {
base.Raw = "sensor not connected (999)"
return base
}
base.OK = true
base.Azimuth = st.Azimuth
az, el, hasEl, raw, err := linkHeading(lr.Link)
if err != nil {
base.Raw = raw
return base
case "arco":
az, raw, herr := arcoClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
if base.Raw == "" {
base.Raw = err.Error()
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
case "spid":
az, _, herr := spidClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth = az
base.Raw = fmt.Sprintf("%d°", az)
return base
case "dcu1":
az, raw, herr := dcu1Client(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
default:
az, raw, herr := pst.New(link.Host, link.Port).Heading()
if herr != nil {
base.Raw = raw
return base
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
}
base.OK = true
base.Azimuth = int(math.Round(az))
base.Elevation, base.HasElevation = int(math.Round(el)), hasEl
base.Raw = raw
return base
}
// RotatorGoTo points the active rotor at the given azimuth (and optional
@@ -17198,19 +17580,7 @@ func (a *App) RotatorGoToPath(az int, el int, path string) error {
if !ok {
return fmt.Errorf("no rotator configured")
}
link := lr.Link
switch link.Type {
case "rotgenius":
return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
case "arco":
return arcoClient(link).GoTo(az)
case "spid":
return spidClient(link).GoTo(az, el)
case "dcu1":
return dcu1Client(link).GoTo(az)
default:
return pst.New(link.Host, link.Port).GoTo(az, link.HasElevation, el)
}
return linkGoTo(lr.Link, az, el)
}
// RotatorStop interrupts any in-progress rotation of the active rotor.
@@ -17219,19 +17589,7 @@ func (a *App) RotatorStop() error {
if !ok {
return fmt.Errorf("no rotator configured")
}
link := lr.Link
switch link.Type {
case "rotgenius":
return rotgenius.New(link.Host, link.Port).Stop()
case "arco":
return arcoClient(link).Stop()
case "spid":
return spidClient(link).Stop()
case "dcu1":
return dcu1Client(link).Stop()
default:
return pst.New(link.Host, link.Port).Stop()
}
return linkStop(lr.Link)
}
// RotorPreset is one quick-turn button on the rotor widget: a short label and
@@ -17354,8 +17712,12 @@ func (a *App) RotatorPark() error {
switch link.Type {
case "rotgenius":
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
case "arco":
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
case "arco", "erc":
return fmt.Errorf("park is a PstRotator feature; not available over a GS-232 link")
case "easycomm":
// EasyComm has no park command either, but it does take an absolute
// position — and the satellite tracker's own park does exactly this.
return easycommClient(link).Point(0, 0)
case "spid":
return fmt.Errorf("park is a PstRotator feature; a SPID controller has no park command")
case "dcu1":
@@ -17398,6 +17760,21 @@ func testRotorLink(l rotorLink) error {
// GS-232 — without moving the antenna.
_, _, err := arcoClient(l).Heading()
return err
case "erc":
if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
return fmt.Errorf("select the ERC-M's COM port first")
}
// Both axes, because reading only the azimuth would pass on a controller
// wired for azimuth alone — and the whole reason for choosing ERC-M over
// the plain GS-232 entry is that it has an elevation motor.
_, _, _, err := ercClient(l).Position()
return err
case "easycomm":
if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
return fmt.Errorf("select the controller's COM port first")
}
_, _, _, err := easycommClient(l).Heading()
return err
case "spid":
if strings.TrimSpace(l.ComPort) == "" {
return fmt.Errorf("select the SPID controller's COM port first")
@@ -19755,7 +20132,7 @@ func tidySerialPorts(ports []string) []string {
}
key := strings.ToUpper(name)
if seen[key] {
applog.Printf("serial: %s is claimed by more than one device in the Windows port map — listing it once", name)
applog.Printf("serial: %s is claimed by more than one device in the system port map — listing it once", name)
continue
}
seen[key] = true
@@ -19772,11 +20149,41 @@ func tidySerialPorts(ports []string) []string {
case okj:
return false
}
return out[i] < out[j]
return naturalLess(out[i], out[j])
})
return out
}
// naturalLess orders names that end in a number by that number, so
// /dev/ttyUSB9 comes before /dev/ttyUSB10. The same trap as COM4/COM10 above,
// met on Linux where the port names are paths rather than COMn — a rig on
// ttyUSB10 listed between ttyUSB1 and ttyUSB2 is a rig the operator scrolls
// past.
func naturalLess(a, b string) bool {
pa, na, oka := trailingNumber(a)
pb, nb, okb := trailingNumber(b)
if oka && okb && pa == pb {
return na < nb
}
return a < b
}
// trailingNumber splits "name123" into "name" and 123.
func trailingNumber(s string) (string, int, bool) {
i := len(s)
for i > 0 && s[i-1] >= '0' && s[i-1] <= '9' {
i--
}
if i == len(s) || i == 0 {
return s, 0, false
}
n, err := strconv.Atoi(s[i:])
if err != nil {
return s, 0, false
}
return s[:i], n, true
}
// comPortNumber extracts n from "COMn", false for any other shape.
func comPortNumber(s string) (int, bool) {
if len(s) <= 3 || !strings.EqualFold(s[:3], "COM") {
+1039
View File
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
package main
// How a satellite station points its antenna.
//
// It does NOT configure a rotator. Every rotator interface OpsLog knows lives in
// Settings ▸ Rotator, once, and the satellite page only CHOOSES one of them.
// The two used to be separate: EasyComm and PstRotator were described inside the
// satellite settings while five other backends were described in the rotator
// list, so an operator with one mast described it twice — and could describe it
// differently the second time, which is a station that works on HF and not on a
// pass, for no reason anyone can see.
//
// What remains here is the adapter: turning whichever backend the operator
// picked into the three things a pass needs — point it, ask where it is, let go
// of it at the end.
import (
"fmt"
"math"
"strings"
"sync"
"hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotator/spid"
)
// satRotator is what the tracker needs of an antenna: point it, ask where it
// is, and let go of it at the end of the pass.
type satRotator interface {
Point(az, el float64) error
// Heading reports where the antenna is. live is false when the answer is
// the last commanded position rather than a reading — a stuck rotator must
// not be able to hide behind an order it never carried out.
Heading() (az, el float64, live bool, err error)
Close()
}
// The legacy satellite-only rotator kinds. They are no longer stored; they
// survive only so migrateSatRotator can read what an operator configured before
// the rotator list existed.
const (
satRotEasycomm = "easycomm"
satRotPst = "pstrotator"
)
// newSatRotator builds a controller for the rotor the satellite page selected.
func (a *App) newSatRotator(s SatSettings) (satRotator, error) {
if strings.TrimSpace(s.RotID) == "" {
return nil, fmt.Errorf("no rotator chosen for satellite tracking — pick one in Settings ▸ Satellite")
}
lr, ok := a.rotorByKey(s.RotID)
if !ok {
// The rotor was deleted from the list after being chosen here. Say that,
// rather than failing to connect to an address nobody can see any more.
return nil, fmt.Errorf("the rotator chosen for satellite tracking no longer exists in Settings ▸ Rotator")
}
// Azimuth only: any rotor will do, including the tower the operator already
// turns for HF. See SatSettings.RotAzOnly for why this is the common case
// rather than a fallback.
if s.RotAzOnly {
return &azOnlySatRotator{link: lr.Link}, nil
}
if !lr.HasEl {
name := strings.TrimSpace(lr.Name)
if name == "" {
name = "this rotator"
}
return nil, fmt.Errorf("%s has no elevation axis — tick \"follow the azimuth only\" in Settings ▸ Satellite, or pick an az/el rotator", name)
}
l := lr.Link
switch l.Type {
case "pst":
return &pstSatRotator{c: pst.New(l.Host, l.Port), maxAz: l.MaxAz}, nil
case "easycomm":
return easycommClient(l), nil
case "erc":
return &gs232SatRotator{c: ercClient(l), maxAz: l.MaxAz}, nil
case "spid":
return &spidSatRotator{c: spidClient(l)}, nil
default:
return nil, fmt.Errorf("the %s backend cannot be pointed in elevation", l.Type)
}
}
// SatelliteRotorChoice is one entry in the satellite page's rotator dropdown.
type SatelliteRotorChoice struct {
Key string `json:"key"`
// Name is the operator's label; Type is the backend's, for the rotors left
// unnamed (a list of three blank rows is a list of one rotor as far as
// anybody can tell).
Name string `json:"name"`
Type string `json:"type"`
HasEl bool `json:"has_el"`
}
// ListSatelliteRotors returns every configured rotor, elevation-capable or not.
//
// Never filtered. Which of them can be USED depends on the azimuth-only switch,
// and that is a question for the panel: with it off an azimuth rotor is shown
// greyed and says why, with it on every rotor is fair game. Hiding them
// outright would only teach an operator with one mast that OpsLog cannot find
// it.
func (a *App) ListSatelliteRotors() ([]SatelliteRotorChoice, error) {
devs, err := a.GetRotators()
if err != nil {
return nil, err
}
out := []SatelliteRotorChoice{}
for _, r := range flattenRotors(devs) {
out = append(out, SatelliteRotorChoice{
Key: r.Key, Name: r.Name, Type: rotorTypeInfo(r.Link.Type).Label, HasEl: r.HasEl,
})
}
return out, nil
}
// gs232SatRotator points an ERC-M (or any GS-232 az/el controller) through the
// W command.
//
// The 450° overlap is handled HERE and not in the package, the same way the
// EasyComm client does it: a controller reports 0-450 and takes 0-450, but the
// tracker works in true bearings, and which of the two ways round to reach 010°
// depends on where the mast currently is.
type gs232SatRotator struct {
c *gs232.Client
maxAz int
}
func (g *gs232SatRotator) Point(az, el float64) error {
return g.c.GoToAzEl(int(math.Round(satWrapAz(az, g.maxAz))), int(math.Round(clampEl(el))))
}
func (g *gs232SatRotator) Heading() (float64, float64, bool, error) {
az, el, _, err := g.c.Position()
if err != nil {
return 0, 0, false, err
}
return float64(az), float64(el), true, nil
}
// Close: nothing to release. The serial port is held by the gs232 package, which
// keeps it open across the whole session on purpose — an Arduino-based
// controller reboots every time its port is opened.
func (g *gs232SatRotator) Close() {}
// spidSatRotator points a SPID Rot2Prog. Its protocol is absolute and binary,
// with no overlap notion to manage: the controller is told a bearing and a
// resolution and works out its own path.
type spidSatRotator struct{ c *spid.Client }
func (s *spidSatRotator) Point(az, el float64) error {
a := math.Mod(az, 360)
if a < 0 {
a += 360
}
return s.c.GoTo(int(math.Round(a)), int(math.Round(clampEl(el))))
}
func (s *spidSatRotator) Heading() (float64, float64, bool, error) {
az, el, err := s.c.Heading()
if err != nil {
return 0, 0, false, err
}
return float64(az), float64(el), true, nil
}
func (s *spidSatRotator) Close() {}
// satWrapAz maps a true bearing onto what the controller accepts. On a 450°
// mast the far end of the overlap is reachable two ways and the higher number is
// chosen for the last 90°, which is what keeps a pass crossing north from
// unwinding the cable in the middle of it.
func satWrapAz(az float64, maxAz int) float64 {
a := math.Mod(az, 360)
if a < 0 {
a += 360
}
if maxAz == 450 && a < 90 {
return a + 360
}
return a
}
// clampEl keeps the elevation inside what a mast will accept. 180 and not 90: a
// G-5500 goes past the zenith and keeps counting, which is how an overhead pass
// is followed without swinging the azimuth 180° through the middle of it.
func clampEl(el float64) float64 {
if el < 0 {
return 0
}
if el > 180 {
return 180
}
return el
}
// pstSatRotator points the antenna through PstRotator.
//
// PstRotator takes whole degrees and does its own overlap handling for a 450°
// rotator — it knows which controller is on the other end, and OpsLog does not.
// So the azimuth is sent plainly, and the 450° logic that the direct backends
// need is deliberately NOT applied here: two programs each deciding to go the
// long way round is how an antenna ends up unwinding in the middle of a pass.
type pstSatRotator struct {
c *pst.Client
maxAz int
mu sync.Mutex
// lastAz/lastEl are what was commanded, for the display when PstRotator
// does not answer a position query — which is the usual case for the many
// setups whose controller reports nothing back to it either.
lastAz, lastEl float64
commanded bool
azSilent bool // the azimuth query went unanswered; stop asking
elSilent bool // likewise for elevation, and far more common
}
func (p *pstSatRotator) Point(az, el float64) error {
a := math.Mod(az, 360)
if a < 0 {
a += 360
}
el = clampEl(el)
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
return err
}
p.mu.Lock()
p.lastAz, p.lastEl, p.commanded = a, el, true
p.mu.Unlock()
return nil
}
func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
p.mu.Lock()
azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded
p.mu.Unlock()
az, el, live := la, le, false
if !azSilent {
if v, _, err := p.c.Heading(); err == nil {
az, live = float64(v), true
} else {
// One silence is enough. Each query binds a socket and waits a second
// and a half; repeating that every few seconds for a setup that will
// never answer is a stall per poll for nothing.
p.mu.Lock()
p.azSilent = true
p.mu.Unlock()
}
}
if !elSilent {
if v, _, err := p.c.Elevation(); err == nil {
el = float64(v)
} else {
p.mu.Lock()
p.elSilent = true
p.mu.Unlock()
}
}
if !live && !commanded {
return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position")
}
return az, el, live, nil
}
// Close: nothing to release. Every PstRotator command is one datagram, and the
// socket lives for the length of a single write.
func (p *pstSatRotator) Close() {}
// azOnlySatRotator follows the satellite in azimuth and never touches the
// elevation axis, whatever the rotor happens to have.
//
// It works because of the geometry, not in spite of it: a pass at the far edge
// of the footprint stays between the horizon and about fifteen degrees for its
// whole length, and a yagi's beamwidth swallows that. What it costs is the high
// passes — a bird straight overhead is a moving azimuth and a useless bearing —
// and that is the operator's trade to make, which is why it is a switch and not
// a silent fallback.
//
// It drives whichever rotor was chosen through the same per-backend dispatch the
// compass uses, so a PstRotator, a Rotator Genius, an ARCO, a DCU-1, a SPID and
// the az/el ones all work here without a second implementation of each.
type azOnlySatRotator struct{ link rotorLink }
// Point sends the azimuth alone. The elevation is passed as -1, the callers'
// "no opinion", so a rotor that HAS an elevation axis is left where it is rather
// than being driven to the horizon.
func (r *azOnlySatRotator) Point(az, _ float64) error {
a := math.Mod(az, 360)
if a < 0 {
a += 360
}
return linkGoTo(r.link, int(math.Round(a)), -1)
}
// Heading reports the azimuth. The elevation comes back as whatever the
// controller said, which for an azimuth rotor is zero — the panel is told
// separately not to draw it (SatTrackStatus.RotAzOnly), because zero is a real
// bearing and not the absence of one.
//
// live stays true when the AZIMUTH was genuinely read: it means "this is a
// reading and not the last command", and that answer is honest whatever the
// other axis does or does not do.
func (r *azOnlySatRotator) Heading() (float64, float64, bool, error) {
az, el, _, _, err := linkHeading(r.link)
if err != nil {
return 0, 0, false, err
}
return az, el, true, nil
}
func (r *azOnlySatRotator) Close() {}
+636
View File
@@ -0,0 +1,636 @@
package main
// Doppler tracking — walking the radio through a pass.
//
// The hard part of satellite tuning is not the arithmetic, it is deciding who
// owns the dial. A tracker that simply forces both frequencies fights the
// operator every time they turn the knob to follow a station across a linear
// transponder, and one that never touches the receiver leaves them chasing a
// signal that slides 9 kHz across a 70 cm pass.
//
// So: the operator owns the receiver, and the tracker follows them. Every tick
// it asks the radio where the receiver actually is. If that is where the tracker
// put it, nothing has changed and it keeps correcting from the same NOMINAL
// frequency. If it has moved further than a dial-turn's tolerance, the operator
// has chosen a new station: the tracker converts what they landed on back into a
// nominal frequency and carries on from there. The transmitter is derived from
// the nominal and never argued with — which is exactly the division of labour on
// a linear bird, where the operator listens and the radio does the sums.
import (
"fmt"
"math"
"strings"
"sync"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/cat"
"hamlog/internal/qso"
"hamlog/internal/sat"
)
// satTickEvery is how often the radio is re-pointed. One second: at the middle
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
// SSB within two or three of them and inaudible within one.
const satTickEvery = time.Second
// satDialTolerance is how far the receiver may differ from where the tracker put
// it before that difference is read as the operator tuning.
//
// 200 Hz is comfortably more than the rounding and the round-trip lag between
// setting a frequency and reading it back, and comfortably less than the
// smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
// into a nominal one. The same constant internal/sat corrects with.
const satLightKmS = 299792.458
type satTracker struct {
name string
tp int
mu sync.Mutex
// nominalDown is where the operator is, expressed as if the satellite were
// standing still. Everything else is derived from it, and it is the only
// thing a dial movement changes.
nominalDown int64
lastDown int64 // what was last sent to the radio
lastUp int64
status SatTrackStatus
fails int
// The az/el rotator, built once at the start of the pass so a serial port is
// opened once rather than on every command. nil when none is configured.
rot satRotator
rotStep float64
rotMinE float64
rotPark bool
rotAzOnly bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
rotReadAt time.Time // when the controller was last asked where it is
stop chan struct{}
done chan struct{}
}
// SatTrackStatus is what the tracker is doing, for the panel.
type SatTrackStatus struct {
On bool `json:"on"`
Name string `json:"name"`
Transponder string `json:"transponder"`
Mode string `json:"mode"`
NominalDown int64 `json:"nominal_down"`
NominalUp int64 `json:"nominal_up"`
DownHz int64 `json:"down_hz"`
UpHz int64 `json:"up_hz"`
Az float64 `json:"az"`
El float64 `json:"el"`
Visible bool `json:"visible"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"`
// Where the antenna is. RotLive distinguishes a reading from the controller
// from the last position it was TOLD to go to — a stuck rotator must not be
// able to hide behind a command it never carried out.
RotOn bool `json:"rot_on"`
RotAz float64 `json:"rot_az"`
RotEl float64 `json:"rot_el"`
RotLive bool `json:"rot_live"`
// RotAzOnly: the elevation is not being driven and RotEl means nothing.
// Sent so the panel can leave it out rather than draw an antenna lying on
// the horizon, which is what an undriven zero looks like.
RotAzOnly bool `json:"rot_az_only"`
}
// StartSatelliteTracking arms the radio and starts following the satellite.
func (a *App) StartSatelliteTracking(name string, transponder int) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok || len(b.Transponders) == 0 {
return fmt.Errorf("%s has no frequency plan to tune to", name)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
a.StopSatelliteTracking()
t := &satTracker{
name: b.Name,
tp: transponder,
nominalDown: b.Transponders[transponder].Centre(),
stop: make(chan struct{}),
done: make(chan struct{}),
}
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The rotator, if there is one. A geostationary bird is pointed at once and
// left alone, so it gets one command rather than a loop.
set := a.satSettings()
if set.RotOn {
r, rerr := a.newSatRotator(set)
if rerr != nil {
applog.Printf("sat: no rotator: %v", rerr)
t.status.Error = rerr.Error()
} else {
t.rot = r
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
t.rotAzOnly = set.RotAzOnly
t.status.RotAzOnly = set.RotAzOnly
}
}
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
// it can still be tuned to the downlink, which is most of a receive-heavy
// pass, and saying so beats refusing to track at all.
radio := "downlink-only"
if a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
applog.Printf("sat: could not arm satellite mode: %v", err)
t.status.Error = err.Error()
} else {
radio = "sat"
}
}
t.status.Radio = radio
a.satTrackMu.Lock()
a.satTrack = t
a.satTrackMu.Unlock()
go a.satTrackLoop(t)
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
return nil
}
// StopSatelliteTracking hands the radio back.
func (a *App) StopSatelliteTracking() {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrack = nil
a.satTrackMu.Unlock()
if t == nil {
return
}
close(t.stop)
<-t.done
if a.cat != nil && a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
applog.Printf("sat: could not disarm satellite mode: %v", err)
}
}
applog.Printf("sat: tracking stopped (%s)", t.name)
a.emitSatTrack(SatTrackStatus{})
}
// TestSatelliteRotator opens the configured controller and asks it where it is.
//
// The one question worth asking before a pass: is this port the rotator, and
// does it talk back? A controller that accepts commands silently is a normal,
// working one — so that answer is a success with a caveat, not a failure.
func (a *App) TestSatelliteRotator() (string, error) {
set := a.satSettings()
if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off")
}
c, err := a.newSatRotator(set)
if err != nil {
return "", err
}
defer c.Close()
az, el, live, err := c.Heading()
if err != nil {
return "", err
}
if !live {
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
}
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
}
// GetSatelliteTracking reports what the tracker is doing.
func (a *App) GetSatelliteTracking() SatTrackStatus {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return SatTrackStatus{}
}
t.mu.Lock()
defer t.mu.Unlock()
return t.status
}
// satTrackedNominal is the nominal downlink the tracker is currently working
// from, or 0 when it is not tracking this satellite and transponder.
func (a *App) satTrackedNominal(name string, transponder int) int64 {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
return t.nominalDown
}
func (a *App) emitSatTrack(s SatTrackStatus) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:track", s)
}
}
func (a *App) satTrackLoop(t *satTracker) {
defer close(t.done)
defer t.releaseRotator()
tick := time.NewTicker(satTickEvery)
defer tick.Stop()
for {
a.satTrackStep(t)
select {
case <-t.stop:
return
case <-tick.C:
}
}
}
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
// what changed.
func (a *App) satTrackStep(t *satTracker) {
_, birds, _ := a.satParts()
b, ok := birds.Find(t.name)
if !ok || t.tp >= len(b.Transponders) {
return
}
tp := b.Transponders[t.tp]
t.mu.Lock()
nominal := t.nominalDown
lastDown, lastUp := t.lastDown, t.lastUp
t.mu.Unlock()
// Where the satellite is, and how fast it is running away. A geostationary
// bird is neither: its range rate is zero, so the zero position below gives
// a zero shift without a special case, and asking for a look angle we do not
// need would only fail on a station with no locator.
var pos sat.Position
visible := true
if !b.Geostationary {
obs, err := a.satObserver()
if err != nil {
t.setError(err.Error())
return
}
real, ok := a.satResolve(t.name)
if !ok {
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
return
}
store, _, _ := a.satParts()
p, err := store.Track(real, obs, time.Now().UTC())
if err != nil {
t.setError(err.Error())
return
}
pos = p
visible = p.Visible()
}
// The fractional shift, positive when the satellite is approaching. Only the
// dial arithmetic below needs it as a number; the pair itself comes from
// sat.Doppler, so there is exactly one place where the sign of a correction
// is decided.
factor := -pos.RangeRate / satLightKmS
// Where did the operator leave the receiver? If it is not where the tracker
// put it, they have moved to another station and that is the new nominal.
if lastDown > 0 && tp.Linear() {
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
if abs64i(actual-lastDown) > satDialTolerance {
moved := satNominalFromDial(actual, factor)
if moved >= tp.DownLo && moved <= tp.DownHi {
nominal = moved
t.mu.Lock()
t.nominalDown = moved
t.mu.Unlock()
}
}
}
}
nomUp := tp.UplinkFor(nominal)
sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz
t.mu.Lock()
t.status = SatTrackStatus{
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
NominalDown: nominal, NominalUp: nomUp,
DownHz: down, UpHz: up,
Az: pos.Az, El: pos.El, Visible: visible,
Radio: t.status.Radio, Error: t.status.Error,
}
t.mu.Unlock()
t.pointRotator(pos, b.Geostationary)
t.readRotator()
t.mu.Lock()
st := t.status
t.mu.Unlock()
a.emitSatTrack(st)
// Only send what has actually moved. The step is the smallest change worth a
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
// under a couple of hundred matters at all.
step := int64(20)
if strings.EqualFold(tp.Mode, "FM") {
step = 200
}
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
return
}
mode := tp.Mode
if lastDown != 0 {
mode = "" // set once, at the start of the pass — see satMode/satSetMode
}
err := a.satTune(down, up, mode, mode)
t.mu.Lock()
if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0
t.status.Error = ""
} else {
t.fails++
t.status.Error = err.Error()
}
fails := t.fails
t.mu.Unlock()
if err != nil && (fails == 1 || fails%30 == 0) {
// Once, then once every half minute: a radio that has gone away must be
// visible in the log without filling it.
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
}
}
// satNominalFromDial turns a frequency the operator tuned to into the nominal
// one it corresponds to.
//
// The inverse of the downlink correction: what comes out of the transponder at
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
// operator hunt across a linear passband without the tracker dragging them back
// — where they land becomes the new truth, and the uplink follows it.
func satNominalFromDial(heardHz int64, factor float64) int64 {
if heardHz <= 0 || factor <= -1 {
return heardHz
}
return int64(math.Round(float64(heardHz) / (1 + factor)))
}
// pointRotator keeps the antenna on the satellite.
//
// Below the configured elevation the rotator is left alone. Not because the
// numbers stop being right — they are right all the way round the orbit — but
// because a rotator that chases a satellite through the far side of the earth
// spends the whole night turning, and a mast is a mechanical thing with a
// finite number of turns in it.
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
if t.rot == nil {
return
}
if !geostationary && pos.El < t.rotMinE {
return
}
// A step below the beamwidth is a command for nothing. Compared against what
// was last COMMANDED rather than where the rotator says it is: a rotator in
// motion is always somewhere between the two, and comparing against that
// would order a fresh move on every tick of a slew.
az, el := pos.Az, pos.El
if geostationary {
// A satellite that does not move needs pointing once. Its own az/el were
// not computed (there is nothing to compute), so leave the rotator where
// the operator put it.
if t.rotSent {
return
}
}
// In azimuth-only mode the elevation is never commanded, so comparing it
// would find a difference on every tick and send a command for nothing —
// the antenna ordered to the same bearing once a second for the whole pass.
moved := math.Abs(az-t.rotAz) >= t.rotStep
if !t.rotAzOnly {
moved = moved || math.Abs(el-t.rotEl) >= t.rotStep
}
if t.rotSent && !moved {
return
}
if err := t.rot.Point(az, el); err != nil {
t.setError(err.Error())
return
}
t.rotAz, t.rotEl, t.rotSent = az, el, true
}
// readRotator asks the controller where it actually is, for the display.
//
// Separate from the pointing, and it runs on every tick rather than only when a
// command was sent: watching the antenna crawl towards the bearing is how an
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
// controller that does not answer says so once and is not asked again.
func (t *satTracker) readRotator() {
if t.rot == nil {
return
}
// Not on every tick. A PstRotator query binds a socket and waits up to a
// second and a half for an answer, and a held serial port still costs a
// round trip; three seconds is often enough to watch an antenna slew and
// rare enough not to sit in the way of the tuning.
if time.Since(t.rotReadAt) < 3*time.Second {
return
}
t.rotReadAt = time.Now()
az, el, live, err := t.rot.Heading()
t.mu.Lock()
defer t.mu.Unlock()
if err != nil {
t.status.RotOn = true
return
}
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
}
// releaseRotator hands the mast back at the end of a pass.
func (t *satTracker) releaseRotator() {
if t.rot == nil {
return
}
if t.rotPark && t.rotSent {
// Elevation down first and azimuth to north: a dish or a pair of yagis
// left pointing at the sky is what a gale takes away.
if err := t.rot.Point(0, 0); err != nil {
applog.Printf("sat: could not park the rotator: %v", err)
}
}
t.rot.Close()
t.rot = nil
}
func (t *satTracker) setError(msg string) {
t.mu.Lock()
t.status.Error = msg
t.mu.Unlock()
}
// satTune sends the pair to whichever radio is connected.
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
return a.cat.SatDo(func(st cat.SatTuner) error {
return st.TuneSatellite(downHz, upHz, downMode, upMode)
})
}
// No satellite pair on this backend: the downlink is what it can do, and the
// operator was told so when tracking started (Radio = "downlink-only").
if err := a.cat.SetFrequency(downHz); err != nil {
return err
}
if downMode != "" {
return a.cat.SetMode(downMode)
}
return nil
}
// satReceiveHz is where the receiver is, asked of the backend that knows.
func (a *App) satReceiveHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatReceiveHz()
hz = v
return e
})
return hz, err
}
st := a.cat.State()
if st.RxFreqHz > 0 {
return st.RxFreqHz, nil
}
return st.FreqHz, nil
}
func abs64i(v int64) int64 {
if v < 0 {
return -v
}
return v
}
// ── What goes in the log ────────────────────────────────────────────────────
// applySatellite stamps a QSO made through a satellite.
//
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
// stations working each other through a transponder read different numbers off
// their dials at the same instant — that is what Doppler means — and the only
// figure they can both agree on, and the only one that means anything to
// somebody reading the log later, is the transponder's own. LoTW matches on the
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
// of where one radio happened to be.
func (a *App) applySatellite(q *qso.QSO) {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return
}
t.mu.Lock()
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
az, el := t.status.Az, t.status.El
t.mu.Unlock()
if name == "" || down <= 0 {
return
}
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
// imported, or logged from a second radio while the tracker happened to be
// running, keeps what it was given.
if strings.TrimSpace(q.PropMode) == "" {
q.PropMode = "SAT"
}
if q.PropMode != "SAT" {
return // they said it was something else — meteor scatter, EME
}
if strings.TrimSpace(q.SatName) == "" {
q.SatName = name
}
if strings.TrimSpace(q.SatMode) == "" {
q.SatMode = satModeLetters(up, down)
}
// The transmit frequency is the uplink and the receive frequency the
// downlink — which is the one place a satellite QSO differs from every other
// kind, and the reason FREQ alone cannot describe one.
if up > 0 {
q.FreqHz = &up
if b := bandForHz(up); b != "" {
q.Band = b
}
}
d := down
q.FreqRXHz = &d
if b := bandForHz(down); b != "" {
q.BandRX = b
}
if q.AntAz == nil && (az != 0 || el != 0) {
v := az
q.AntAz = &v
}
if q.AntEl == nil && el != 0 {
v := el
q.AntEl = &v
}
}
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
// are what every satellite operator writes on a QSL card.
func satModeLetters(upHz, downHz int64) string {
u, d := satBandLetter(upHz), satBandLetter(downHz)
if u == "" || d == "" {
return ""
}
return u + "/" + d
}
func satBandLetter(hz int64) string {
switch {
case hz <= 0:
return ""
case hz < 30_000_000:
return "A" // 10 m — mode A's downlink
case hz < 148_000_000:
return "V" // 2 m
case hz < 450_000_000:
return "U" // 70 cm
case hz < 1_300_000_000:
return "L" // 23 cm
case hz < 2_500_000_000:
return "S" // 13 cm
case hz < 6_000_000_000:
return "C" // 6 cm
case hz < 11_000_000_000:
return "X" // 3 cm
}
return "K" // 24 GHz and above
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"testing"
"hamlog/internal/sat"
)
// The dial arithmetic has to be the exact inverse of the correction, or every
// touch of the knob would nudge the nominal frequency a little further off and
// the uplink would walk across the passband over a pass.
func TestSatNominalFromDialRoundTrip(t *testing.T) {
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
// covers a low orbit overhead.
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
p := sat.Position{RangeRate: rate}
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
sh := sat.Doppler(p, nominal, 0)
factor := -rate / satLightKmS
got := satNominalFromDial(sh.DownHz, factor)
if diff := got - nominal; diff > 1 || diff < -1 {
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
rate, nominal, sh.DownHz, got, diff)
}
}
}
}
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
// the downlink's — the order operators write and the order ADIF wants.
func TestSatModeLetters(t *testing.T) {
for _, tc := range []struct {
name string
up, down int64
want string
}{
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
{"receive only", 0, 145_800_000, ""},
} {
if got := satModeLetters(tc.up, tc.down); got != tc.want {
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
}
}
}
// Azimuth-only tracking must not command the rotor once a second.
//
// The step check used to compare BOTH axes, so with the elevation never
// commanded its difference stayed above the step for the whole pass and every
// tick sent the antenna to the bearing it was already on. A rotator is a
// mechanical thing with a finite number of turns in it.
func TestPointRotatorAzOnlyIgnoresElevation(t *testing.T) {
rec := &countingRotator{}
tr := &satTracker{rot: rec, rotStep: 5, rotAzOnly: true}
// The satellite climbs while the bearing barely moves — a pass going
// overhead from the side, which is the shape that provoked this.
for _, p := range []sat.Position{
{Az: 100, El: 5},
{Az: 101, El: 20},
{Az: 102, El: 45},
{Az: 103, El: 70},
} {
tr.pointRotator(p, false)
}
if rec.n != 1 {
t.Errorf("azimuth-only sent %d commands for 3° of bearing, want 1", rec.n)
}
if rec.lastAz != 100 {
t.Errorf("commanded azimuth %v, want the first one", rec.lastAz)
}
// And it still follows the azimuth when the azimuth actually moves.
tr.pointRotator(sat.Position{Az: 130, El: 70}, false)
if rec.n != 2 {
t.Errorf("a 30° swing was not followed: %d commands", rec.n)
}
}
// With an elevation axis, a climb is still followed.
func TestPointRotatorFollowsElevationWhenItCan(t *testing.T) {
rec := &countingRotator{}
tr := &satTracker{rot: rec, rotStep: 5}
tr.pointRotator(sat.Position{Az: 100, El: 5}, false)
tr.pointRotator(sat.Position{Az: 101, El: 40}, false)
if rec.n != 2 {
t.Errorf("a 35° climb was not followed: %d commands", rec.n)
}
if rec.lastEl != 40 {
t.Errorf("commanded elevation %v, want 40", rec.lastEl)
}
}
type countingRotator struct {
n int
lastAz, lastEl float64
}
func (c *countingRotator) Point(az, el float64) error {
c.n++
c.lastAz, c.lastEl = az, el
return nil
}
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
func (c *countingRotator) Close() {}
+108
View File
@@ -0,0 +1,108 @@
package main
// Which decoder the entry field belongs to, when several are running.
//
// A station running WSJT-X, JTDX and MSHV at once has three programs sending
// Status once a second each. Click a call in one of them and only that one has
// a DX Call; the other two are idle and say so. Both statements are true, and
// both arrive — so the entry field is filled by the program the operator is
// working and emptied by the two that are not, once a second, and the map
// zooms in and out with it.
//
// So the first program to announce a station is FOCUSED, and until it lets go
// the others cannot touch the entry field. That is the operator's own answer:
// "if I call on one program, keep that one's UDP for the duration of the QSO".
//
// Focus is released when the focused program clears its own DX Call, when it
// stops sending altogether (it was closed), or when a QSO is logged — never on
// a timer that could hand the field to another program mid-over.
import (
"strings"
"sync"
"time"
"hamlog/internal/applog"
)
// udpFocusIdle is how long a focused program may go silent before the focus is
// given up.
//
// Generous on purpose: a decoder sends Status every second, so anything above a
// few seconds means it has been closed or has lost its network. Thirty is long
// enough to survive a machine that stutters and short enough that a program
// closed mid-QSO does not lock the entry field for the rest of the evening.
const udpFocusIdle = 30 * time.Second
type udpFocus struct {
mu sync.Mutex
inst string
at time.Time
// told marks that the log already carries the line explaining why another
// program's callsign is being ignored. Once per focus, not once a second.
told map[string]bool
}
// claim records that inst is announcing a station, and reports whether inst is
// the program the entry field currently belongs to.
func (f *udpFocus) claim(inst string) bool {
if inst == "" {
return true // a sender with no id: nothing to arbitrate between
}
f.mu.Lock()
defer f.mu.Unlock()
if f.inst == "" || f.inst == inst || time.Since(f.at) > udpFocusIdle {
if f.inst != inst {
applog.Printf("udp: the entry field follows %s while it is calling", inst)
f.told = nil
}
f.inst, f.at = inst, time.Now()
return true
}
return false
}
// holds reports whether inst may act on the entry field, without claiming it.
// Used for the clear: a program that is not focused clearing its own DX Call
// says nothing about the QSO in progress somewhere else.
func (f *udpFocus) holds(inst string) bool {
if inst == "" {
return true
}
f.mu.Lock()
defer f.mu.Unlock()
if f.inst == "" || time.Since(f.at) > udpFocusIdle {
return true
}
return f.inst == inst
}
// release gives the field up — the focused program cleared its call, or a QSO
// was logged and the next station may come from anywhere.
func (f *udpFocus) release(why string) {
f.mu.Lock()
had := f.inst
f.inst, f.at, f.told = "", time.Time{}, nil
f.mu.Unlock()
if had != "" {
applog.Printf("udp: the entry field is free again (%s let go: %s)", had, why)
}
}
// noteIgnored logs, once per focused program, that another one's callsign was
// not applied. Without it the behaviour is invisible: an operator whose second
// decoder "stopped filling the call" has nothing to read.
func (f *udpFocus) noteIgnored(inst, call string) {
f.mu.Lock()
if f.told == nil {
f.told = map[string]bool{}
}
first := !f.told[inst]
f.told[inst] = true
holder := f.inst
f.mu.Unlock()
if first {
applog.Printf("udp: [%s] %q not applied — %s has the entry field while it is calling",
inst, strings.ToUpper(call), holder)
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"testing"
"time"
)
// The reported failure, in order: MSHV is called on, WSJT-X and JTDX sit idle
// beside it, and every one of their Status packets used to empty the entry
// field that MSHV had just filled — once a second, with the map zooming in and
// out to match.
func TestUdpFocusKeepsTheFieldWithTheCallingProgram(t *testing.T) {
var f udpFocus
if !f.claim("MSHV") {
t.Fatal("the first program to announce a station must take the field")
}
// The other two, announcing stations of their own, are refused.
if f.claim("WSJT-X") {
t.Error("WSJT-X took the field while MSHV was calling")
}
if f.claim("JTDX") {
t.Error("JTDX took the field while MSHV was calling")
}
// And their clears do not empty it — this is the half that caused the flicker.
if f.holds("WSJT-X") {
t.Error("an idle WSJT-X was allowed to clear MSHV's callsign")
}
if !f.holds("MSHV") {
t.Error("MSHV lost the right to clear its own callsign")
}
// MSHV moving to the next station keeps the field.
if !f.claim("MSHV") {
t.Error("the focused program must keep the field across stations")
}
}
// Letting go, three ways.
func TestUdpFocusRelease(t *testing.T) {
var f udpFocus
// The focused program clears its own call.
f.claim("MSHV")
f.release("DX Call cleared")
if !f.claim("WSJT-X") {
t.Error("after a release the next program should be able to take the field")
}
// A QSO is logged.
f.release("QSO logged")
if !f.claim("JTDX") {
t.Error("logging a QSO must free the field for whichever program hears the next station")
}
// The focused program is closed and stops sending. Its hold lapses rather
// than locking the entry field for the rest of the evening.
f.mu.Lock()
f.at = time.Now().Add(-udpFocusIdle - time.Second)
f.mu.Unlock()
if !f.claim("MSHV") {
t.Error("a silent program must not hold the field for ever")
}
}
// A sender with no program id — an ADIF relay, a remote "set call" — is not
// something to arbitrate between, and must never be locked out.
func TestUdpFocusIgnoresUnnamedSenders(t *testing.T) {
var f udpFocus
f.claim("MSHV")
if !f.claim("") {
t.Error("an unnamed sender was refused the entry field")
}
if !f.holds("") {
t.Error("an unnamed sender was refused a clear")
}
}
+3 -40
View File
@@ -6,10 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"hamlog/internal/applog"
@@ -75,11 +73,8 @@ func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
// BrowseExecutable opens a native file picker for choosing a program to launch.
func (a *App) BrowseExecutable() (string, error) {
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose a program to launch on startup",
Filters: []wruntime.FileFilter{
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
Title: "Choose a program to launch on startup",
Filters: executableFilters(),
})
}
@@ -150,9 +145,7 @@ func (a *App) CloseAutostartPrograms() {
if name == "" {
name = filepath.Base(p.Path)
}
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
if out, err := cmd.CombinedOutput(); err != nil {
if out, err := closeProcess(pid); err != nil {
applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
continue
}
@@ -227,33 +220,3 @@ func splitArgs(s string) []string {
}
return args
}
// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
out := map[string]bool{}
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
data, err := cmd.Output()
if err != nil {
applog.Printf("autostart: tasklist failed: %v", err)
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// CSV row: "image.exe","PID",... — take the first quoted field.
field := line
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
field = line[1 : i+1]
}
field = strings.Trim(field, "\"")
if field != "" {
out[strings.ToLower(field)] = true
}
}
return out
}
+18 -9
View File
@@ -23,12 +23,9 @@ import (
"time"
)
// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable.
// bootLogPath is the file, or "" when no writable folder can be found at all.
func bootLogPath() string {
dir := os.Getenv("LOCALAPPDATA")
if strings.TrimSpace(dir) == "" {
dir = os.TempDir()
}
dir := bootLogDir()
if dir == "" {
return ""
}
@@ -136,11 +133,23 @@ func webviewDataPath() string {
// stuckMarkerPath is written before the window is attempted and removed once it
// opens, so the NEXT launch can tell that the last one never got there.
func stuckMarkerPath() string {
dir := os.Getenv("LOCALAPPDATA")
if strings.TrimSpace(dir) == "" {
dir = os.TempDir()
return filepath.Join(bootLogDir(), "OpsLog", ".launching")
}
// bootLogDir is where the breadcrumbs live: %LOCALAPPDATA% on Windows, and on
// Linux the XDG cache directory (~/.cache) that os.UserCacheDir resolves to.
//
// The temp directory is the last resort and not the first, because it is the
// one place the evidence does not survive: a station that reboots after a
// failed launch loses exactly the log that would have explained it.
func bootLogDir() string {
if dir := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); dir != "" {
return dir
}
return filepath.Join(dir, "OpsLog", ".launching")
if dir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(dir) != "" {
return dir
}
return os.TempDir()
}
// lastLaunchHung is set at startup from the marker left by the previous run.
+104 -2
View File
@@ -1,16 +1,118 @@
[
{
"version": "0.27.20",
"date": "",
"en": [
"Every rotator interface now lives in Settings ▸ Rotator, and the satellite page only picks one of them. EasyComm and PstRotator used to be described inside the satellite settings while the other backends were described in the rotator list, so one mast was configured twice. What you already set up is moved into the list for you and selected.",
"Each rotator interface says whether it drives azimuth alone or azimuth and elevation, beside the interface itself. The satellite rotator list shows the azimuth-only ones greyed out rather than hiding them, so a rotor that cannot follow a pass says why.",
"New rotator interface: ERC-M by DF9GR, the azimuth/elevation controller for a Yaesu G-5500. Over its USB COM port or the network, with its emulation set to GS-232. Untested on hardware — reports welcome.",
"EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass.",
"On the satellite map, an unselected satellite is readable: a bigger dot with a dark halo under a white ring, which shows up on a street map and on a dark ocean alike, and the ones above the horizon carry their name.",
"Hovering a satellite on the map now says what a pass is worth — elevation and azimuth, distance and whether it is closing or going away, rise and set with the countdown, and how high it will get. It no longer closes itself every five seconds while you read it.",
"The satellite footprint is drawn for the selected bird only. A footprint is thousands of kilometres across, and a dozen of them overlapped into a wash of circles that hid the coastline, the ground track and the satellites themselves.",
"The frequency plan goes from 25 satellites to 44, cut from Celestrak, PE0SAT and the SatNOGS transponder database instead of typed by hand — the nine Tevel-2 satellites, the Chinese space station, AO-27, AO-123, RS-44 and twenty more. Twelve that had re-entered are gone, first-generation Tevel among them. Your own file is merged rather than replaced: satellites you have never seen are added, and any frequency you corrected stands.",
"A satellite is now found by its catalog number rather than by its name. \"RADFXSAT (FOX-1B)\" and \"AO-91\" are the same bird, and so are \"TIANYAN 01\" and \"TO-108\" — the second pair never met before, so TO-108 tracked nothing.",
"On the satellite tab, the mode is a coloured badge instead of a grey footnote, and an FM bird shows its CTCSS tone with the same weight as a frequency — a repeater called without its tone does not answer, and the operator hears an empty channel and concludes the satellite is not up. When there is no tone it says so, rather than leaving a blank that could mean either. The mode also appears in the transponder list and in the header, so it survives hiding the readout column.",
"New option: follow the azimuth only. A station with an ordinary rotator and no elevation motor can now track a satellite — a pass at the edge of the footprint stays between the horizon and about 15° for its whole length, and a beam covers that with its beamwidth. With it on, any rotator in the list can be chosen. What you give up is the high passes, where a satellite overhead has a bearing that means nothing, which is why it is a switch and not something OpsLog decides for you.",
"The pass table now lists every satellite you follow, not only the ones with a pass coming. QO-100 never has one because it never sets, a bird whose elements have not arrived cannot be predicted, and one whose next pass falls beyond the window is simply past the horizon of the table — all three used to look like satellites OpsLog had lost. They sit at the end, each saying which of the three it is, and clicking one selects it like any other row."
],
"fr": [
"Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait quen choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.",
"Chaque interface de rotor indique si elle pilote lazimut seul ou lazimut et l’élévation, juste à côté de linterface. La liste des rotors de la page satellite affiche les azimut-seul en grisé plutôt que de les cacher : un rotor qui ne peut pas suivre un passage dit pourquoi.",
"Nouvelle interface de rotor : ERC-M de DF9GR, le contrôleur azimut/élévation pour un Yaesu G-5500. Via son port COM USB ou le réseau, avec son émulation réglée sur GS-232. Non testé sur matériel — vos retours sont les bienvenus.",
"EasyComm II devient une interface de rotor comme les autres : elle peut tourner lantenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite.",
"Sur la carte satellite, un satellite non sélectionné est lisible : un point plus gros avec un halo sombre sous un anneau blanc, visible aussi bien sur une carte routière que sur un océan noir, et ceux au-dessus de lhorizon portent leur nom.",
"Le survol dun satellite sur la carte indique désormais ce que vaut le passage — élévation et azimut, distance et si elle diminue ou augmente, lever et coucher avec le décompte, et la hauteur quil atteindra. Linfobulle ne se referme plus toutes les cinq secondes pendant quon la lit.",
"Lempreinte au sol nest tracée que pour le satellite sélectionné. Une empreinte fait des milliers de kilomètres, et une douzaine se superposaient en un lavis de cercles qui masquait le trait de côte, la trace au sol et les satellites eux-mêmes.",
"Le plan de fréquences passe de 25 à 44 satellites, généré depuis Celestrak, PE0SAT et la base de transpondeurs SatNOGS au lieu d’être saisi à la main — les neuf Tevel-2, la station spatiale chinoise, AO-27, AO-123, RS-44 et vingt autres. Douze rentrés dans latmosphère ont été retirés, dont les Tevel de première génération. Votre fichier est fusionné et non remplacé : les satellites inconnus sont ajoutés, et vos corrections de fréquence restent.",
"Un satellite est désormais trouvé par son numéro de catalogue plutôt que par son nom. « RADFXSAT (FOX-1B) » et « AO-91 » sont le même oiseau, tout comme « TIANYAN 01 » et « TO-108 » — ces deux-là ne se rencontraient jamais, donc TO-108 ne suivait rien.",
"Sur longlet satellite, le mode est une pastille colorée au lieu dune note grise, et un satellite FM affiche sa tonalité CTCSS avec le même poids quune fréquence — un relais appelé sans sa tonalité ne répond pas, et lOM entend un canal vide et en conclut que le satellite nest pas passé. Quand il ny a pas de tonalité, cest écrit, plutôt quun blanc qui pourrait vouloir dire lun ou lautre. Le mode apparaît aussi dans la liste des transpondeurs et dans len-tête, donc il survit au masquage de la colonne de droite.",
"Nouvelle option : suivre lazimut seulement. Une station avec un rotor ordinaire et sans moteur d’élévation peut désormais suivre un satellite — un passage en bord dempreinte reste entre lhorizon et 15° environ sur toute sa durée, et une beam couvre ça avec son ouverture. Avec loption activée, nimporte quel rotor de la liste peut être choisi. Ce quon perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire — doù un réglage plutôt quun choix fait à votre place.",
"Le tableau des passages liste désormais tous les satellites suivis, et plus seulement ceux qui ont un passage à venir. QO-100 nen a jamais puisquil ne se couche pas, un satellite dont les éléments ne sont pas arrivés ne peut pas être prédit, et celui dont le prochain passage tombe au-delà de la fenêtre est simplement hors de portée du tableau — les trois avaient lair de satellites quOpsLog avait perdus. Ils sont en fin de liste, chacun disant lequel des trois cas il est, et un clic les sélectionne comme nimporte quelle ligne."
]
},
{
"version": "0.27.19",
"date": "",
"en": [
"After an update, OpsLog starts again. The fix that stopped Defender calling the updater a trojan removed the helper that waited for the old process to die, and nothing took over the job: the new instance was patient with the single-instance lock for twenty seconds while the old one is allowed thirty to shut down — closing a remote logbook, a CAT session, sometimes a backup. Where that ran long the new process gave up in silence, leaving no window and a leftover OpsLog in the task manager. It now waits for the previous process itself, ending the instant it does; and if it really has not gone, it says so instead of claiming OpsLog is already running.",
"A UDP row set to multicast on an address that is not one now listens anyway. 127.0.0.1 in the group box is the common mistake — it is the address every other field in every other program wants — but a multicast group runs 224.0.0.0 to 239.255.255.255, and joining anything else failed on every interface with a Windows error naming nothing the operator had typed. The row simply did not run. It now listens on unicast, which is what such an address means, and says so in the log.",
"The confirmation defaults added for the newest services were blank. HAMLOG.online arrived after most profiles were set up, so it had no default at all — and blank is not a status anybody chose. Every service now starts the same way: the sent side at R (waiting to go out), the received side at N. A blank left by a service that did not exist when you last saved is filled in; a status you chose yourself is untouched.",
"OmniRig: a setting for rig files whose CW is the reverse one. OmniRig has two CW modes and nothing says which one a rig file calls plain CW — some Icom files map PM_CW_U to CW, others to CW-R — so clicking a CW spot on an IC-7610 landed the radio in CW-R, and the only way out was to edit the rig file. Settings → CAT → OmniRig now has a tick box for it, applied at once without dropping the link. (If your VFOs read the wrong way round on the same rig, the VFO override beside it is the answer: rig files disagree there too.)",
"Choosing a radio now switches CAT on. The master switch sits above the radio dropdown, and leaving it off while you pick your brand, type the address and run the detector — which finds your radio and prints its name — is a trap: a Flex 6700 owner did exactly that, saved six times, and got no link and no error. Picking a radio, or clicking one the detector found, ticks it. The panel also says so plainly while it is off, and the log line that used to announce \"link unchanged, staying connected\" when nothing was connected now says CAT is switched off.",
"My rig and my antenna are dropdowns now, in the entry form and in the QSO editor, offering what you declared in Settings → Operating conditions — and the antennas of the rig you picked, since that is what they hang off. Typing them again on every contact was both work and a source of spellings that do not match: \"IC-7610\", \"IC 7610\" and \"ic7610\" are three different rigs to an award and to a filter. Free text still works, for a QSO made from somebody else's station.",
"The FT decodes list is capped at 2000 rows. The rolling half hour is not a limit on a crowded evening — three decoders fill it with several thousand — and the panel slowed down long before anything aged out, since every row is a layout, a status and a distance. Past two thousand the oldest go, which is what has already been scrolled past.",
"The rotor dial sits on the panel instead of punching a hole in it. It was drawn on a full black square, which read as a tile dropped into the widget rather than an instrument on it; it is a disc now, and the corners are whatever it is sitting on. The continents are brighter too — at the old shade the land was about eight per cent lighter than the sea, technically a map and practically a dark square with a suggestion in it."
],
"fr": [
"Après une mise à jour, OpsLog redémarre. Le correctif qui a fait cesser la détection en cheval de Troie a supprimé l'assistant qui attendait la mort de l'ancien processus, et rien n'a repris ce travail : la nouvelle instance patientait vingt secondes sur le verrou d'instance unique alors que l'ancienne dispose de trente pour se fermer — elle referme un journal distant, une session CAT, parfois une sauvegarde. Quand cela durait, le nouveau processus abandonnait en silence : pas de fenêtre, et un OpsLog restant dans le gestionnaire des tâches. Il attend désormais l'ancien processus lui-même, et repart à l'instant où celui-ci s'arrête ; et s'il n'est vraiment pas parti, il le dit au lieu d'annoncer qu'OpsLog tourne déjà.",
"Une ligne UDP réglée en multicast sur une adresse qui n'en est pas une écoute désormais quand même. 127.0.0.1 dans le champ groupe est l'erreur classique — c'est l'adresse que réclame tout autre champ de tout autre programme — mais un groupe multicast va de 224.0.0.0 à 239.255.255.255, et rejoindre autre chose échouait sur toutes les interfaces avec une erreur Windows ne nommant rien de ce que l'opérateur avait saisi. La ligne ne tournait tout simplement pas. Elle écoute maintenant en unicast, ce que veut dire une telle adresse, et le dit dans le journal.",
"Les statuts par défaut des services les plus récents étaient vides. HAMLOG.online est arrivé après la configuration de la plupart des profils : il n'avait donc aucun défaut — et vide n'est pas un statut que quelqu'un a choisi. Chaque service démarre désormais pareil : côté envoi R (en attente de départ), côté réception N. Un vide laissé par un service qui n'existait pas lors de votre dernier enregistrement est comblé ; un statut que vous avez choisi n'est pas touché.",
"OmniRig : un réglage pour les fichiers de rig dont la CW est l'inverse. OmniRig a deux modes CW et rien ne dit lequel un fichier appelle CW tout court — certains fichiers Icom associent PM_CW_U à CW, d'autres à CW-R — si bien qu'un clic sur un spot CW mettait un IC-7610 en CW-R, sans autre issue que de modifier le fichier de rig. Réglages → CAT → OmniRig a désormais une case pour cela, appliquée aussitôt sans couper la liaison. (Si vos VFO sont inversés sur la même radio, le sélecteur de VFO juste à côté est la réponse : les fichiers de rig divergent là aussi.)",
"Choisir une radio active désormais le CAT. L'interrupteur principal est au-dessus de la liste des radios, et le laisser éteint pendant qu'on choisit sa marque, saisit l'adresse et lance la détection — qui trouve la radio et affiche son nom — est un piège : un possesseur de Flex 6700 a fait exactement cela, enregistré six fois, sans liaison ni erreur. Choisir une radio, ou cliquer sur celle que la détection a trouvée, coche la case. Le panneau le dit aussi clairement tant qu'elle est décochée, et la ligne de journal qui annonçait « liaison inchangée, toujours connecté » alors que rien n'était connecté dit maintenant que le CAT est désactivé.",
"Mon équipement et mon antenne sont désormais des listes déroulantes, dans la saisie comme dans l'éditeur de QSO, proposant ce que vous avez déclaré dans Réglages → Conditions de trafic — et les antennes du poste choisi, puisque c'est à lui qu'elles sont rattachées. Les retaper à chaque contact était à la fois du travail et une source d'orthographes divergentes : « IC-7610 », « IC 7610 » et « ic7610 » sont trois équipements différents pour un diplôme et pour un filtre. La saisie libre reste possible, pour un QSO fait depuis la station de quelqu'un d'autre.",
"La liste des décodages FT est plafonnée à 2000 lignes. La demi-heure glissante n'est pas une limite un soir chargé — trois décodeurs la remplissent de plusieurs milliers — et le panneau ralentissait bien avant que quoi que ce soit n'expire, chaque ligne étant une mise en page, un statut et une distance. Au-delà de deux mille, les plus anciennes partent : celles qu'on a déjà dépassées en défilant.",
"Le cadran du rotor se pose sur le panneau au lieu d'y percer un trou. Il était dessiné sur un carré noir plein, qui se lisait comme une tuile posée dans le widget plutôt que comme un instrument dessus ; c'est un disque désormais, et les coins sont ce sur quoi il repose. Les continents sont aussi plus clairs — à l'ancienne teinte, la terre était environ huit pour cent plus claire que la mer : techniquement une carte, en pratique un carré sombre avec une suggestion dedans."
]
},
{
"version": "0.27.18",
"date": "",
"en": [
"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 — and behind these dialogs is an application that never stops moving: CAT polling four times a second, spots arriving, meters sweeping, maps redrawing. Worse in one place: the cluster editor opens from Preferences, so its overlay was a second full-window filter stacked over the first. Preferences, the cluster editor, the QSO editor, bulk edit, alert rules and award definitions now dim the background instead of blurring it; everything else keeps the blur.",
"One padlock on the entry form instead of five. Logging a contact from 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 was five clicks in five different places, each of which had to be found first. The padlock beside Start UTC now holds all of them, and releases all of them.",
"Preferences no longer says the section name twice — the small line above each panel repeated the heading right under it, and the sidebar beside it already shows which section is open.",
"The band matrix can open on the digital mode you actually work. An operator who only ever does FT8 was shown DIGI every time and had to click through to their own mode on every callsign; Settings → General now chooses which digital row the matrix starts on. The row still rotates when you click it, and DIGI — all of them together — stays the default.",
"The MQTT chip is gone from the status bar. That is the name of a message protocol, not of anything an operator has. The state it carried — the openings feed up or down, and how many reports have arrived — is in the Chase New panel, which is the place that uses it.",
"The callsign box no longer narrows when you close the padlock. Its row gains a date field for a manual entry, and a flex row makes room by shrinking its children — so the widest box, the one the eye is on while typing, was the one that visibly moved. The callsign and both report boxes are now a notch narrower and fixed there, whether the date is showing or not."
],
"fr": [
"Les dialogues dans lesquels on tape ne reposent plus sur un fond flouté. Un filtre de fond couvre toute la fenêtre et est recalculé chaque fois que quoi que ce soit au-dessus se repeint — et derrière ces dialogues il y a une application qui ne s'arrête jamais de bouger : le CAT qui interroge quatre fois par seconde, les spots qui arrivent, les vumètres qui balaient, les cartes qui se redessinent. Pire à un endroit : l'éditeur de cluster s'ouvre depuis les Préférences, donc son fond était un deuxième filtre plein écran empilé sur le premier. Les Préférences, l'éditeur de cluster, l'éditeur de QSO, l'édition groupée, les règles d'alerte et les définitions de diplômes assombrissent désormais le fond au lieu de le flouter ; tout le reste garde le flou.",
"Un seul cadenas dans la saisie au lieu de cinq. Enregistrer un contact depuis une feuille — un carnet de concours, le report d'un ami, un QSO fait sur une autre radio — suppose que la fréquence, la bande, le mode, la date et les deux heures cessent tous en même temps de suivre le poste et l'horloge. C'étaient cinq clics à cinq endroits différents, qu'il fallait d'abord trouver. Le cadenas à côté de Début UTC les fige maintenant tous, et les libère tous.",
"Les Préférences ne disent plus deux fois le nom de la section — la petite ligne au-dessus de chaque panneau répétait le titre juste en dessous, et la barre latérale montre déjà laquelle est ouverte.",
"La matrice peut s'ouvrir sur le mode numérique que vous travaillez vraiment. Celui qui ne fait que du FT8 voyait DIGI à chaque fois et devait cliquer jusqu'à son mode pour chaque indicatif ; Réglages → Général choisit désormais la ligne numérique sur laquelle la matrice démarre. La ligne continue de tourner au clic, et DIGI — tous ensemble — reste le défaut.",
"La pastille MQTT disparaît de la barre d'état. C'est le nom d'un protocole de messages, pas de quelque chose que possède un opérateur. Ce qu'elle indiquait — le flux d'ouvertures actif ou non, et le nombre de reports arrivés — est dans le panneau Chasse au nouveau, à l'endroit qui s'en sert.",
"Le champ indicatif ne rétrécit plus quand on ferme le cadenas. Sa ligne gagne un champ date pour une saisie manuelle, et une ligne flex fait de la place en rétrécissant ses enfants — donc le plus large, celui que l'œil suit pendant la frappe, était celui qui bougeait visiblement. L'indicatif et les deux champs de report sont désormais un cran plus étroits et fixes, que la date soit affichée ou non."
]
},
{
"version": "0.27.17",
"date": "",
"en": [
"[NEW] Satellites. A new tab (Tools → Satellites) works the amateur birds from end to end. A map with each satellite's footprint and the selected one's path over the ground; a sky plot the way every tracker draws one, centre straight up and rim at the horizon, with the whole pass and where the bird is on it; a countdown to AOS — or to LOS once it is up — with rise, peak and set, their compass directions, distance, altitude and footprint; and a pass table for everything you follow.\n\nTrack puts the radio on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if missing, full duplex on) — and any other rig on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. An az/el rotator follows along, either driven directly over EasyComm II or handed to PstRotator if you already run it; a 450° rotator is used as one, so a pass crossing north continues instead of unwinding.\n\nQSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be.\n\nOrbital elements come from Celestrak with a mirror behind it and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. Twenty-five satellites ship with a frequency plan — the FM and linear birds, GreenCube, QO-100 narrow and wide — in a file you can correct yourself when a transponder is switched. Everything about setting it up lives in Settings → Satellites, including which satellites you follow, chosen the way you choose awards.",
"Each map keeps its own imagery. The world map and the grid-square map shared one setting, so choosing satellite imagery to look at grids repainted the main map as well, and there was no way to have terrain on one and plain streets on the other. All four — world, grid squares, FT map, satellites — now remember their own choice, and it travels with the data folder like the remembered views. A choice already made for the grid map is carried over, not reset.",
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.",
"The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.",
"The cluster editor offers a list of known nodes. 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. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.",
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop.",
"Station Control shows what commands the station, not only what it switches. The radio is there now — frequency, mode, band, and the split pair when there is one — with the CW keyer beside it (speed up and down, and Stop, because a message going to the wrong callsign has to end now) and the voice keyer with its recorded messages as buttons, so a CQ goes out without leaving the tab. The two keyers appear only when there is something behind them: a port configured, or a message actually recorded. All three move and reorder with the other cards."
],
"fr": [
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.",
"Chaque carte garde son propre fond. La carte principale et celle des carrés partageaient un seul réglage : choisir la vue satellite pour regarder les carrés repeignait aussi la carte principale, et il n'y avait aucun moyen d'avoir le relief sur l'une et les rues sur l'autre. Les quatre — principale, carrés, FT map, satellites — retiennent désormais leur propre choix, qui suit le dossier de données comme les positions mémorisées. Un choix déjà fait pour la carte des carrés est repris, pas réinitialisé.",
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.",
"Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.",
"L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.",
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie.",
"Contrôle station montre ce qui commande la station, et plus seulement ce qui la commute. La radio y figure désormais — fréquence, mode, bande, et le couple split quand il y en a un — avec à côté le manipulateur CW (vitesse en plus ou en moins, et Stop, parce qu'un message parti vers le mauvais indicatif doit s'arrêter tout de suite) et le manipulateur vocal avec ses messages enregistrés en boutons, pour lancer un CQ sans quitter l'onglet. Les deux manipulateurs n'apparaissent que s'il y a quelque chose derrière : un port configuré, ou un message réellement enregistré. Les trois se déplacent et se réordonnent avec les autres cartes."
]
},
{
"version": "0.27.16",
"date": "",
"en": [
"[NEW] Typing a digital watering hole sets the mode with it. A spot click has always carried one; a frequency typed by hand carried none, so the rig stayed in SSB on 28.074 while the operator waited for decodes. Same table and same tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), only towards the digital modes: tuning away from one leaves the mode alone, because there the frequency says nothing about what you mean to do.",
"Yaesu CAT now drives the older radios. The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits; everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and answers a nine-digit command with a rejection, which is what an FTDX3000 owner saw: every FA refused and a radio that would not follow. The width is taken from the rigs own reply rather than from a table of models, so a set is in the format that radio speaks — including models this backend has never heard of.",
"Yaesu: RTTY can be set on USB (Settings → CAT). ADIF records only “RTTY” and the rig has both sidebands, so the log cannot answer for it — the older RTTY-L stays the default, and a station whose FSK controller wants the upper one says so once."
"Yaesu: RTTY can be set on USB (Settings → CAT). ADIF records only “RTTY” and the rig has both sidebands, so the log cannot answer for it — the older RTTY-L stays the default, and a station whose FSK controller wants the upper one says so once. The choice reaches the radio already connected: it is not part of what defines the link, so the link is not rebuilt for it — and until now that meant it waited for the next launch while the rig went on choosing LSB.",
"The update no longer relaunches OpsLog through a hidden PowerShell. An unsigned program that replaces itself on disk, clears the mark-of-the-web and then spawns a windowless PowerShell to start another executable is — byte for byte — the shape of a dropper, and Windows Defenders machine-learning model reads the shape, not the intention: 0.27.14 was removed from a station under Trojan:Script/Wacatac.H!ml. The new version simply starts itself and waits its turn on the single-instance lock, which it already knew how to do. Only the rare fallback path, when the running file cannot even be renamed, still needs a helper that outlives the process.",
"Rotor widget: with more than one rotor the panel no longer runs off the bottom. The selector row appears above the dial, and the widgets height is not its own to take — it sits in a strip sized by the entry form beside it — so the SP/LP pair and half the Stop button were cut off. The dial, the button rows and the padding now give that row back between them, in proportion, and nothing is dropped."
],
"fr": [
"[NEW] Taper une fréquence dappel numérique règle le mode avec elle. Un clic sur un spot en portait un depuis toujours ; une fréquence tapée à la main nen portait aucun, si bien que le poste restait en SSB sur 28.074 pendant quon attendait les décodages. Même table et même tolérance quun spot (±3 kHz dune fréquence FT8/FT4/JS8 connue), et seulement vers les modes numériques : en sen éloignant le mode nest pas touché, car là la fréquence ne dit rien de ce quon veut faire.",
"Le CAT Yaesu pilote désormais les postes plus anciens. FTDX10, FT-991A, FT-891 et FT-710 écrivent une fréquence sur neuf chiffres ; tout ce qui précède — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — l’écrit sur huit et rejette une commande à neuf chiffres. Cest ce que voyait un possesseur de FTDX3000 : chaque FA refusée et une radio qui ne suivait pas. Le format est pris dans la réponse du poste plutôt que dans une table de modèles : lenvoi part donc dans la langue de cette radio-là, y compris pour des modèles que ce backend ne connaît pas.",
"Yaesu : le RTTY peut être placé en USB (Réglages → CAT). LADIF nenregistre que « RTTY » et le poste a les deux bandes latérales : le log ne peut pas répondre à sa place. Le RTTY-L ancien reste par défaut, et une station dont linterface FSK veut la supérieure le dit une fois."
"Yaesu : le RTTY peut être placé en USB (Réglages → CAT). LADIF nenregistre que « RTTY » et le poste a les deux bandes latérales : le log ne peut pas répondre à sa place. Le RTTY-L ancien reste par défaut, et une station dont linterface FSK veut la supérieure le dit une fois. Le choix atteint le poste déjà connecté : il ne fait pas partie de ce qui définit la liaison, donc celle-ci nest pas reconstruite pour lui — et jusquici cela voulait dire quil attendait le prochain lancement pendant que le poste continuait de choisir la LSB.",
"La mise à jour ne relance plus OpsLog par un PowerShell caché. Un programme non signé qui se remplace sur le disque, efface la marque « téléchargé depuis Internet » puis lance un PowerShell sans fenêtre pour démarrer un autre exécutable a — à loctet près — la forme dun dropper, et le modèle dapprentissage de Windows Defender lit la forme, pas lintention : la 0.27.14 a été supprimée chez un OM sous Trojan:Script/Wacatac.H!ml. La nouvelle version se lance elle-même et attend son tour sur le verrou dinstance unique, ce quelle savait déjà faire. Seul le repli rare, quand le fichier en cours dexécution ne peut même pas être renommé, garde un assistant qui survit au processus.",
"Widget rotor : avec plusieurs rotors, le panneau ne déborde plus par le bas. La rangée de sélection apparaît au-dessus du cadran, et la hauteur du widget ne lui appartient pas — il occupe une bande dont la hauteur est fixée par la saisie à côté — si bien que la paire SP/LP et la moitié du bouton Stop se retrouvaient coupées. Le cadran, les rangées de boutons et les marges rendent désormais cette hauteur entre eux, chacun pour sa part, sans rien supprimer."
]
},
{
+444
View File
@@ -0,0 +1,444 @@
// Command satgen refreshes internal/sat/birds.json from the public databases.
//
// A one-shot generator, run by hand, NOT part of the build — the same
// arrangement as cmd/cntygen. Satellites are switched between modes and new
// ones fly, and the shipped frequency plan should be re-cut every few releases
// rather than typed from memory.
//
// go run ./cmd/satgen
//
// It reads three sources and joins them on the NORAD catalog number:
//
// - Celestrak's amateur group and PE0SAT's mirror, for WHICH satellites
// OpsLog can get elements for. There is no point shipping a frequency plan
// for a bird whose TLE never arrives.
// - SatNOGS DB, for the transmitters. It is the maintained, machine-readable
// transponder database; AMSAT's chart is authoritative but is a web page.
//
// It NEVER destroys a curated entry. The hand-written plans carry things
// SatNOGS does not reliably hold — a CTCSS tone, a readable label, the QO-100
// passband as operators actually describe it — so an existing bird is kept
// verbatim and only has its NORAD number filled in. New satellites are appended.
// Read the diff before committing it: this is a starting point for an operator,
// and a wrong uplink is worse than a missing one.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
"hamlog/internal/sat"
)
const (
birdsPath = "internal/sat/birds.json"
satnogsTX = "https://db.satnogs.org/api/transmitters/?format=json"
satnogsSats = "https://db.satnogs.org/api/satellites/?format=json"
)
// satellite is the subset of a SatNOGS satellite record we use. Its whole
// purpose is the decay date: a frequency plan for a spacecraft that burned up
// two years ago is a row in the operator's list that will never do anything.
type satellite struct {
NORAD int `json:"norad_cat_id"`
Name string `json:"name"`
Names string `json:"names"` // other designations, comma or newline separated
Status string `json:"status"`
Decayed string `json:"decayed"`
}
// tleFeeds are the element sources OpsLog itself reads (see internal/sat/tle.go).
var tleFeeds = []string{
"https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle",
"http://tle.pe0sat.nl/kepler/amateur.txt",
}
// transmitter is the subset of a SatNOGS DB record we use.
type transmitter struct {
Description string `json:"description"`
Alive bool `json:"alive"`
Type string `json:"type"` // Transmitter | Transponder | Transceiver
UplinkLow int64 `json:"uplink_low"`
UplinkHigh int64 `json:"uplink_high"`
DownlinkLow int64 `json:"downlink_low"`
DownlinkHigh int64 `json:"downlink_high"`
Mode string `json:"mode"`
Invert bool `json:"invert"`
NORAD int `json:"norad_cat_id"`
Status string `json:"status"`
}
func main() {
feed, err := loadFeeds()
if err != nil {
die(err)
}
fmt.Printf("elements: %d satellites across %d feeds\n", len(feed), len(tleFeeds))
txs, err := loadTransmitters()
if err != nil {
die(err)
}
fmt.Printf("satnogs: %d transmitters\n", len(txs))
cat, err := loadSatellites()
if err != nil {
die(err)
}
fmt.Printf("satnogs: %d catalogued satellites\n", len(cat))
birds, err := loadBirds()
if err != nil {
die(err)
}
fmt.Printf("existing plan: %d satellites\n", len(birds))
// 0. Drop what has come down. SatNOGS carries the re-entry date, so this is
// a documented fact rather than a judgement about which of the missing
// satellites are missing for good — the first-generation Tevel
// constellation alone had left eight rows that could never do anything.
kept := birds[:0]
for _, b := range birds {
if s, ok := decayed(b, cat); ok {
fmt.Printf(" - %s re-entered %s — removed\n", b.Name, strings.TrimSuffix(s.Decayed, "T00:00:00Z"))
continue
}
kept = append(kept, b)
}
birds = kept
// 1. Give every curated entry its catalog number, so the join stops
// depending on how three different parties spell the same satellite.
covered := map[int]bool{}
for i := range birds {
if birds[i].NORAD == 0 {
if n, ok := noradFor(birds[i], feed); ok {
birds[i].NORAD = n
fmt.Printf(" + NORAD %5d for %s\n", n, birds[i].Name)
} else {
fmt.Printf(" ! no elements found for %s — left without a catalog number\n", birds[i].Name)
}
}
if birds[i].NORAD != 0 {
covered[birds[i].NORAD] = true
}
}
// 2. Append the satellites we can track and have a usable uplink for.
byNORAD := map[int][]transmitter{}
for _, t := range txs {
if !usable(t) || feed[t.NORAD] == "" || covered[t.NORAD] {
continue
}
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
}
added := 0
for n, list := range byNORAD {
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
if alias := strings.TrimSpace(feed[n]); alias != "" && alias != b.Name {
b.Aliases = []string{alias}
}
for _, t := range list {
b.Transponders = append(b.Transponders, toTransponder(t))
}
sort.Slice(b.Transponders, func(i, j int) bool {
return b.Transponders[i].DownLo < b.Transponders[j].DownLo
})
birds = append(birds, b)
added++
fmt.Printf(" NEW %5d %-24s %d transponder(s)\n", n, b.Name, len(b.Transponders))
}
sort.SliceStable(birds, func(i, j int) bool { return birds[i].Name < birds[j].Name })
out, err := json.MarshalIndent(birds, "", " ")
if err != nil {
die(err)
}
if err := os.WriteFile(birdsPath, append(out, '\n'), 0o644); err != nil {
die(err)
}
fmt.Printf("\nwrote %s — %d satellites (%d new)\n", birdsPath, len(birds), added)
}
// usable decides whether a SatNOGS transmitter is something an operator can
// work through.
//
// The database holds every emission a satellite makes, and most of them are not
// a contact: a telemetry beacon with a command uplink is listed exactly like an
// FM repeater, and shipping the command channel as a transponder would invite
// somebody to transmit on it. So both ends must exist, and anything that
// describes itself as telemetry or control is refused unless it also calls
// itself a repeater, a transponder or a digipeater.
func usable(t transmitter) bool {
if !t.Alive || t.Status != "active" {
return false
}
if t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
return false
}
d := strings.ToLower(t.Description)
isWorkable := strings.Contains(d, "repeater") || strings.Contains(d, "transponder") ||
strings.Contains(d, "digipeater") || strings.Contains(d, "aprs") ||
strings.Contains(d, "voice") || strings.Contains(d, "sstv") || strings.Contains(d, "dstar")
if isWorkable {
return true
}
for _, bad := range []string{"telemetry", "command", "control", "dtmf", "beacon", "tlm"} {
if strings.Contains(d, bad) {
return false
}
}
// An ANALOG emission with both ends is a contact by construction: nobody
// puts an FM or SSB uplink on a satellite for housekeeping. This is what
// catches the plainly-described repeaters — AO-27 says only "Mode V/U FM",
// and rejecting it for not using the word "repeater" would have dropped one
// of the best-known FM birds there is.
if m := adifMode(t.Mode); m == "FM" || m == "SSB" || m == "CW" {
return true
}
// A digital emission has to say what it is. A GMSK uplink is a command
// channel far more often than it is a digipeater, and shipping the wrong one
// invites an operator to transmit on a control frequency.
return t.Type == "Transponder" || (t.UplinkHigh > t.UplinkLow && t.DownlinkHigh > t.DownlinkLow)
}
// ctcssRe pulls a tone out of prose. SatNOGS has no field for it, and it is not
// optional: an FM uplink without the right tone opens nothing at all.
var ctcssRe = regexp.MustCompile(`(?i)(?:ctcss|pl)[^0-9]{0,4}(\d{2,3}(?:\.\d)?)|(\d{2,3}(?:\.\d)?)\s*(?:hz)?\s*(?:ctcss|pl)\b`)
func toTransponder(t transmitter) sat.Transponder {
tp := sat.Transponder{
Label: cleanLabel(t.Description),
Mode: adifMode(t.Mode),
DownLo: t.DownlinkLow,
DownHi: t.DownlinkHigh,
UpLo: t.UplinkLow,
UpHi: t.UplinkHigh,
Inverting: t.Invert,
}
// A "high" equal to the "low" is SatNOGS saying "a channel", not a one-hertz
// passband; Transponder.Linear() must not be fooled into interpolating.
if tp.DownHi <= tp.DownLo {
tp.DownHi = 0
}
if tp.UpHi <= tp.UpLo {
tp.UpHi = 0
}
// Inversion is a property of a PASSBAND. SatNOGS sets the flag on some FM
// channels too, where it means nothing — the code ignores it there, but a
// data file that says an FM repeater inverts is a data file that will
// mislead the next person to read it.
if tp.DownHi == 0 || tp.UpHi == 0 {
tp.Inverting = false
}
if m := ctcssRe.FindStringSubmatch(t.Description); m != nil {
v := m[1]
if v == "" {
v = m[2]
}
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 60 && f <= 260 {
tp.CTCSS = f
}
}
return tp
}
// adifMode maps SatNOGS' modulation names onto the four modes a log knows.
func adifMode(m string) string {
switch u := strings.ToUpper(strings.TrimSpace(m)); {
case strings.HasPrefix(u, "FM"), u == "SSTV", u == "DSTAR", u == "NFM":
return "FM"
case u == "USB", u == "LSB", u == "SSB":
return "SSB"
case u == "CW":
return "CW"
default:
return "DATA"
}
}
func cleanLabel(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "Transponder"
}
return s
}
// displayName prefers the OSCAR designation an operator says out loud.
// "SAUDISAT 1C (SO-50)" is SO-50 to everybody except a catalog.
func displayName(feedName string) string {
s := strings.TrimSpace(feedName)
if i := strings.IndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") {
inner := strings.TrimSpace(s[i+1 : len(s)-1])
if oscarRe.MatchString(inner) {
return inner
}
}
// "RS-44 & BREEZE-KM R/B" — the rocket body it flies with is not its name.
if i := strings.Index(s, " & "); i > 0 {
return strings.TrimSpace(s[:i])
}
return s
}
var oscarRe = regexp.MustCompile(`^[A-Z]{1,3}-\d{1,3}$`)
// noradFor finds a curated entry's catalog number by the name matching the
// package already does.
//
// Deterministic on purpose. One satellite can hold TWO catalog entries — a
// deployment catalogued before the objects were told apart, GreenCube being
// 53106 and 53109 in the two feeds — and iterating the map picked a different
// one each run, so the generated file changed for no reason and the diff was
// unreadable. Candidates are therefore scored and tied on the lower number:
// a feed name whose designation IS the bird's name ("GREENCUBE (IO-117)" for
// IO-117) beats one that only matches through an alias.
func noradFor(b sat.Bird, feed map[int]string) (int, bool) {
nums := make([]int, 0, len(feed))
for n := range feed {
nums = append(nums, n)
}
sort.Ints(nums)
best, bestScore := 0, -1
for _, n := range nums {
name := feed[n]
if !b.Matches(name) {
continue
}
score := 0
if strings.EqualFold(displayName(name), b.Name) {
score = 2
} else if strings.EqualFold(strings.TrimSpace(name), b.Name) {
score = 1
}
if score > bestScore {
best, bestScore = n, score
}
}
return best, best != 0
}
func loadFeeds() (map[int]string, error) {
out := map[int]string{}
for _, url := range tleFeeds {
body, err := get(url)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", url, err)
continue
}
lines := []string{}
for _, l := range strings.Split(string(body), "\n") {
if s := strings.TrimSpace(l); s != "" {
lines = append(lines, s)
}
}
for i := 0; i+2 < len(lines); i += 3 {
if !strings.HasPrefix(lines[i+1], "1 ") || len(lines[i+1]) < 7 {
continue
}
n, err := strconv.Atoi(strings.TrimSpace(lines[i+1][2:7]))
if err != nil || n <= 0 {
continue
}
// First feed wins: Celestrak's spelling is the one the operator sees.
if _, had := out[n]; !had {
out[n] = lines[i]
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("no elements from any feed")
}
return out, nil
}
// decayed reports whether this bird's spacecraft has re-entered, matching on
// the catalog number when we have one and on the designations SatNOGS lists
// otherwise — "NAYIF-1" carries "EO-88" only in its alternative names.
func decayed(b sat.Bird, cat []satellite) (satellite, bool) {
for _, s := range cat {
if s.Status != "re-entered" && s.Decayed == "" {
continue
}
if b.NORAD != 0 {
if s.NORAD == b.NORAD {
return s, true
}
continue
}
names := append(strings.FieldsFunc(s.Names, func(r rune) bool { return r == ',' || r == '\n' }), s.Name)
for _, n := range names {
if strings.TrimSpace(n) == "" {
continue
}
if b.Matches(strings.TrimSpace(n)) {
return s, true
}
}
}
return satellite{}, false
}
func loadSatellites() ([]satellite, error) {
body, err := get(satnogsSats)
if err != nil {
return nil, err
}
var out []satellite
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("satnogs satellites: %w", err)
}
return out, nil
}
func loadTransmitters() ([]transmitter, error) {
body, err := get(satnogsTX)
if err != nil {
return nil, err
}
var out []transmitter
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("satnogs: %w", err)
}
return out, nil
}
func loadBirds() ([]sat.Bird, error) {
b, err := os.ReadFile(birdsPath)
if err != nil {
return nil, err
}
var out []sat.Bird
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("%s: %w", birdsPath, err)
}
return out, nil
}
func get(url string) ([]byte, error) {
c := &http.Client{Timeout: 90 * time.Second}
resp, err := c.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", url, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 32<<20))
}
func die(err error) {
fmt.Fprintln(os.Stderr, "satgen:", err)
os.Exit(1)
}
+74
View File
@@ -0,0 +1,74 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"sync"
)
// systemInstallDataDir keeps the portable "data beside the binary" layout where
// it works, and falls back to the XDG data directory where it cannot.
//
// Both halves are needed on Linux, and only on Linux. A tarball or AppImage
// unpacked into the home directory behaves exactly like the Windows build —
// the folder travels with the program, which is the whole point of the design.
// But the ordinary way software arrives here is a package that installs into
// /usr/bin or /opt, where no user may write, and telling an operator to "move
// the program somewhere writable" is telling them their distribution installed
// it wrong. So when the folder beside the binary is read-only, OpsLog keeps its
// data in ~/.local/share/OpsLog instead and says so in the log.
//
// Detection is by TRYING, not by matching path prefixes: /opt, /usr/local and a
// NFS-mounted home are all writable on some stations and not on others, and the
// only honest test is whether the write succeeds.
func systemInstallDataDir(besideExe string) (string, bool) {
xdgOnce.Do(func() { xdgDir, xdgUsed = resolveDataDir(besideExe) })
return xdgDir, xdgUsed
}
var (
xdgOnce sync.Once
xdgDir string
xdgUsed bool
)
func resolveDataDir(besideExe string) (string, bool) {
if writable(besideExe) {
return "", false
}
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", false // nowhere better to go; let the caller report the failure
}
base = filepath.Join(home, ".local", "share")
}
alt := filepath.Join(base, "OpsLog", "data")
if !writable(alt) {
return "", false
}
bootLog("data dir: %s is not writable — keeping the data in %s instead", besideExe, alt)
return alt, true
}
// writable reports whether dir can be created and written to. The probe file is
// removed again; a leftover in the data folder would be one more thing to
// explain.
func writable(dir string) bool {
if err := os.MkdirAll(dir, 0o755); err != nil {
return false
}
probe := filepath.Join(dir, ".writetest")
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
return false
}
_ = os.Remove(probe)
return true
}
// dataDirAdvice is what the operator is told when neither location works — a
// full disk, or a home directory that is not writable either.
const dataDirAdvice = "\n\nOpsLog keeps its data next to the program, or in ~/.local/share/OpsLog when that folder belongs to the system. Neither could be written to: check the disk is not full and that your home directory is writable."
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package main
// systemInstallDataDir never diverts on Windows: the data folder is beside the
// executable, full stop. That is what makes an OpsLog on a USB stick carry its
// logbook with it, and an operator who copies the folder to a new PC find
// everything already there.
//
// A copy dropped into Program Files is refused the write and told so (see
// checkDataDirWritable) rather than quietly logging somewhere else, because
// "where are my QSOs?" is a far worse afternoon than "move this folder".
func systemInstallDataDir(besideExe string) (string, bool) { return "", false }
// dataDirAdvice is what the operator is told when that folder cannot be written.
const dataDirAdvice = "\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator."
+116 -68
View File
@@ -12,7 +12,7 @@ import {
ContestDupe,
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
LookupCallsign, GetStationSettings, GetListsSettings, GetSatelliteNames,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
SMTPConfigured, SendLogToDeveloper,
WorkedBefore,
@@ -52,7 +52,7 @@ import {
ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
} from '../wailsjs/go/main/App';
@@ -80,6 +80,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { SatellitePanel } from '@/components/SatellitePanel';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
@@ -565,7 +566,9 @@ function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle
type="button"
tabIndex={-1}
onClick={onToggle}
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
// The whole tooltip, not a verb glued to a noun: the caller knows what
// this padlock does and can say it in the operator's own language.
title={title}
className={cn(
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
@@ -617,31 +620,34 @@ export default function App() {
});
const locksRef = useRef(locks);
useEffect(() => { locksRef.current = locks; }, [locks]);
const toggleLock = (k: LockKey) => {
setLocks((s) => {
const wasLocked = s[k];
const next = { ...s, [k]: !wasLocked };
if (wasLocked) {
// Unlocking → restore automatic behavior. Without this the locked
// value would linger forever: a stale Start time would never refresh
// even after a new callsign is entered.
if (k === 'start') {
// If a QSO is currently in progress (callsign typed), snap start
// to now since we missed the auto-start moment. Otherwise clear.
setQsoStartedAt(callsign.trim() ? new Date() : null);
} else if (k === 'end') {
// Drop the frozen end so the field tracks the live UTC clock.
setQsoEndedAt(null);
}
} else {
// Locking (manual / deferred entry) → pre-fill with today's date + the
// current UTC time so the fields aren't empty; the operator just adjusts.
const now = new Date();
if (k === 'start') setQsoStartedAt((d) => d ?? now);
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
}
return next;
});
// ONE padlock, not 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 used to be five clicks in
// five different places, each of which had to be found first.
//
// The five per-field locks stay underneath, because everything downstream
// reads them and they say the right thing individually ("this value is
// decoupled from the rig"). Only the control is one.
const manualEntry = locks.start && locks.end && locks.band && locks.mode && locks.freq;
const setManualEntry = (on: boolean) => {
setLocks({ band: on, mode: on, freq: on, start: on, end: on });
if (on) {
// Pre-filled with today's date and the current UTC time so the fields are
// not empty; the operator only has to correct them.
const now = new Date();
setQsoStartedAt((d) => d ?? now);
setQsoEndedAt((d) => d ?? now);
} else {
// Back to automatic. Without this the frozen values would linger for
// ever: a start time held from a backdated entry would never refresh,
// even after a new callsign is typed. A QSO already in progress snaps its
// start to now, since the moment it would have been taken has passed.
setQsoStartedAt(callsign.trim() ? new Date() : null);
setQsoEndedAt(null);
}
};
const [band, setBand] = useState('20m');
const [mode, setMode] = useState('SSB');
@@ -1354,6 +1360,17 @@ export default function App() {
}
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
const [satTabOpen, setSatTabOpen] = useState(() => localStorage.getItem('opslog.satTab') === '1');
function openSatTab() {
setSatTabOpen(true);
writeUiPref('opslog.satTab', '1');
setActiveTab('sat');
}
function closeSatTab() {
setSatTabOpen(false);
writeUiPref('opslog.satTab', '0');
setActiveTab((t) => (t === 'sat' ? 'recent' : t));
}
function openDxpedTab() {
setDxpedTabOpen(true);
writeUiPref('opslog.dxpedTab', '1');
@@ -2342,16 +2359,6 @@ export default function App() {
return () => window.clearInterval(t);
}, []);
// PSK Reporter feed, for the status-bar chip. Polled slowly: the chip only
// says up or down, and the count behind it is a tooltip.
const [pskr, setPskr] = useState<any>(null);
useEffect(() => {
const load = () => { GetPSKReporterStatus().then(setPskr).catch(() => {}); };
load();
const t = window.setInterval(load, 10000);
return () => window.clearInterval(t);
}, []);
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
@@ -2587,6 +2594,15 @@ export default function App() {
// half hour — long enough to hold a whole opening, short enough that a night
// of FT8 on 20 m does not turn the list into something no filter can rescue.
const DECODE_KEEP_MS = 30 * 60 * 1000;
// And a hard ceiling on the count, because the half hour is not one on a
// crowded band.
//
// Three decoders on an open evening put several thousand rows in that window,
// and the panel slows down long before the age limit removes any of them:
// every one is a row to lay out, a status to resolve and a distance to work
// out. Two thousand is more than a screen can hold many times over, and past
// it the oldest go — the newest period is what an operator is reading.
const DECODE_MAX = 2000;
const [decodes, setDecodes] = useState<DecodeRow[]>([]);
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
// The LIVE transmit state, replaced on every Status — what is going out now
@@ -3320,7 +3336,11 @@ export default function App() {
const l: ListsSettings = await GetListsSettings();
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
if (l.bands && l.bands.length) setBands(l.bands);
setSatellites([...(((l as any).satellites ?? []) as string[])].filter(Boolean).sort());
// The satellites come from the satellite side now, not from a list typed
// by hand in Settings: one station kept two lists of the same birds and
// they drifted apart. Go merges the followed set with whatever the old
// hand-kept list still holds, so nobody's typing is lost.
GetSatelliteNames().then((s) => setSatellites((s ?? []) as string[])).catch(() => {});
if (l.modes && l.modes.length) {
setModePresets(l.modes);
const names = l.modes.map((m) => m.name);
@@ -3876,7 +3896,9 @@ export default function App() {
return !b2 || (d.band ?? '').toLowerCase() === b2;
});
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
return next;
// Oldest first in this list, so the ceiling is applied from the front:
// what goes is what was already scrolled past.
return next.length > DECODE_MAX ? next.slice(next.length - DECODE_MAX) : next;
});
};
flushDecodesRef.current = () => { void flushDecodes(); };
@@ -5210,6 +5232,7 @@ export default function App() {
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
{ type: 'item', label: t('sat.tab'), action: 'tools.sat' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
@@ -5267,6 +5290,7 @@ export default function App() {
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break;
case 'tools.sat': openSatTab(); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break;
@@ -5403,8 +5427,15 @@ export default function App() {
// "59+30" turned up, which is five characters plus its padding and no longer
// fitted. The RST fields are back to their original width; the callsign keeps
// the rest of the row.
// shrink-0, and a notch narrower than it used to be.
//
// The row it sits in gains a date field when the padlock is closed, and a
// flex row makes room by shrinking its children — so the callsign box, the
// widest of them, visibly narrowed the moment the operator started a manual
// entry. The field the eye is on while typing must not move. It is now the
// size it will always be, and the slack comes from the boxes beside it.
const callsignBlock = (
<div className="flex flex-col w-56" data-esm="call">
<div className="flex flex-col w-52 shrink-0" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
<span className="text-primary font-semibold">{t('field.callsign')}</span>
{lookupBusy && (
@@ -5550,8 +5581,12 @@ export default function App() {
</div>
</div>
);
// Both report boxes: a notch narrower and pinned, for the same reason as the
// callsign. They were the next widest things in the row, so once the callsign
// stopped giving, they were the ones that moved when the date appeared.
// "59+20" is the longest report either ever holds and still fits.
const rstTxBlock = (
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
{/* The wheel steps the report an S-unit on RST, a decibel on a digital
one. Wheeling is the same gesture as saying "he is a bit stronger than
that", and it beats retyping three characters between overs. */}
@@ -5561,7 +5596,7 @@ export default function App() {
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
@@ -5597,7 +5632,7 @@ export default function App() {
) : null;
const startBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={manualEntry} title={manualEntry ? t('field.manualEntryOff') : t('field.manualEntryOn')} onToggle={() => setManualEntry(!manualEntry)} /></Label>
<Input
readOnly={!locks.start}
tabIndex={locks.start ? 0 : -1}
@@ -5616,7 +5651,7 @@ export default function App() {
);
const endBlock = (
<div className="flex flex-col w-28">
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')}</Label>
<Input
readOnly={!locks.end}
tabIndex={locks.end ? 0 : -1}
@@ -5887,7 +5922,7 @@ export default function App() {
// used in the full layout to save vertical height.
const bandRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')}</Label>
<div className="flex-1 min-w-0">
<Select value={band} onValueChange={onBandUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -5898,7 +5933,7 @@ export default function App() {
);
const modeRow = (
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')}</Label>
<div className="flex-1 min-w-0">
<Select value={mode} onValueChange={onModeUserChange}>
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
@@ -5996,7 +6031,7 @@ export default function App() {
};
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')}</Label>
<Input
tabIndex={-1}
className="font-mono"
@@ -8160,6 +8195,21 @@ export default function App() {
</span>
</TabsTrigger>
)}
{satTabOpen && (
<TabsTrigger value="sat" className="gap-1.5">
{t('sat.tab')}
<span
role="button"
aria-label="Close Satellites"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeSatTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')}
@@ -8808,6 +8858,18 @@ export default function App() {
)}
</TabsContent>
)}
{satTabOpen && (
<TabsContent value="sat" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{/* Mounted only while it is the visible tab: the panel polls the
tuning once a second, and there is no reason to compute an
orbit for a tab nobody is looking at. */}
{activeTab === 'sat' && (
<div className="h-full w-full min-h-0">
<SatellitePanel myGrid={station.my_grid} />
</div>
)}
</TabsContent>
)}
{ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && (
@@ -9086,26 +9148,12 @@ export default function App() {
</button>
);
})}
{/* PSK Reporter, next to the hardware chips because it is the same
kind of fact: a link that is either up or it is not. Shown ONLY
when the opening watch is on a permanently grey chip for a
feature nobody enabled is clutter, and the bar is 28 px.
The decode count is in the tooltip rather than the chip: it moves
several times a second on an open band, and a number flickering in
the corner of the eye is not information, it is a distraction. */}
{pskr?.running && (
<button
type="button"
title={t('pskr.tip', { n: pskr.received ?? 0, bands: (pskr.bands ?? []).join(' ') })}
onClick={() => { setSettingsSection('cluster'); setShowSettings(true); }}
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer shrink-0"
>
<span className={cn('size-2 rounded-full',
(pskr.received ?? 0) > 0 ? 'bg-success' : 'bg-warning')} />
MQTT
</button>
)}
{/* The PSK Reporter chip used to sit here, labelled MQTT. 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. The state it carried the openings
feed up or down, and how many reports have arrived is shown in
the Chase New panel, which is the place that uses it. */}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for
+1 -1
View File
@@ -141,7 +141,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogContent overlayBlur={false} className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
<DialogDescription>{t('altm.desc')}</DialogDescription>
+1 -1
View File
@@ -403,7 +403,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent overlayBlur={false} className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
+15 -1
View File
@@ -60,7 +60,11 @@ const DEFAULT_BANDS: { tag: string; label: string }[] = [
];
const CLASSES = ['PH', 'CW', 'DIG'] as const;
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
export const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
// Which digital row the matrix opens on. Empty = DIG, the group of them all.
// Set in Settings ▸ General; see the rotation below.
export const MATRIX_DIGI_KEY = 'opslog.matrixDigiMode';
function classMatchesMode(cls: string, mode: string): boolean {
const u = (mode || '').toUpperCase();
if (cls === 'PH') return PHONE_MODES.has(u);
@@ -143,7 +147,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes,
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes],
);
// Where the rotation STARTS. An operator who only ever works FT8 was shown
// "DIG" every time and had to click to the mode they actually use, on every
// callsign — so the row they want is the one it opens on. Empty (the default)
// keeps DIG, which is right for anyone working several digital modes.
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
useEffect(() => {
const want = (localStorage.getItem(MATRIX_DIGI_KEY) || '').toUpperCase().trim();
if (!want) { setDigIdx(0); return; }
const i = digModes.indexOf(want);
setDigIdx(i >= 0 ? i + 1 : 0);
}, [digModes]);
// A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
+1 -1
View File
@@ -193,7 +193,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogContent overlayBlur={false} className="max-w-md">
<DialogHeader>
<DialogTitle>{t('bulk.title')}</DialogTitle>
<DialogDescription>
+131 -22
View File
@@ -12,7 +12,7 @@
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
// come from the same resolver the cluster uses, so a call means the same thing in
// both panels rather than being judged twice by two rules.
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -320,6 +320,39 @@ const US_STATES: Record<string, string> = {
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
// The columns worth sorting on. Not every column: time is what the periods
// already are, and sorting a slot by callsign or message answers no question an
// operator has.
type SortKey = 'snr' | 'freq' | 'dist' | 'country' | 'status';
const SORTABLE: SortKey[] = ['snr', 'freq', 'dist', 'country', 'status'];
// Which way each column is worth reading FIRST — strongest signal, lowest
// frequency, furthest DX, A to Z, most wanted. Clicking again reverses it.
const SORT_FIRST: Record<SortKey, 'asc' | 'desc'> = {
snr: 'desc', freq: 'asc', dist: 'desc', country: 'asc', status: 'desc',
};
// How wanted a station is, as a number to sort by. The cluster's own order,
// most wanted first — a new entity above a new band above a new slot — so the
// two views rank the same things the same way.
const STATUS_RANK: Record<string, number> = {
'new': 100, 'new-band-mode': 90, 'new-band': 80, 'new-mode': 70, 'new-slot': 60,
'new-call': 30, 'worked': 10,
};
function statusRank(e?: StatusEntry): number {
if (!e) return 0;
let r = STATUS_RANK[e.status ?? ''] ?? 0;
// The markers that are orthogonal to the entity: a new county on a worked
// country is still something to chase, and should not sort with the plain
// duplicates.
if (e.new_pota) r = Math.max(r, 50);
if (e.new_county) r = Math.max(r, 45);
if (e.new_grid) r = Math.max(r, 44);
if (e.new_state) r = Math.max(r, 43);
if (e.new_pfx) r = Math.max(r, 42);
return r;
}
const COL_MAX = 600;
const COLW_KEY = 'opslog.decodeColWidths';
@@ -671,6 +704,68 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
const statusOf = (d: Decode): StatusEntry | undefined =>
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// ── Sorting, inside a period ──────────────────────────────────────────
//
// WITHIN each slot and never across them. The periods are the point of this
// panel — what was on the air in one fifteen-second window — and a list
// sorted end to end by signal would mix three minutes of decodes into one
// column of numbers with no way to tell which slot any of them came from.
//
// 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.
const [sortSpec, setSortSpec] = usePersisted('sort', '');
const [sortKey, sortDir] = useMemo(() => {
const [k, d] = String(sortSpec || '').split(':');
return [SORTABLE.includes(k as SortKey) ? (k as SortKey) : '', d === 'asc' ? 'asc' : 'desc'] as const;
}, [sortSpec]);
// One click sorts the way that column is worth reading — strongest signal,
// furthest DX, lowest frequency, A to Z, most wanted. The second reverses it,
// the third gives arrival order back.
const toggleSort = (k: SortKey) => {
if (sortKey !== k) { setSortSpec(`${k}:${SORT_FIRST[k]}`); return; }
if (sortDir === SORT_FIRST[k]) { setSortSpec(`${k}:${SORT_FIRST[k] === 'asc' ? 'desc' : 'asc'}`); return; }
setSortSpec('');
};
const sortValue = useCallback((d: Decode, k: SortKey): number | string => {
const e = statusOf(d);
switch (k) {
case 'snr': return d.snr;
case 'freq': return d.freq_hz ?? 0;
case 'dist': {
const g = d.grid || e?.grid || '';
const path = myGrid && g ? pathBetween(myGrid, g) : null;
// A station that never sent a grid cannot be placed. Sorted to the end
// whichever way round the column goes, rather than pretending to a
// distance of zero and sitting at the top of "nearest first".
return path ? path.distanceShort : Number.NaN;
}
case 'country': return (e?.country ?? '').toUpperCase();
case 'status': return statusRank(e);
}
}, [spotStatus, myGrid]);
const sortDecodes = useCallback((list: Decode[]): Decode[] => {
if (!sortKey) return list;
const sign = sortDir === 'asc' ? 1 : -1;
return [...list].sort((a, b) => {
const va = sortValue(a, sortKey), vb = sortValue(b, sortKey);
const na = typeof va === 'number' && Number.isNaN(va);
const nb = typeof vb === 'number' && Number.isNaN(vb);
if (na !== nb) return na ? 1 : -1; // unknowns last, both ways
if (na && nb) return 0;
if (typeof va === 'string' || typeof vb === 'string') {
const sa = String(va), sb = String(vb);
// An empty country is an unknown too, not a name that sorts first.
if (!sa !== !sb) return sa ? -1 : 1;
return sign * sa.localeCompare(sb);
}
return sign * ((va as number) - (vb as number));
});
}, [sortKey, sortDir, sortValue]);
// The mode currently on the air, for the slot clock. The newest decode knows
// best; between overs the transmit state still does.
// A decoder that has lost its CAT link keeps announcing the last dial
@@ -802,20 +897,24 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
// the transmit messages belong to one receiver and not the other.
const panes = useMemo(() => {
// The sort is applied to each period's decodes, never to the periods
// themselves: the slots stay newest-first, which is what the panel is.
const sorted = (ps: ReturnType<typeof buildPeriods>) =>
sortKey ? ps.map((p) => ({ ...p, decodes: sortDecodes(p.decodes) })) : ps;
if (!splitByInstance || instances.length < 2) {
return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }];
return [{ key: '', label: '', tx: txState ?? undefined, periods: sorted(buildPeriods(filtered, txMsgs)) }];
}
return instances.map((inst) => ({
key: inst,
// What the program is called, not the id it announces — see decoderName.
label: decoderName(inst),
tx: txStates?.[inst],
periods: buildPeriods(
periods: sorted(buildPeriods(
filtered.filter((d) => (d.instance ?? '') === inst),
txMsgs.filter((m) => (m.instance ?? '') === inst),
),
)),
}));
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates, sortKey, sortDecodes]);
const resetFilters = () => {
@@ -1198,23 +1297,33 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}>
{cols.map((c, i) => (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
<ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)}
/>
</span>
))}
{cols.map((c, i) => {
const sortable = SORTABLE.includes(c.key as SortKey);
const active = sortable && sortKey === c.key;
return (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end',
sortable && 'cursor-pointer select-none hover:text-foreground',
active && 'text-primary')}
onClick={sortable ? () => toggleSort(c.key as SortKey) : undefined}
title={sortable ? t('dec.sortTip')
: c.key === 'dt' ? t('dec.colDtTitle')
: c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
{active && <span className="ml-0.5 shrink-0">{sortDir === 'asc' ? '▲' : '▼'}</span>}
<ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)}
/>
</span>
);
})}
</div>
</div>
+14 -2
View File
@@ -9,6 +9,7 @@ import {
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { Combobox } from '@/components/ui/combobox';
import { useOperatingLists } from '@/lib/operatingLists';
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector';
@@ -158,6 +159,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
const { t } = useI18n();
const oper = useOperatingLists(tab);
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
@@ -476,11 +478,21 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
</SelectContent>
</Select>
</Field>
{/* The rigs and antennas already declared in Settings ▸ Operating
conditions. Typing them again on every contact is both work and a
source of spellings that do not match — "IC-7610", "IC 7610" and
"ic7610" are three different rigs to an award and to a filter.
Free text stays allowed: a QSO made from somebody else's station
carries a rig that was never in this tree. */}
<Field label={t('detp.rig')} span={3}>
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
<Combobox value={details.my_rig} options={oper.rigs} showToggle allowFreeText
onChange={(v) => onChange({ my_rig: v })} />
</Field>
<Field label={t('detp.antenna')} span={3}>
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
{/* The antennas of the chosen rig, since that is what they hang off
— and all of them when the rig is one this tree does not know. */}
<Combobox value={details.my_antenna} options={oper.antennasFor(details.my_rig)} showToggle allowFreeText
onChange={(v) => onChange({ my_antenna: v })} />
</Field>
{satelliteMode && (
<>
+2 -1
View File
@@ -6,6 +6,7 @@ import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
// FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by
@@ -128,7 +129,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
localStorage.setItem('opslog.ftmapBase', basemap);
saveMapBase(MAP_BASE_FT, basemap);
}, [basemap]);
// The arcs, redrawn when the decode list changes. Newest last so they paint
+6 -3
View File
@@ -6,7 +6,8 @@ import { GridSquares } from '../../wailsjs/go/main/App';
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
import { BASEMAPS, addBasemap, type BasemapKey } from '@/components/MainMap';
import { loadMapBase, saveMapBase, MAP_BASE_GRIDS, MAP_BASE_WORLD } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
@@ -86,7 +87,9 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
// This map's own imagery. It shared the world map's key until they were
// separated, so a choice made back then is inherited rather than reset.
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_GRIDS, 'light', MAP_BASE_WORLD));
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
// Repaint the squares when the THEME changes, not the basemap: the fills come
@@ -253,7 +256,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
</span>
<select
value={basemap}
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); saveMapBase(MAP_BASE_GRIDS, v); }}
title={t('gsm.basemap')}
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
>
+5 -3
View File
@@ -3,6 +3,7 @@ import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { loadMapBase, saveMapBase, MAP_BASE_WORLD } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { formatDistance } from '@/lib/units';
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
@@ -116,9 +117,10 @@ export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: st
attr: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics',
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
};
// loadBasemap is the WORLD map's imagery. Each map keeps its own — see
// lib/mapBase, which is where the keys live.
export function loadBasemap(): BasemapKey {
const v = localStorage.getItem('opslog.mapBasemap');
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
return loadMapBase(MAP_BASE_WORLD, 'light');
}
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
@@ -445,7 +447,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
<button
key={k}
type="button"
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
onClick={() => { setBasemap(k); saveMapBase(MAP_BASE_WORLD, k); }}
title={`Basemap: ${BASEMAPS[k].label}`}
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
+17 -3
View File
@@ -19,6 +19,7 @@ import {
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { useOperatingLists } from '@/lib/operatingLists';
import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n';
@@ -288,6 +289,9 @@ function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: strin
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
const { t } = useI18n();
// Read once per opening of the editor: rigs and antennas do not change while
// a contact is being corrected.
const oper = useOperatingLists();
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
// fall back to the built-in sets. Always include the QSO's own band/mode so an
// imported/legacy value is never silently dropped from the dropdown.
@@ -542,7 +546,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent overlayBlur={false} className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
@@ -982,8 +986,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
{/* The station's own rigs and antennas (Settings ▸ Operating
conditions), so a correction here spells them the same way
the log already does. Free text stays: an imported contact
carries whatever the other logger wrote. */}
<F label={t('qedit.rig')} span={3}>
<Combobox value={draft.my_rig ?? ''} options={oper.rigs} showToggle allowFreeText
onChange={(v) => set('my_rig', v)} />
</F>
<F label={t('qedit.antenna')} span={3}>
<Combobox value={draft.my_antenna ?? ''} options={oper.antennasFor(draft.my_rig ?? '')} showToggle allowFreeText
onChange={(v) => set('my_antenna', v)} />
</F>
</div>
</TabsContent>
+41 -14
View File
@@ -81,8 +81,12 @@ type BeamKind = 'antenna' | 'hover';
const TARGET_YELLOW = '#FBBF24';
const MAP_BG_TOP = '#0B1015';
const MAP_BG_BOTTOM = '#080C11';
const MAP_LAND = '#202832';
const MAP_LAND_SECONDARY = '#25303A';
// The continents, and they have to be VISIBLE. 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. These read as coastlines
// while staying well under the beams, which are what the dial is for.
const MAP_LAND = '#33414F';
const MAP_LAND_SECONDARY = '#41525F';
// What each rotor was last seen at, and what it was last told to do, kept
// OUTSIDE the component and keyed by rotor index.
@@ -289,7 +293,11 @@ function RotorCompassDial({
};
return (
<div className="w-full h-full min-w-0 aspect-square rounded-md border border-border bg-background flex items-center justify-center overflow-hidden">
// No card of its own, and no square: the dial is drawn as a disc and the
// corners are left to whatever it is sitting on. A black tile inside the
// rotor panel read as a hole punched in it — the widget is already a card,
// and this is an instrument on that card, not a second one.
<div className="w-full h-full min-w-0 aspect-square flex items-center justify-center overflow-hidden">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
@@ -338,10 +346,10 @@ function RotorCompassDial({
))}
</defs>
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${bgGradientId})`} />
<circle cx={CENTER} cy={CENTER} r={MAP_RADIUS} fill={`url(#${bgGradientId})`} />
{landPath && (
<g mask={`url(#${mapFadeMaskId})`} opacity="0.78" pointerEvents="none">
<g mask={`url(#${mapFadeMaskId})`} opacity="0.92" pointerEvents="none">
<path d={landPath} fill={MAP_LAND} />
<path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
</g>
@@ -610,9 +618,22 @@ export function RotorCompass({
return columns;
}, [presets]);
// 192 dial + 6 gap + 154 controls + 16 padding, plus 60 per preset column.
// THE SELECTOR HAS TO COME OUT OF SOMEWHERE.
//
// With more than one rotor a row of buttons appears above the dial, and the
// widget's height is not its own to take: it sits in a strip whose height is
// set by the entry form beside it. The extra row simply pushed the bottom of
// the panel off the end — the SP/LP pair and half the Stop button gone.
//
// So the dial and the button rows give the row back, in proportion: 24 px off
// the dial and 8 off each of the three rows is the height of a selector, and
// nothing has to be dropped.
const tight = !!(rotors && rotors.length > 1);
const dialPx = tight ? 168 : 192;
const rowPx = tight ? 52 : 60;
// 6 gap + 154 controls + 16 padding, plus 60 per preset column.
const controlsWidth = 154 + presetColumns.length * 60;
const widgetWidth = 368 + presetColumns.length * 60;
const widgetWidth = dialPx + 176 + presetColumns.length * 60;
const markMovementCommanded = () => {
movementSeenRef.current = false;
@@ -713,7 +734,8 @@ export function RotorCompass({
const renderPresetColumn = (column: RotorPreset[], columnIndex: number) => (
<div key={`preset-column-${columnIndex}`}
className="w-[54px] min-w-[54px] shrink-0 grid grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
className="w-[54px] min-w-[54px] shrink-0 grid gap-1.5 min-h-0"
style={{ gridTemplateRows: `repeat(3, ${rowPx}px)` }}>
{[0, 2, 4].map((row) => (
<div key={row} className="h-full min-h-0 grid grid-rows-2 gap-1">
{column[row] && renderPresetButton(column[row], columnIndex * 6 + row)}
@@ -749,7 +771,8 @@ export function RotorCompass({
);
const mainControls = (
<div className="w-[154px] min-w-[154px] shrink-0 grid grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
<div className="w-[154px] min-w-[154px] shrink-0 grid gap-1.5 min-h-0"
style={{ gridTemplateRows: `repeat(3, ${rowPx}px)` }}>
{/* Where the antenna is, and under it — smaller, yellow, and only while it
matters — where it was told to go. */}
<div className="h-full min-h-0 rounded-md border border-border bg-background/30 px-1 text-center relative overflow-hidden">
@@ -760,7 +783,8 @@ export function RotorCompass({
it. The green one does not move, so nothing jumps when the mouse
leaves the dial. */}
<div className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
tight ? 'text-[26px]' : 'text-[30px]',
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
hoverAzimuth != null ? 'opacity-0 scale-95' : 'opacity-100 scale-100',
displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
@@ -769,7 +793,8 @@ export function RotorCompass({
</div>
<div className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
tight ? 'text-[26px]' : 'text-[30px]',
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
hoverAzimuth != null ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
)}
@@ -904,7 +929,7 @@ export function RotorCompass({
</div>
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
<div className="flex flex-wrap gap-1 px-2 pt-1">
{rotors.map((name, index) => {
const active = (activeRotor ?? 0) === index;
const label = name?.trim() || `Rotor ${index + 1}`;
@@ -922,8 +947,10 @@ export function RotorCompass({
)}
{showControls ? (
<div className="flex items-stretch gap-1.5 p-2 min-h-0">
<div className="w-[192px] min-w-[192px] h-[192px] min-h-[192px] shrink-0">{dial}</div>
// The padding gives its share too: four pixels, which is what the
// selector row still owed after the dial and the buttons had paid.
<div className={cn('flex items-stretch gap-1.5 min-h-0', tight ? 'p-1.5' : 'p-2')}>
<div className="shrink-0" style={{ width: dialPx, minWidth: dialPx, height: dialPx, minHeight: dialPx }}>{dial}</div>
<div className="shrink-0 flex gap-1.5 min-h-0"
style={{ width: `${controlsWidth}px`, minWidth: `${controlsWidth}px` }}>
{mainControls}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
import { useI18n } from '@/lib/i18n';
// The sky, 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: whether the
// bird comes over the top or clips the horizon behind the house is something no
// amount of azimuth and elevation digits conveys, and one glance at a polar
// plot settles it.
//
// The projection is the one every satellite tracker uses and every operator
// already reads: the centre is the zenith, the outer circle is the horizon, and
// north is up. So the radius is (90 elevation), NOT the elevation — a
// satellite overhead is a dot in the middle, and a pass that stays near the rim
// is one that never rises.
export type SkyPoint = { at: string; az: number; el: number };
export function SkyPlot({ track, az, el, name, visible, size = 300 }: {
// The pass, sampled from rise to set. Empty draws the dial alone, which is
// still worth showing: it says where the antenna is pointing now.
track: SkyPoint[];
// Where the satellite is at this instant, or null when it is not up.
az?: number | null;
el?: number | null;
name?: string;
visible?: boolean;
size?: number;
}) {
const { t } = useI18n();
const R = size / 2 - 14; // the horizon circle
const cx = size / 2, cy = size / 2;
// Where a bearing and an elevation land on the dial.
const pt = (azDeg: number, elDeg: number): [number, number] => {
const r = R * (90 - Math.max(0, Math.min(90, elDeg))) / 90;
const a = (azDeg * Math.PI) / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
};
const rings = [15, 30, 45, 60, 75];
const path = track.length > 1
? track.map((p, i) => `${i === 0 ? 'M' : 'L'}${pt(p.az, p.el).map((v) => v.toFixed(1)).join(' ')}`).join(' ')
: '';
// Ticks every 10°, longer every 30°, so the rim reads as a compass rather
// than a plain circle.
const ticks = [];
for (let a = 0; a < 360; a += 10) {
const long = a % 30 === 0;
const rad = (a * Math.PI) / 180;
const r1 = R, r2 = R - (long ? 7 : 4);
ticks.push(
<line key={a}
x1={cx + r1 * Math.sin(rad)} y1={cy - r1 * Math.cos(rad)}
x2={cx + r2 * Math.sin(rad)} y2={cy - r2 * Math.cos(rad)}
stroke="var(--border)" strokeWidth={long ? 1.4 : 0.8} />,
);
}
const here = az != null && el != null ? pt(az, el) : null;
const start = track.length > 1 ? pt(track[0].az, track[0].el) : null;
const end = track.length > 1 ? pt(track[track.length - 1].az, track[track.length - 1].el) : null;
return (
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto select-none" role="img"
aria-label={t('sat.skyPlot')}>
<defs>
{/* An arrowhead on the track: a pass has a direction, and which way the
satellite is travelling decides where to point the antenna next. */}
<marker id="skyArrow" viewBox="0 0 10 10" refX="6" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--success)" />
</marker>
</defs>
<circle cx={cx} cy={cy} r={R} fill="var(--muted)" fillOpacity={0.25}
stroke="var(--border)" strokeWidth={1.5} />
{rings.map((e) => (
<circle key={e} cx={cx} cy={cy} r={R * (90 - e) / 90}
fill="none" stroke="var(--border)" strokeWidth={0.6} strokeDasharray="3 4" />
))}
{ticks}
{/* The cardinal cross. */}
<line x1={cx} y1={cy - R} x2={cx} y2={cy + R} stroke="var(--border)" strokeWidth={0.6} />
<line x1={cx - R} y1={cy} x2={cx + R} y2={cy} stroke="var(--border)" strokeWidth={0.6} />
{([
{ lbl: 'N', x: cx, y: cy - R - 3, anchor: 'middle' },
{ lbl: 'S', x: cx, y: cy + R + 11, anchor: 'middle' },
{ lbl: 'E', x: cx + R + 4, y: cy + 4, anchor: 'start' },
{ lbl: 'W', x: cx - R - 4, y: cy + 4, anchor: 'end' },
] as const).map((c) => (
<text key={c.lbl} x={c.x} y={c.y} textAnchor={c.anchor}
fontSize={11} fontWeight={600} fill="var(--muted-foreground)">{c.lbl}</text>
))}
{/* Elevation labels along the west arm, the way a tracker draws them. */}
{[0, 30, 60].map((e) => (
<text key={e} x={cx - R * (90 - e) / 90 + 2} y={cy + 10} fontSize={8}
fill="var(--muted-foreground)" opacity={0.8}>{e}°</text>
))}
{/* The pass. */}
{path && (
<path d={path} fill="none" stroke="var(--success)" strokeWidth={1.6}
strokeDasharray="5 3" markerMid="url(#skyArrow)" markerEnd="url(#skyArrow)"
opacity={0.85} />
)}
{start && <circle cx={start[0]} cy={start[1]} r={3} fill="none" stroke="var(--success)" strokeWidth={1.4} />}
{end && <circle cx={end[0]} cy={end[1]} r={3} fill="var(--success)" opacity={0.6} />}
{/* Where it is now. Hollow and grey below the horizon: the numbers are
still right, but nothing can be worked through the earth. */}
{here && (
<g>
<line x1={here[0] - 6} y1={here[1]} x2={here[0] + 6} y2={here[1]}
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
<line x1={here[0]} y1={here[1] - 6} x2={here[0]} y2={here[1] + 6}
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
<circle cx={here[0]} cy={here[1]} r={4}
fill={visible ? 'var(--success)' : 'none'}
stroke={visible ? 'var(--background)' : 'var(--muted-foreground)'} strokeWidth={1.2} />
</g>
)}
{/* The name and the look angles, in the middle, where a tracker puts them
— big enough to read from the other side of the shack. */}
{!!name && (
<text x={cx} y={cy - R * 0.42} textAnchor="middle" fontSize={18} fontWeight={600}
fill="var(--foreground)" opacity={0.85}>{name}</text>
)}
{az != null && el != null && (
<text x={cx} y={cy - R * 0.22} textAnchor="middle" fontSize={12}
fill="var(--muted-foreground)" className="tabular-nums">
AZ {az.toFixed(1)}° EL {el.toFixed(1)}°
</text>
)}
</svg>
);
}
+205 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -25,6 +25,9 @@ import {
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetPSUStatus, GetPSUSettings, SetPSUOutput,
GetCATState,
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -82,6 +85,181 @@ function PSUCard({ st, busy, onToggle, t }: {
);
}
// ── What commands the station, and not only what it switches ───────────────
//
// This tab began as the relay and rotator dashboard, and stopped there: the
// three things an operator touches most — the radio, the CW keyer and the voice
// keyer — were the ones missing from the page that claims to show the station.
//
// Each card polls its own binding and holds its own state, like PSUCard above.
// That is deliberate: they can then be dropped into the grid, reordered and
// hidden with everything else, and adding one costs nothing to the panel around
// it. None of them tries to be the full console — a card says what the thing is
// doing and offers the one or two controls worth reaching for from here.
const fmtMHz = (hz: number) => (hz > 0 ? (hz / 1e6).toFixed(6) : '—');
// The radio. The frequency and the mode large, because that is what an operator
// glances at, and the split pair underneath only when there IS a split — a
// second frequency shown at all times is one more number to read past.
function RigCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetCATState().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Radio className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{st?.rig || t('station.rig')}</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold tabular-nums leading-none">{fmtMHz(st?.freq_hz ?? 0)}</span>
<span className="text-xs text-muted-foreground">MHz</span>
</div>
<div className="flex items-center gap-2 flex-wrap text-[11px]">
{!!st?.mode && <span className="rounded px-1.5 py-px font-semibold bg-primary/15 text-primary border border-primary/30">{st.mode}</span>}
{!!st?.band && <span className="text-muted-foreground">{st.band}</span>}
{!!st?.vfo && <span className="text-muted-foreground">VFO {st.vfo}</span>}
{!!st?.backend && <span className="ml-auto text-muted-foreground/70 truncate">{st.backend}</span>}
</div>
{st?.split && (
<div className="flex items-center gap-2 text-[11px] tabular-nums">
<span className="rounded px-1.5 py-px font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border">SPLIT</span>
<span className="text-muted-foreground">RX {fmtMHz(st?.freq_rx_hz ?? 0)}</span>
</div>
)}
{!on && (
<div className="text-[11px] text-muted-foreground truncate" title={st?.error || ''}>
{st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')}
</div>
)}
</div>
</div>
);
}
// The CW keyer. Speed is the control an operator reaches for mid-QSO — a
// station answers faster or slower than expected and the reply has to match —
// so it is here rather than only in the docked panel, and Stop is beside it
// because a message sent to the wrong callsign has to end NOW.
function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(h); };
}, []);
const on = !!st?.connected;
const wpm = st?.wpm || 0;
const step = (d: number) => {
const w = Math.max(5, Math.min(50, wpm + d));
setSt((cur: any) => ({ ...(cur ?? {}), wpm: w })); // shows at once; the poll confirms
WinkeyerSetSpeed(w).catch(() => {});
};
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Zap className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.keyer')}</div>
{st?.busy && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(-1)}>
<Minus className="size-3.5" />
</Button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-semibold tabular-nums leading-none">{wpm || '—'}</span>
<span className="text-xs text-muted-foreground">WPM</span>
</div>
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(1)}>
<Plus className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="ml-auto h-7 px-2" disabled={!on || !st?.busy}
onClick={() => WinkeyerStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{st?.port || t('station.noPort')}</span>
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
</div>
{!on && (
<Button variant="outline" size="sm" className="w-full h-7"
onClick={() => WinkeyerConnect().catch(() => {})}>
{t('station.connect')}
</Button>
)}
</div>
</div>
);
}
// The voice keyer. The messages themselves, because a card that only said
// "idle" would be a light and not a control — from here a CQ goes out without
// leaving the tab.
function VoiceKeyerCard({ t }: { t: (k: string, v?: any) => string }) {
const [st, setSt] = useState<any>({ playing: false, recording: false });
const [msgs, setMsgs] = useState<any[]>([]);
useEffect(() => {
let alive = true;
const tick = () => GetDVKStatus().then((s: any) => { if (alive) setSt(s ?? {}); }).catch(() => {});
tick();
const h = window.setInterval(tick, 1000);
// The recordings change when the operator records one, which is rare and
// never from this tab — read once, and again only on a status change worth
// it would be more machinery than it saves.
GetDVKMessages().then((m: any[]) => { if (alive) setMsgs(m ?? []); }).catch(() => {});
return () => { alive = false; window.clearInterval(h); };
}, []);
const recorded = msgs.filter((m) => m.has_audio);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Mic className="size-4 text-primary" />
<div className="text-sm font-semibold truncate">{t('station.voiceKeyer')}</div>
{st?.playing && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
{st?.recording && <span className="text-[10px] font-bold text-warning animate-pulse">REC</span>}
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2 text-[11px]"
disabled={!st?.playing} onClick={() => DVKStop().catch(() => {})}>
<Square className="size-3 mr-1" />{t('station.stop')}
</Button>
</div>
<div className="p-3">
{recorded.length === 0 ? (
<div className="text-[11px] text-muted-foreground">{t('station.noVoiceMsg')}</div>
) : (
<div className="flex flex-wrap gap-1.5">
{recorded.map((m) => (
<button key={m.slot} type="button"
onClick={() => DVKPlay(m.slot).catch(() => {})}
disabled={st?.playing}
title={`${m.duration_sec?.toFixed?.(1) ?? ''}s`}
className="rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40">
<span className="text-muted-foreground mr-1">F{m.slot}</span>
{m.label || `#${m.slot}`}
</button>
))}
</div>
)}
</div>
</div>
);
}
type Device = {
id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[];
@@ -317,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
}, [poll, pollAnt, devices.length]);
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
// Whether the two keyers exist at this station. Asked ONCE, on opening the
// tab: a keyer is bought, wired and configured, not something that appears
// mid-session, and polling for the answer would be a round trip a second for
// a fact that does not change. A keyer counts as present when it is connected
// or a port is configured for it, the voice keyer when at least one message
// has actually been recorded — an empty set of slots is not a keyer.
const [keyerShown, setKeyerShown] = useState(false);
const [dvkShown, setDvkShown] = useState(false);
useEffect(() => {
let alive = true;
GetWinkeyerStatus().then((s: any) => {
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim()));
}).catch(() => {});
GetDVKMessages().then((m: any[]) => {
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
}).catch(() => {});
return () => { alive = false; };
}, []);
// Reorder so `dragged` lands just before `target`.
const onDrop = (targetId: string) => {
const src = dragId.current; dragId.current = null;
@@ -419,6 +616,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
// The radio first: it is the station, and everything else on this page is
// something attached to it. Then the two keyers, each only when there is
// something behind it — an operator who works neither CW nor voice keyer
// should not be given two dead cards to read past.
widgets.push({ id: 'rig', node: <RigCard t={t} /> });
if (keyerShown) widgets.push({ id: 'keyer', node: <KeyerCard t={t} /> });
if (dvkShown) widgets.push({ id: 'dvk', node: <VoiceKeyerCard t={t} />, wide: true });
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
@@ -0,0 +1,102 @@
import * as React from 'react';
import { Input } from '@/components/ui/input';
// A text field that types into itself first.
//
// Preferences is one component holding two hundred pieces of state, and its
// biggest panels are eight hundred lines of form. A plain controlled input
// sends every keystroke into that state, so every character re-renders the
// whole dialog — the external-services panel, the CAT panel — and the letter
// appears after the finger has left the key.
//
// This keeps the text where it is being typed and hands it up shortly after.
// The value shown is the operator's, immediately; the parent's copy catches up
// a moment later, which is soon enough for anything that reads it — nothing in
// a settings form acts on a half-typed word.
//
// It is a drop-in for Input, on purpose: the fix is a changed import, not a
// hundred edited call sites. Which means it has to behave correctly in every
// shape those call sites take:
//
// • Blur flushes at once, so clicking Save cannot lose the last word typed,
// 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
// while the field has focus. That is how the fields which normalise as you
// type keep working: a callsign box that upper-cases, a 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.
// • Types that are not text — checkbox, colour, file — pass straight
// through. There is no typing to buffer and their events are not text.
const PASSTHROUGH = new Set(['checkbox', 'radio', 'file', 'color', 'range', 'submit', 'button', 'image', 'reset']);
// Short enough that a normalising field corrects itself while the operator is
// still on the same word, long enough that a burst of typing is one render.
const DEBOUNCE_MS = 120;
export const BufferedInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ value, onChange, onBlur, onFocus, type, ...props }, ref) => {
const buffered = value !== undefined && !!onChange && !PASSTHROUGH.has(type ?? 'text');
const incoming = String(value ?? '');
const [local, setLocal] = React.useState(incoming);
const focused = React.useRef(false);
const timer = React.useRef<number | undefined>(undefined);
// What we last handed up. Anything else arriving from the parent is the
// parent's own doing — a normalisation, a reload — and it wins.
const emitted = React.useRef(incoming);
const pending = React.useRef<React.ChangeEvent<HTMLInputElement> | null>(null);
const onChangeRef = React.useRef(onChange);
React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
React.useEffect(() => {
if (!focused.current || incoming !== emitted.current) {
setLocal(incoming);
emitted.current = incoming;
}
}, [incoming]);
const flush = React.useCallback(() => {
window.clearTimeout(timer.current);
timer.current = undefined;
const e = pending.current;
pending.current = null;
if (e) {
emitted.current = e.target.value;
onChangeRef.current?.(e);
}
}, []);
// Unmounted mid-word — the panel changed, the dialog closed — still hands
// up what was typed.
React.useEffect(() => () => {
window.clearTimeout(timer.current);
if (pending.current) onChangeRef.current?.(pending.current);
}, []);
if (!buffered) {
return <Input ref={ref} type={type} value={value} onChange={onChange} onBlur={onBlur} onFocus={onFocus} {...props} />;
}
return (
<Input
ref={ref}
type={type}
value={local}
onFocus={(e) => { focused.current = true; onFocus?.(e); }}
onChange={(e) => {
const v = e.target.value;
setLocal(v);
// The element's value changes again before the timer fires, so what
// matters is copied out of it now.
pending.current = { ...e, target: { ...e.target, value: v } } as React.ChangeEvent<HTMLInputElement>;
window.clearTimeout(timer.current);
timer.current = window.setTimeout(flush, DEBOUNCE_MS);
}}
onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }}
{...props}
/>
);
},
);
BufferedInput.displayName = 'BufferedInput';
+15 -6
View File
@@ -8,14 +8,23 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
// blur=false drops the backdrop filter and dims harder instead.
//
// A backdrop-filter over the whole window is recomputed every time anything
// above it repaints — and underneath this one sits an application that never
// stops moving: CAT polls four times a second, spots arrive, meters sweep, maps
// redraw. On a long-lived dialog with text fields in it, that shows as a delay
// between the key and the letter. Ornament is not worth a keyboard that feels
// slow, so the dialogs an operator TYPES in for minutes at a time turn it off.
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
>(({ className, blur = true, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
blur ? 'bg-stone-900/40 backdrop-blur-sm' : 'bg-stone-900/60',
className,
)}
{...props}
@@ -25,10 +34,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean; overlayBlur?: boolean }
>(({ className, children, hideClose, hideOverlay, overlayBlur, ...props }, ref) => (
<DialogPortal>
{!hideOverlay && <DialogOverlay />}
{!hideOverlay && <DialogOverlay blur={overlayBlur} />}
<DialogPrimitive.Content
ref={ref}
className={cn(
+6 -2
View File
@@ -10,7 +10,7 @@
// Stored through writeUiPref like every other portable preference, so the
// buttons travel with data/ rather than living in one browser profile.
import { writeUiPref } from '@/lib/uiPref';
import { writeUiPrefDebounced } from '@/lib/uiPref';
export type ClusterMacro = {
label: string; // what the button says
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
return out;
}
// Debounced, because this is called on every keystroke in twenty-four text
// boxes. The local cache is written at once — it is what everything reads back
// — and only the database write waits for the typing to stop. A round trip into
// Go per character is what "the letters appear after I have moved on" was.
export function saveClusterMacros(macros: ClusterMacro[]): void {
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
writeUiPrefDebounced(clusterMacrosKey, JSON.stringify(macros));
}
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
+50
View File
@@ -0,0 +1,50 @@
// Cluster nodes worth starting from.
//
// Setting up a telnet cluster is the step operators get stuck on: the host and
// the port are two pieces of information nobody has to hand, a typo produces a
// silent failure to connect, and the ports are not guessable — a Reverse Beacon
// feed on 7000 carries CW and RTTY while 7001 carries FT8 and FT4, which no
// amount of trying will tell you.
//
// So the editor offers a list. It fills the fields and then gets out of the
// way: everything stays editable, because a node moves or an operator wants a
// different name for it, and a preset that could not be corrected would be
// worse than none.
//
// The list is meant to grow. One entry per node, and nothing here is special —
// a node added by hand behaves exactly the same.
export type ClusterPreset = {
name: string;
host: string;
port: number;
// What it carries, in a few words: the dropdown is chosen from, not read, and
// "SOTA" means nothing to somebody who has never chased a summit.
about: string;
// Sent one per line after login. Empty for the nodes that need nothing.
init?: string;
};
export const CLUSTER_PRESETS: ClusterPreset[] = [
{ name: 'F4BPO', host: 'cluster.f4bpo.com', port: 7300,
about: 'General DX cluster (OpsLog authors node)' },
{ name: 'DXFun', host: 'dxfun.com', port: 8000,
about: 'General DX cluster, worldwide' },
{ name: 'F5LEN', host: 'dxcluster.f5len.org', port: 7373,
about: 'General DX cluster' },
{ name: 'F5MZN', host: 'f5mzn.org', port: 9000,
about: 'General DX cluster' },
{ name: 'KM3T', host: 'dxcc.km3t.net', port: 7373,
about: 'General DX cluster' },
{ name: 'SOTA', host: 'cluster.sota.org.uk', port: 7300,
about: 'Summits On The Air spots' },
{ name: 'POTA', host: 'pota-cluster.iz2lsc.eu', port: 7373,
about: 'Parks On The Air spots' },
// The two Reverse Beacon feeds are one network on two ports, and which port
// decides which modes arrive. Getting that wrong looks exactly like a dead
// node, so they are listed separately and named for what they carry.
{ name: 'RBN CW', host: 'telnet.reversebeacon.net', port: 7000,
about: 'Reverse Beacon Network — CW and RTTY skimmers' },
{ name: 'RBN FTx', host: 'telnet.reversebeacon.net', port: 7001,
about: 'Reverse Beacon Network — FT8 and FT4 skimmers' },
];
+174 -14
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
// Which imagery each map draws on — one choice per map.
//
// The world map and the grid-square map used to share a single key, so picking
// satellite imagery to look at grids also repainted the main map, and there was
// no way to have terrain on one and plain 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.
//
// Portable (see lib/uiPref) like the remembered views in lib/mapView: a copied
// data folder brings the choices with it.
import { writeUiPref } from '@/lib/uiPref';
import type { BasemapKey } from '@/components/MainMap';
// The keys in use. Named here rather than typed at each call site so a rename
// cannot silently orphan somebody's choice.
export const MAP_BASE_WORLD = 'opslog.mapBasemap';
export const MAP_BASE_GRIDS = 'opslog.gridMapBase';
export const MAP_BASE_FT = 'opslog.ftmapBase';
export const MAP_BASE_SAT = 'opslog.satMapBase';
const VALID = ['light', 'voyager', 'street', 'satellite'];
// loadMapBase reads one map's choice.
//
// inheritFrom exists for the split: the grid map's choice lived under the world
// map's key until they were separated, so an operator who had chosen imagery
// there keeps it instead of being silently reset to the default.
export function loadMapBase(key: string, fallback: BasemapKey, inheritFrom?: string): BasemapKey {
const read = (k: string) => {
const v = localStorage.getItem(k);
return v && VALID.includes(v) ? (v as BasemapKey) : null;
};
return read(key) ?? (inheritFrom ? read(inheritFrom) : null) ?? fallback;
}
export function saveMapBase(key: string, v: BasemapKey): void {
writeUiPref(key, v);
}
+66
View File
@@ -0,0 +1,66 @@
// The station's own rigs and antennas, for the MY_RIG and MY_ANTENNA fields.
//
// They are already defined once, in Settings ▸ Operating conditions — a station
// per rig, with the antennas hanging off it. Typing them again into every
// contact is both work and a source of spellings that do not match: "IC-7610",
// "IC 7610" and "ic7610" are three different rigs to an award, a filter and to
// anyone reading the log later.
//
// So the two fields offer what the operator has already declared. FREE TEXT
// stays allowed: a QSO 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 — the same rule the satellite-name field follows.
import { useEffect, useState } from 'react';
import { ListOperatingTree } from '../../wailsjs/go/main/App';
export type OperatingLists = {
rigs: string[];
// Every antenna in the profile, whichever rig it belongs to.
antennas: string[];
// The antennas of ONE rig. Falls back to all of them for a rig that is not in
// the tree — an operator typing a borrowed rig's name should still be offered
// their own antennas rather than nothing.
antennasFor: (rig: string) => string[];
};
const EMPTY: OperatingLists = { rigs: [], antennas: [], antennasFor: () => [] };
function build(stations: any[]): OperatingLists {
const rigs: string[] = [];
const byRig = new Map<string, string[]>();
const all = new Set<string>();
for (const st of stations ?? []) {
const name = String(st?.name ?? '').trim();
const ants = ((st?.antennas ?? []) as any[])
.map((a) => String(a?.name ?? '').trim())
.filter(Boolean);
if (name) {
rigs.push(name);
byRig.set(name.toUpperCase(), ants);
}
for (const a of ants) all.add(a);
}
const antennas = [...all];
return {
rigs,
antennas,
antennasFor: (rig: string) => byRig.get(String(rig ?? '').trim().toUpperCase()) ?? antennas,
};
}
// useOperatingLists reads the tree when the component mounts, and again whenever
// `reloadKey` changes — pass something that moves when Preferences close, so a
// rig added there is offered without a restart.
export function useOperatingLists(reloadKey?: unknown): OperatingLists {
const [lists, setLists] = useState<OperatingLists>(EMPTY);
useEffect(() => {
let live = true;
ListOperatingTree()
.then((st: any) => { if (live) setLists(build(st ?? [])); })
// An empty list simply leaves both fields as free text, which is what they
// were before they had a list at all.
.catch(() => {});
return () => { live = false; };
}, [reloadKey]);
return lists;
}
+50 -2
View File
@@ -31,11 +31,13 @@ const PORTABLE_KEYS = [
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest.
'opslog.ftMapView', 'opslog.gridMapView',
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.matrixDigiMode', // band matrix: which digital row it opens on ('' = DIGI, the group)
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
// One imagery choice per map — world, grid squares, FT map, satellites.
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
@@ -58,6 +60,8 @@ const PORTABLE_KEYS = [
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.satSideWidth', 'opslog.satSideShown', // Satellites tab: readout column width, and whether it is shown
'opslog.satSkyShown', // Satellites tab: the polar sky plot
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
@@ -90,6 +94,50 @@ export async function syncPortablePrefs(): Promise<void> {
}));
}
// writeUiPrefDebounced is writeUiPref for a value that changes AS SOMEBODY
// TYPES.
//
// The local cache is written at once, because that is what the interface reads
// back and it costs nothing. The DATABASE write is held until the typing stops:
// writeUiPref crosses into Go and writes a row, and doing that per character in
// a text box is a round trip per keystroke — twenty-four boxes of cluster
// macros was exactly that, and it showed as characters appearing after the
// finger had left the key.
//
// Pending writes are flushed when the page goes away, so a value typed and
// immediately followed by a close is not lost.
const pendingPrefs = new Map<string, { value: string; timer: number }>();
export function writeUiPrefDebounced(key: string, value: string, ms = 400): void {
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
const prev = pendingPrefs.get(key);
if (prev) window.clearTimeout(prev.timer);
const timer = window.setTimeout(() => {
pendingPrefs.delete(key);
SetUIPref(key, value).catch((e: any) => {
try { LogUIError('ui pref', 'could not store ' + key + ': ' + String(e?.message ?? e), ''); } catch { /* nothing left to try */ }
});
}, ms);
pendingPrefs.set(key, { value, timer });
}
// flushUiPrefs writes every pending value immediately.
export function flushUiPrefs(): void {
for (const [key, p] of pendingPrefs) {
window.clearTimeout(p.timer);
SetUIPref(key, p.value).catch(() => { /* the local cache still holds it */ });
}
pendingPrefs.clear();
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushUiPrefs);
// Closing the app does not always fire beforeunload in a WebView; a hidden
// page is the earlier and more reliable signal.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushUiPrefs();
});
}
// writeUiPref write-throughs a value to the local cache AND the portable DB.
// Use it everywhere these keys are written instead of localStorage.setItem.
export function writeUiPref(key: string, value: string): void {
+37
View File
@@ -1245,3 +1245,40 @@
.leaflet-container {
background: var(--card) !important;
}
/* Satellite map tooltips. Leaflet's own are a white box with a grey border
fine on a street map, a bright rectangle on a dark one, and always the wrong
colours for whichever theme the operator chose. These follow the theme, and
are wide enough for a pass: AOS, LOS, elevation and range each on their own
line. */
.leaflet-tooltip.sat-tip {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.35);
padding: 0.4rem 0.55rem;
font-size: 11px;
line-height: 1.45;
white-space: nowrap;
}
.leaflet-tooltip.sat-tip::before { border-top-color: var(--border); }
.sat-tip-name { font-weight: 600; font-size: 12px; margin-bottom: 0.2rem; }
.sat-tip-row { display: flex; justify-content: space-between; gap: 1.25rem; }
.sat-tip-row > span:first-child { color: var(--muted-foreground); }
.sat-tip-note { color: var(--muted-foreground); font-style: italic; }
/* The name beside a satellite that is up right now. A plain div marker and
not a Leaflet tooltip, because Leaflet keeps one tooltip per layer and the
hover detail is the one worth keeping. */
.sat-name-label {
pointer-events: none;
white-space: nowrap;
font-size: 10px;
font-weight: 600;
/* Painted twice a dark halo under a light glyph because the label sits on
satellite imagery, on a street map and on a dark ocean in the same session,
and no single colour is readable on all three. */
color: #fff;
text-shadow: 0 0 3px #000, 0 0 3px #000, 0 1px 2px #000;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.16';
export const APP_VERSION = '0.27.20';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+41
View File
@@ -19,6 +19,7 @@ import {pskrtgt} from '../models';
import {pskr} from '../models';
import {psu} from '../models';
import {spe} from '../models';
import {sat} from '../models';
import {solar} from '../models';
import {tunergenius} from '../models';
import {webpub} from '../models';
@@ -52,6 +53,8 @@ export function ActiveRadioMyRig():Promise<string>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AddSatelliteElements(arg1:string):Promise<number>;
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
@@ -580,6 +583,8 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorTypes():Promise<Array<main.RotatorTypeInfo>>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
@@ -588,6 +593,30 @@ export function GetRowColors():Promise<main.RowColorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
export function GetSatSettings():Promise<main.SatSettings>;
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
export function GetSatelliteNames():Promise<Array<string>>;
export function GetSatelliteNextPass(arg1:string):Promise<main.SatPassInfo>;
export function GetSatelliteObserver():Promise<Record<string, any>>;
export function GetSatellitePasses(arg1:Array<string>,arg2:number):Promise<Array<sat.Pass>>;
export function GetSatellitePositions(arg1:Array<string>):Promise<Array<sat.Position>>;
export function GetSatelliteSkyTrack(arg1:string,arg2:number):Promise<Array<main.SatSkyPoint>>;
export function GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>;
@@ -804,6 +833,8 @@ export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
export function ListRadios():Promise<Array<main.RadioListEntry>>;
export function ListSatelliteRotors():Promise<Array<main.SatelliteRotorChoice>>;
export function ListSerialPorts():Promise<Array<string>>;
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
@@ -974,6 +1005,8 @@ export function RefreshDXpeditions():Promise<void>;
export function RefreshKenwood():Promise<void>;
export function RefreshSatelliteTLE():Promise<main.SatTLEInfo>;
export function RefreshSolar():Promise<void>;
export function RefreshYaesuPanel():Promise<void>;
@@ -1116,6 +1149,8 @@ export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
export function SaveSatSettings(arg1:main.SatSettings):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
export function SaveSpotColors(arg1:main.SpotColors):Promise<void>;
@@ -1356,10 +1391,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StartSatelliteTracking(arg1:string,arg2:number):Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function StopSatelliteTracking():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncFolderNow():Promise<number>;
@@ -1400,6 +1439,8 @@ export function TestQRZUpload():Promise<string>;
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
export function TestSatelliteRotator():Promise<string>;
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
+80
View File
@@ -38,6 +38,10 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function AddSatelliteElements(arg1) {
return window['go']['main']['App']['AddSatelliteElements'](arg1);
}
export function AmpFanMode(arg1, arg2) {
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
}
@@ -1094,6 +1098,10 @@ export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
export function GetRotatorTypes() {
return window['go']['main']['App']['GetRotatorTypes']();
}
export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
@@ -1110,6 +1118,54 @@ export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus']();
}
export function GetSatSettings() {
return window['go']['main']['App']['GetSatSettings']();
}
export function GetSatelliteBirds() {
return window['go']['main']['App']['GetSatelliteBirds']();
}
export function GetSatelliteGroundTrack(arg1, arg2) {
return window['go']['main']['App']['GetSatelliteGroundTrack'](arg1, arg2);
}
export function GetSatelliteNames() {
return window['go']['main']['App']['GetSatelliteNames']();
}
export function GetSatelliteNextPass(arg1) {
return window['go']['main']['App']['GetSatelliteNextPass'](arg1);
}
export function GetSatelliteObserver() {
return window['go']['main']['App']['GetSatelliteObserver']();
}
export function GetSatellitePasses(arg1, arg2) {
return window['go']['main']['App']['GetSatellitePasses'](arg1, arg2);
}
export function GetSatellitePositions(arg1) {
return window['go']['main']['App']['GetSatellitePositions'](arg1);
}
export function GetSatelliteSkyTrack(arg1, arg2) {
return window['go']['main']['App']['GetSatelliteSkyTrack'](arg1, arg2);
}
export function GetSatelliteTLEInfo() {
return window['go']['main']['App']['GetSatelliteTLEInfo']();
}
export function GetSatelliteTracking() {
return window['go']['main']['App']['GetSatelliteTracking']();
}
export function GetSatelliteTuning(arg1, arg2, arg3) {
return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3);
}
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
@@ -1542,6 +1598,10 @@ export function ListRadios() {
return window['go']['main']['App']['ListRadios']();
}
export function ListSatelliteRotors() {
return window['go']['main']['App']['ListSatelliteRotors']();
}
export function ListSerialPorts() {
return window['go']['main']['App']['ListSerialPorts']();
}
@@ -1882,6 +1942,10 @@ export function RefreshKenwood() {
return window['go']['main']['App']['RefreshKenwood']();
}
export function RefreshSatelliteTLE() {
return window['go']['main']['App']['RefreshSatelliteTLE']();
}
export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar']();
}
@@ -2166,6 +2230,10 @@ export function SaveRowColors(arg1) {
return window['go']['main']['App']['SaveRowColors'](arg1);
}
export function SaveSatSettings(arg1) {
return window['go']['main']['App']['SaveSatSettings'](arg1);
}
export function SaveSelfSpotSettings(arg1) {
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
}
@@ -2646,6 +2714,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StartSatelliteTracking(arg1, arg2) {
return window['go']['main']['App']['StartSatelliteTracking'](arg1, arg2);
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
@@ -2654,6 +2726,10 @@ export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function StopSatelliteTracking() {
return window['go']['main']['App']['StopSatelliteTracking']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
@@ -2734,6 +2810,10 @@ export function TestRotatorDevice(arg1, arg2) {
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
}
export function TestSatelliteRotator() {
return window['go']['main']['App']['TestSatelliteRotator']();
}
export function TestStationDevice(arg1) {
return window['go']['main']['App']['TestStationDevice'](arg1);
}
+502
View File
@@ -2360,6 +2360,7 @@ export namespace main {
backend: string;
omnirig_rig: number;
omnirig_vfo: string;
omnirig_cw_lower: boolean;
digi_as_usb: boolean;
flex_host: string;
flex_port: number;
@@ -2414,6 +2415,7 @@ export namespace main {
this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"];
this.omnirig_cw_lower = source["omnirig_cw_lower"];
this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
@@ -3879,6 +3881,7 @@ export namespace main {
com_port: string;
baud: number;
spid_model?: string;
max_az?: number;
static createFrom(source: any = {}) {
return new RotatorDevice(source);
@@ -3901,6 +3904,7 @@ export namespace main {
this.com_port = source["com_port"];
this.baud = source["baud"];
this.spid_model = source["spid_model"];
this.max_az = source["max_az"];
}
}
export class RotatorHeading {
@@ -3908,6 +3912,8 @@ export namespace main {
ok: boolean;
azimuth: number;
raw: string;
elevation: number;
has_elevation: boolean;
rotors: string[];
active: number;
motorized: boolean;
@@ -3922,11 +3928,39 @@ export namespace main {
this.ok = source["ok"];
this.azimuth = source["azimuth"];
this.raw = source["raw"];
this.elevation = source["elevation"];
this.has_elevation = source["has_elevation"];
this.rotors = source["rotors"];
this.active = source["active"];
this.motorized = source["motorized"];
}
}
export class RotatorTypeInfo {
id: string;
label: string;
elevation: boolean;
elevation_optional: boolean;
serial: boolean;
network: boolean;
default_port: number;
default_baud: number;
static createFrom(source: any = {}) {
return new RotatorTypeInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.label = source["label"];
this.elevation = source["elevation"];
this.elevation_optional = source["elevation_optional"];
this.serial = source["serial"];
this.network = source["network"];
this.default_port = source["default_port"];
this.default_baud = source["default_baud"];
}
}
export class RotorPreset {
label: string;
azimuth: number;
@@ -4003,6 +4037,371 @@ export namespace main {
return a;
}
}
export class SatTransponder {
label: string;
mode: string;
down_lo: number;
down_hi: number;
up_lo: number;
up_hi: number;
inverting: boolean;
ctcss: number;
linear: boolean;
static createFrom(source: any = {}) {
return new SatTransponder(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.mode = source["mode"];
this.down_lo = source["down_lo"];
this.down_hi = source["down_hi"];
this.up_lo = source["up_lo"];
this.up_hi = source["up_hi"];
this.inverting = source["inverting"];
this.ctcss = source["ctcss"];
this.linear = source["linear"];
}
}
export class SatBird {
name: string;
norad: number;
geostationary: boolean;
favorite: boolean;
has_elements: boolean;
element_name: string;
epoch_age_h: number;
transponders: SatTransponder[];
static createFrom(source: any = {}) {
return new SatBird(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.norad = source["norad"];
this.geostationary = source["geostationary"];
this.favorite = source["favorite"];
this.has_elements = source["has_elements"];
this.element_name = source["element_name"];
this.epoch_age_h = source["epoch_age_h"];
this.transponders = this.convertValues(source["transponders"], SatTransponder);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatPassInfo {
name: string;
has_pass: boolean;
in_pass: boolean;
// Go type: time
aos: any;
// Go type: time
los: any;
aos_az: number;
los_az: number;
max_el: number;
max_el_az: number;
// Go type: time
max_el_at: any;
duration_s: number;
static createFrom(source: any = {}) {
return new SatPassInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.has_pass = source["has_pass"];
this.in_pass = source["in_pass"];
this.aos = this.convertValues(source["aos"], null);
this.los = this.convertValues(source["los"], null);
this.aos_az = source["aos_az"];
this.los_az = source["los_az"];
this.max_el = source["max_el"];
this.max_el_az = source["max_el_az"];
this.max_el_at = this.convertValues(source["max_el_at"], null);
this.duration_s = source["duration_s"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatSettings {
favorites: string[];
min_el: number;
window_h: number;
auto_tle: boolean;
grid: string;
alt_m: number;
rot_on: boolean;
rot_id: string;
rot_az_only: boolean;
rot_min_el: number;
rot_step: number;
rot_park: boolean;
static createFrom(source: any = {}) {
return new SatSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.favorites = source["favorites"];
this.min_el = source["min_el"];
this.window_h = source["window_h"];
this.auto_tle = source["auto_tle"];
this.grid = source["grid"];
this.alt_m = source["alt_m"];
this.rot_on = source["rot_on"];
this.rot_id = source["rot_id"];
this.rot_az_only = source["rot_az_only"];
this.rot_min_el = source["rot_min_el"];
this.rot_step = source["rot_step"];
this.rot_park = source["rot_park"];
}
}
export class SatSkyPoint {
// Go type: time
at: any;
az: number;
el: number;
static createFrom(source: any = {}) {
return new SatSkyPoint(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.at = this.convertValues(source["at"], null);
this.az = source["az"];
this.el = source["el"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatTLEInfo {
count: number;
// Go type: time
fetched_at: any;
age_h: number;
stale: boolean;
custom: number;
static createFrom(source: any = {}) {
return new SatTLEInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.fetched_at = this.convertValues(source["fetched_at"], null);
this.age_h = source["age_h"];
this.stale = source["stale"];
this.custom = source["custom"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatTrackStatus {
on: boolean;
name: string;
transponder: string;
mode: string;
nominal_down: number;
nominal_up: number;
down_hz: number;
up_hz: number;
az: number;
el: number;
visible: boolean;
radio: string;
error: string;
rot_on: boolean;
rot_az: number;
rot_el: number;
rot_live: boolean;
rot_az_only: boolean;
static createFrom(source: any = {}) {
return new SatTrackStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.on = source["on"];
this.name = source["name"];
this.transponder = source["transponder"];
this.mode = source["mode"];
this.nominal_down = source["nominal_down"];
this.nominal_up = source["nominal_up"];
this.down_hz = source["down_hz"];
this.up_hz = source["up_hz"];
this.az = source["az"];
this.el = source["el"];
this.visible = source["visible"];
this.radio = source["radio"];
this.error = source["error"];
this.rot_on = source["rot_on"];
this.rot_az = source["rot_az"];
this.rot_el = source["rot_el"];
this.rot_live = source["rot_live"];
this.rot_az_only = source["rot_az_only"];
}
}
export class SatTuning {
name: string;
transponder: string;
mode: string;
nominal_down: number;
nominal_up: number;
down_hz: number;
up_hz: number;
ctcss: number;
inverting: boolean;
az: number;
el: number;
range_km: number;
range_rate: number;
visible: boolean;
// Go type: time
at: any;
lat: number;
lon: number;
alt_km: number;
footprint_km: number;
static createFrom(source: any = {}) {
return new SatTuning(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.transponder = source["transponder"];
this.mode = source["mode"];
this.nominal_down = source["nominal_down"];
this.nominal_up = source["nominal_up"];
this.down_hz = source["down_hz"];
this.up_hz = source["up_hz"];
this.ctcss = source["ctcss"];
this.inverting = source["inverting"];
this.az = source["az"];
this.el = source["el"];
this.range_km = source["range_km"];
this.range_rate = source["range_rate"];
this.visible = source["visible"];
this.at = this.convertValues(source["at"], null);
this.lat = source["lat"];
this.lon = source["lon"];
this.alt_km = source["alt_km"];
this.footprint_km = source["footprint_km"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatelliteRotorChoice {
key: string;
name: string;
type: string;
has_el: boolean;
static createFrom(source: any = {}) {
return new SatelliteRotorChoice(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.name = source["name"];
this.type = source["type"];
this.has_el = source["has_el"];
}
}
export class ScpStatus {
enabled: boolean;
count: number;
@@ -6073,6 +6472,109 @@ export namespace qso {
}
export namespace sat {
export class Pass {
name: string;
// Go type: time
aos: any;
// Go type: time
los: any;
aos_az: number;
los_az: number;
max_el: number;
max_el_az: number;
// Go type: time
max_el_at: any;
duration_s: number;
static createFrom(source: any = {}) {
return new Pass(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.aos = this.convertValues(source["aos"], null);
this.los = this.convertValues(source["los"], null);
this.aos_az = source["aos_az"];
this.los_az = source["los_az"];
this.max_el = source["max_el"];
this.max_el_az = source["max_el_az"];
this.max_el_at = this.convertValues(source["max_el_at"], null);
this.duration_s = source["duration_s"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Position {
name: string;
// Go type: time
at: any;
lat: number;
lon: number;
alt_km: number;
footprint_km: number;
az: number;
el: number;
range_km: number;
range_rate: number;
static createFrom(source: any = {}) {
return new Position(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.at = this.convertValues(source["at"], null);
this.lat = source["lat"];
this.lon = source["lon"];
this.alt_km = source["alt_km"];
this.footprint_km = source["footprint_km"];
this.az = source["az"];
this.el = source["el"];
this.range_km = source["range_km"];
this.range_rate = source["range_rate"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace scp {
export class Result {
+2
View File
@@ -3,11 +3,13 @@ module hamlog
go 1.25.0
require (
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb
github.com/braheezy/shine-mp3 v0.1.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/jfreymuth/pulse v0.1.3
github.com/jlaffaye/ftp v0.2.2
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0
+4
View File
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb h1:d9tZ7tJrssgs7Va9j8iu9vl7BlK2rmIs5RiOU7WQJrs=
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb/go.mod h1:JfAepWD223Cel6uRpzYdip/xijWZ2FT457YFLWy8Md4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
@@ -27,6 +29,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8=
github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+39
View File
@@ -0,0 +1,39 @@
package audio
// Device is one audio endpoint (a capture input or a render output).
//
// ID is whatever the platform calls the endpoint and is PERSISTED in settings:
// a WASAPI endpoint id on Windows, a PulseAudio source/sink name on Linux.
// It is opaque to everything above this package, which only ever hands it back.
type Device struct {
ID string `json:"id"` // opaque platform endpoint id (persisted)
Name string `json:"name"` // friendly name shown in dropdowns
Default bool `json:"default"` // is this the system default endpoint
}
// DeviceName resolves an endpoint id to its friendly name.
//
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
// straight at the DAX panel.
//
// Falls back to the id when the endpoint cannot be found, which is itself worth
// seeing: a device that has disappeared explains an empty recording too.
func DeviceName(id string) string {
if id == "" {
return "(none)"
}
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
devs, err := list()
if err != nil {
continue
}
for _, d := range devs {
if d.ID == id {
return d.Name
}
}
}
return id
}
-34
View File
@@ -15,13 +15,6 @@ import (
"github.com/moutend/go-wca/pkg/wca"
)
// Device is one audio endpoint (a capture input or a render output).
type Device struct {
ID string `json:"id"` // stable WASAPI endpoint id (persisted)
Name string `json:"name"` // friendly name shown in dropdowns
Default bool `json:"default"` // is this the system default endpoint
}
// ListInputDevices returns the active capture endpoints — microphones,
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
@@ -101,30 +94,3 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
}
return fallback
}
// DeviceName resolves an endpoint id to its friendly name.
//
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
// straight at the DAX panel.
//
// Falls back to the id when the endpoint cannot be found, which is itself worth
// seeing: a device that has disappeared explains an empty recording too.
func DeviceName(id string) string {
if id == "" {
return "(none)"
}
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
devs, err := list()
if err != nil {
continue
}
for _, d := range devs {
if d.ID == id {
return d.Name
}
}
}
return id
}
+99
View File
@@ -0,0 +1,99 @@
//go:build linux
package audio
// devices_linux.go — audio endpoints on Linux, through PulseAudio.
//
// PulseAudio and not ALSA, for two reasons that both matter here. ALSA's C
// library needs cgo, and OpsLog is a pure-Go build; and PulseAudio is the API
// that is actually present on a ham's desktop — PipeWire, which most current
// distributions ship, answers the PulseAudio protocol through pipewire-pulse,
// so one client speaks to both. github.com/jfreymuth/pulse implements that
// protocol in Go over the server's Unix socket, so nothing is linked in.
//
// The endpoint id we persist is the sink/source NAME
// ("alsa_input.usb-Icom_Inc._IC-7610-00.analog-stereo"), never the numeric
// index: the index is assigned at boot in device-arrival order and moves the
// moment a rig is plugged in before a headset.
import (
"fmt"
"strings"
"github.com/jfreymuth/pulse"
)
// pulseClient opens a short-lived connection to the local sound server. Each
// call gets its own: the connection is a Unix socket to a server that may be
// restarted underneath us (a PipeWire update, a user logging the session out
// and in), and holding one open for the lifetime of the app means every later
// call fails until OpsLog itself restarts.
func pulseClient() (*pulse.Client, error) {
c, err := pulse.NewClient(pulse.ClientApplicationName("OpsLog"))
if err != nil {
return nil, fmt.Errorf("cannot reach the sound server (is PulseAudio or PipeWire running?): %w", err)
}
return c, nil
}
// ListInputDevices returns the capture sources.
//
// Monitor sources (".monitor", what a given output is playing) are kept rather
// than filtered out. They look like clutter until you meet the operator whose
// rig audio reaches OpsLog through a virtual cable — on Linux that is a
// null-sink and its monitor, and hiding it would hide the only device that
// works for them.
func ListInputDevices() ([]Device, error) {
c, err := pulseClient()
if err != nil {
return nil, err
}
defer c.Close()
srcs, err := c.ListSources()
if err != nil {
return nil, err
}
defID := ""
if d, err := c.DefaultSource(); err == nil && d != nil {
defID = d.ID()
}
out := make([]Device, 0, len(srcs))
for _, s := range srcs {
out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID})
}
return out, nil
}
// ListOutputDevices returns the render sinks.
func ListOutputDevices() ([]Device, error) {
c, err := pulseClient()
if err != nil {
return nil, err
}
defer c.Close()
sinks, err := c.ListSinks()
if err != nil {
return nil, err
}
defID := ""
if d, err := c.DefaultSink(); err == nil && d != nil {
defID = d.ID()
}
out := make([]Device, 0, len(sinks))
for _, s := range sinks {
out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID})
}
return out, nil
}
// endpointLabel prefers the server's human description ("USB Audio CODEC
// Analog Stereo") and falls back to the raw name, which is ugly but still
// identifies the device — an empty entry in the dropdown identifies nothing.
func endpointLabel(desc, id string) string {
if d := strings.TrimSpace(desc); d != "" {
return d
}
return id
}
-58
View File
@@ -5,7 +5,6 @@ package audio
import (
"fmt"
"runtime"
"sync"
"time"
"unsafe"
@@ -281,63 +280,6 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
}
}
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
// render stream. Producers (a USB-codec capture, or a decoded network audio
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
// without touching the render side, mirroring the civTransport split on the CAT
// side. On overflow the oldest audio is dropped to keep latency bounded; on
// underrun Pull simply returns short and the render loop pads with silence.
type pcmRing struct {
mu sync.Mutex
buf []byte
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
}
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
func newPCMRing(maxBytes int) *pcmRing {
if maxBytes <= 0 {
maxBytes = bytesPerSec // 1 s default
}
return &pcmRing{max: maxBytes}
}
// Push appends samples, dropping the oldest audio if the backlog would exceed
// the cap (a slow/absent consumer never makes the producer block or grow without
// bound). A short glitch beats runaway latency for live monitoring.
func (r *pcmRing) Push(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
r.mu.Unlock()
}
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
// when empty. The render loop pads any shortfall with silence.
func (r *pcmRing) pull(maxBytes int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buf) == 0 || maxBytes <= 0 {
return nil
}
n := maxBytes
if n > len(r.buf) {
n = len(r.buf)
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = append(r.buf[:0], r.buf[n:]...)
return out
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the WASAPI clock steady so live
+275
View File
@@ -0,0 +1,275 @@
//go:build linux
package audio
// engine_linux.go — the four calls the rest of the package makes into the sound
// card, implemented on PulseAudio. The Windows half of this pair is engine.go
// (WASAPI); nothing above these functions knows which one it is talking to.
//
// Capture is fixed at 16 kHz mono 16-bit, the format the DVK, the recorder and
// the CW tap all share (see wav.go). We ask the server for it and let the
// server resample from whatever the device really runs at — the same division
// of labour as WASAPI's AUTOCONVERTPCM, and for the same reason: a rig codec
// that only does 48 kHz must still feed a 16 kHz pipeline, and the sound
// server's converter filters before it decimates, where a naive one folds the
// receiver hiss above 8 kHz straight back on top of the voice.
import (
"fmt"
"io"
"time"
"github.com/jfreymuth/pulse"
"github.com/jfreymuth/pulse/proto"
)
// chunkFrames is how much audio a playback reader hands over at once (20 ms).
// It bounds how much silence a padded underrun can queue ahead of real audio,
// which is what keeps live monitoring from drifting seconds behind the rig.
const chunkFrames = sampleRate / 50
// channelMap describes n channels to the server. Only mono and stereo occur
// here — capture is always mono, and playback follows the WAV being played.
func channelMap(n int) proto.ChannelMap {
if n >= 2 {
return proto.ChannelMap{proto.ChannelLeft, proto.ChannelRight}
}
return proto.ChannelMap{proto.ChannelMono}
}
// chunkWriter turns the record stream's byte deliveries into onChunk calls.
// The server reuses its buffer between deliveries, so every chunk is copied
// before it leaves: the recorder keeps the slices it is given.
type chunkWriter struct{ onChunk func([]byte) }
func (w chunkWriter) Write(p []byte) (int, error) {
if len(p) > 0 && w.onChunk != nil {
cp := make([]byte, len(p))
copy(cp, p)
w.onChunk(cp)
}
return len(p), nil
}
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed.
func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) {
out := make([]byte, 0, bytesPerSec*4)
err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) })
return out, err
}
// captureStream opens a device and calls onChunk with freshly-captured 16 kHz
// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private
// copy it may retain.
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
c, err := pulseClient()
if err != nil {
return err
}
defer c.Close()
// Rate and channels first, then latency — the latency option sizes its
// buffer from both, so setting it earlier would size it from the defaults.
//
// 50 ms of fragment: the CW decoder is downstream of this and works on the
// chunks as they arrive, so a server-chosen fragment of a quarter of a
// second would make it decide about a dit long after the dit was over.
opts := []pulse.RecordOption{
pulse.RecordSampleRate(sampleRate),
pulse.RecordChannels(channelMap(channels)),
pulse.RecordLatency(0.05),
pulse.RecordMediaName("OpsLog capture"),
}
// An empty id means "whatever the desktop calls the default", which is also
// what an operator who has never opened the audio settings expects.
if deviceID != "" {
src, err := c.SourceByID(deviceID)
if err != nil {
return fmt.Errorf("no audio input %q: %w", deviceID, err)
}
opts = append(opts, pulse.RecordSource(src))
}
st, err := c.NewRecord(pulse.NewWriter(chunkWriter{onChunk}, proto.FormatInt16LE), opts...)
if err != nil {
return fmt.Errorf("open capture: %w", err)
}
defer st.Close()
st.Start()
<-stop
st.Stop()
return st.Error()
}
// playPCM plays a fixed buffer to a device and returns when it has been heard
// (or when stop closes, which cuts it short).
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if len(pcm) == 0 {
return nil
}
format, err := pulseFormat(bits)
if err != nil {
return err
}
frameBytes := ch * bits / 8
if frameBytes <= 0 || rate <= 0 {
return fmt.Errorf("bad audio format")
}
c, err := pulseClient()
if err != nil {
return err
}
defer c.Close()
// The reader hands out the buffer a slice at a time and ends the stream
// with EndOfData — the library's own sentinel. io.EOF would work as an end
// too, but it is recorded as the stream's error, and a message finishing
// normally must not look like a fault in the log.
pos := 0
read := func(buf []byte) (int, error) {
select {
case <-stop:
return 0, pulse.EndOfData
default:
}
if pos >= len(pcm) {
return 0, pulse.EndOfData
}
n := copy(buf, pcm[pos:])
n -= n % frameBytes // never hand the server a partial frame
if n == 0 {
return 0, pulse.EndOfData
}
pos += n
return n, nil
}
opts := []pulse.PlaybackOption{
pulse.PlaybackSampleRate(rate),
pulse.PlaybackChannels(channelMap(ch)),
pulse.PlaybackLatency(0.1),
pulse.PlaybackMediaName("OpsLog playback"),
}
if deviceID != "" {
sink, err := c.SinkByID(deviceID)
if err != nil {
return fmt.Errorf("no audio output %q: %w", deviceID, err)
}
opts = append(opts, pulse.PlaybackSink(sink))
}
st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...)
if err != nil {
return fmt.Errorf("open playback: %w", err)
}
defer st.Close()
st.Start()
// Drain blocks until the server has played everything queued. Waiting on it
// in a goroutine keeps stop responsive: a voice message must cut off the
// instant the operator unkeys, not at the end of the buffer.
drained := make(chan struct{})
go func() { st.Drain(); close(drained) }()
select {
case <-drained:
case <-stop:
st.Stop()
}
return st.Error()
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the server's clock steady so
// live monitor audio flows smoothly even when the source stalls briefly.
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
format, err := pulseFormat(bits)
if err != nil {
return err
}
frameBytes := ch * bits / 8
if frameBytes <= 0 || rate <= 0 || src == nil {
return fmt.Errorf("bad audio format")
}
c, err := pulseClient()
if err != nil {
return err
}
defer c.Close()
// Never return 0 bytes without an error: the library's playback loop would
// spin on it. A stalled source therefore yields silence, which is also the
// behaviour that keeps the clock running.
chunk := chunkFrames * frameBytes
read := func(buf []byte) (int, error) {
select {
case <-stop:
return 0, pulse.EndOfData
default:
}
n := len(buf)
if n > chunk {
n = chunk
}
n -= n % frameBytes
if n == 0 {
n = frameBytes
}
got := copy(buf[:n], src.pull(n))
for i := got; i < n; i++ {
buf[i] = 0
}
return n, nil
}
opts := []pulse.PlaybackOption{
pulse.PlaybackSampleRate(rate),
pulse.PlaybackChannels(channelMap(ch)),
pulse.PlaybackLatency(0.1),
pulse.PlaybackMediaName("OpsLog monitor"),
}
if deviceID != "" {
sink, err := c.SinkByID(deviceID)
if err != nil {
return fmt.Errorf("no audio output %q: %w", deviceID, err)
}
opts = append(opts, pulse.PlaybackSink(sink))
}
st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...)
if err != nil {
return fmt.Errorf("open monitor: %w", err)
}
defer st.Close()
st.Start()
<-stop
st.Stop()
// Give the server a moment to notice the stream stopped before the client
// socket goes away, so the last fragment is heard instead of clipped.
time.Sleep(20 * time.Millisecond)
return st.Error()
}
// pulseFormat maps a WAV bit depth onto the server's sample formats. 8 and 16
// bit cover everything OpsLog produces or reads; anything else is refused by
// name rather than played as noise.
func pulseFormat(bits int) (byte, error) {
switch bits {
case 8:
return proto.FormatUint8, nil
case 16:
return proto.FormatInt16LE, nil
default:
return 0, fmt.Errorf("unsupported sample size %d-bit (8 or 16 expected)", bits)
}
}
// readerFunc adapts a read closure to io.Reader.
type readerFunc func([]byte) (int, error)
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
var _ io.Reader = readerFunc(nil)
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package audio
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package audio
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package audio
import (
+60
View File
@@ -0,0 +1,60 @@
package audio
import "sync"
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
// render stream. Producers (a USB-codec capture, or a decoded network audio
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
// without touching the render side, mirroring the civTransport split on the CAT
// side. On overflow the oldest audio is dropped to keep latency bounded; on
// underrun Pull simply returns short and the render loop pads with silence.
type pcmRing struct {
mu sync.Mutex
buf []byte
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
}
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
func newPCMRing(maxBytes int) *pcmRing {
if maxBytes <= 0 {
maxBytes = bytesPerSec // 1 s default
}
return &pcmRing{max: maxBytes}
}
// Push appends samples, dropping the oldest audio if the backlog would exceed
// the cap (a slow/absent consumer never makes the producer block or grow without
// bound). A short glitch beats runaway latency for live monitoring.
func (r *pcmRing) Push(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
r.mu.Unlock()
}
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
// when empty. The render loop pads any shortfall with silence.
func (r *pcmRing) pull(maxBytes int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buf) == 0 || maxBytes <= 0 {
return nil
}
n := maxBytes
if n > len(r.buf) {
n = len(r.buf)
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = append(r.buf[:0], r.buf[n:]...)
return out
}
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package audio
import (
+61
View File
@@ -832,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
})
}
// SatTuner is a backend that can be put on a satellite: a receiver on one band
// and a transmitter on another, both moving under Doppler, at the same time.
//
// It is a separate interface from the per-manufacturer ones because what a
// satellite needs is not a manufacturer's feature — it is a shape of operating
// that a FlexRadio and an IC-9700 both provide and reach in completely
// different ways. A backend that cannot do it simply does not implement this,
// and the caller falls back to tuning the downlink alone rather than pretending.
type SatTuner interface {
// SetSatellite arms or disarms satellite operation: the rig's own satellite
// mode where it has one, two slices where it has those. Disarming must leave
// the radio somewhere an operator can work from, not half-configured.
SetSatellite(on bool) error
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
// an empty one leaves that side's mode alone.
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
// SatReceiveHz is where the receiver actually is. The operator tunes it to
// follow a station across a linear transponder, and that dial movement is
// the input the whole tracker works from — without reading it back, a
// tracker fights the operator instead of helping them.
SatReceiveHz() (int64, error)
}
// SatCapable reports whether the active backend can hold a satellite pair.
func (m *Manager) SatCapable() bool {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
_, ok := b.(SatTuner)
return ok
}
// SatDo dispatches a satellite control onto the CAT goroutine.
func (m *Manager) SatDo(fn func(SatTuner) error) error {
return m.exec(func(b Backend) error {
st, ok := b.(SatTuner)
if !ok {
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
}
return fn(st)
})
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
@@ -1101,6 +1145,23 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
})
}
// OmniRigController is the handful of OmniRig preferences that can be changed
// without dropping the rig link.
type OmniRigController interface {
SetCWLower(bool) // which of OmniRig's two CW bits means plain CW
}
// OmniRigDo dispatches an OmniRig preference onto the CAT goroutine.
func (m *Manager) OmniRigDo(fn func(OmniRigController) error) error {
return m.exec(func(b Backend) error {
oc, ok := b.(OmniRigController)
if !ok {
return fmt.Errorf("active CAT backend is not OmniRig")
}
return fn(oc)
})
}
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
// so a K3 can key CW through its single CAT link instead of a second COM port.
type KenwoodController interface {
+16
View File
@@ -49,6 +49,11 @@ const (
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
// CmdVFO selects which receiver subsequent commands address. On the two-band
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
// band the uplink, so every satellite frequency set is "point at a band, then
// tune it".
CmdVFO = 0x07
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
@@ -112,6 +117,17 @@ const (
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
// two dials linked the way the radio's designers meant. Asking it to do that
// is always better than building the same thing out of split.
SubSwSatellite = 0x5A
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
// addresses.
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
SubVFOSub = 0xD1 // SUB band — the uplink
SubVFOExchange = 0xB0 // swap main and sub
)
// CW break-in modes (CmdSwitch 0x47).
+20 -3
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import (
@@ -67,6 +65,14 @@ type Flex struct {
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
// one it will close again.
satOn bool
satRX int
satTX int
satCreatedTX bool
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
@@ -227,7 +233,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -458,12 +464,23 @@ func (f *Flex) reader(conn net.Conn) {
if splitSeq {
delete(f.pendingSplit, seq)
}
// The same reply carries the index of a slice created for a satellite
// pair; which of the two it is was recorded when it was asked for.
satRole := f.pendingSat[seq]
if satRole != "" {
delete(f.pendingSat, seq)
}
f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
if satRole != "" && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.adoptSatSlice(satRole, idx)
}
}
}
}
// Connection ended.
+1 -5
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import (
@@ -9,8 +7,6 @@ import (
"strconv"
"syscall"
"time"
"golang.org/x/sys/windows"
)
// FlexRadio is one radio found by discovery.
@@ -38,7 +34,7 @@ func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
Control: func(_, _ string, c syscall.RawConn) error {
var serr error
_ = c.Control(func(fd uintptr) {
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
serr = setSocketReuse(fd)
})
return serr
},
+202
View File
@@ -0,0 +1,202 @@
package cat
import (
"fmt"
"strings"
"hamlog/internal/applog"
)
// Satellite operation on a FlexRadio.
//
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
// is the downlink and slice B the uplink — the arrangement every Flex satellite
// operator already uses by hand — with the transmitter on B and full duplex on,
// so the operator hears their own signal come back through the transponder.
// The transverters that put 145 and 435 MHz within the radio's reach are
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
// sends the real satellite frequency and SmartSDR does the arithmetic.
//
// The two slices are CREATED when they are missing, because "slice B does not
// exist" is not a thing to make the operator fix at the start of a ten-minute
// pass. Only what OpsLog created is taken away again on disarming: a slice the
// operator opened is theirs.
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
func (f *Flex) SetSatellite(on bool) error {
f.mu.Lock()
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !on {
return f.satDisarm()
}
// The downlink slice is the one the operator is already on: taking the
// active slice rather than insisting on index 0 means arming the satellite
// does not move them off the receiver they were listening to.
f.mu.Lock()
rxIdx, _ := f.mainSliceLocked()
var txIdx = -1
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
txIdx = idx
break
}
}
f.satRX, f.satTX = rxIdx, txIdx
f.satOn = true
f.mu.Unlock()
// Full duplex before anything else: without it the radio mutes the receiver
// on transmit, and an operator who cannot hear their own downlink has no way
// to know they are in the passband at all.
f.send("radio set full_duplex_enabled=1")
if rxIdx < 0 {
// A radio with no slice at all. One is created; the status that comes
// back adopts it as the downlink.
f.satCreate("rx", 145.900, "USB")
}
if txIdx < 0 {
f.satCreate("tx", 435.100, "USB")
} else {
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
}
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
return nil
}
func (f *Flex) satDisarm() error {
f.mu.Lock()
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
f.mu.Unlock()
f.send("radio set full_duplex_enabled=0")
if created && tx >= 0 {
f.send(fmt.Sprintf("slice remove %d", tx))
}
// Transmit goes back where the operator is listening. A radio left
// transmitting on a slice that no longer exists — or on the uplink band with
// the satellite gone — is not somewhere anyone should be handed back.
if rx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rx))
}
applog.Printf("flex: satellite disarmed")
return nil
}
// satCreate asks for a slice and remembers what it is for; the index arrives in
// the reply (see the R-line handler), which is where the role is applied.
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
if seq <= 0 {
return
}
f.mu.Lock()
if f.pendingSat == nil {
f.pendingSat = map[int]string{}
}
f.pendingSat[seq] = role
f.mu.Unlock()
}
// adoptSatSlice records a freshly created slice in its role. Called from the
// reply handler with the index the radio assigned.
func (f *Flex) adoptSatSlice(role string, idx int) {
f.mu.Lock()
switch role {
case "rx":
f.satRX = idx
case "tx":
f.satTX = idx
f.satCreatedTX = true
}
f.mu.Unlock()
if role == "tx" {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
applog.Printf("flex: satellite %s slice is %d", role, idx)
}
// TuneSatellite moves the two slices.
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
f.mu.Lock()
rx, tx := f.satRX, f.satTX
connected := f.conn != nil
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
}
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
f.slices[tx].freqHz = upHz
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if rx < 0 {
// The slice was asked for and its index has not come back yet. Nothing is
// wrong — the next Doppler step, a second later, will find it.
return nil
}
if downHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
f.satMode(rx, downMode, downHz)
}
if tx >= 0 && upHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
f.satMode(tx, upMode, upHz)
}
return nil
}
// satMode sets a slice's mode only when it is not already there. A mode command
// on every Doppler step is a command a second per slice for a whole pass, and
// SmartSDR redraws the filter each time.
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
mode = strings.TrimSpace(mode)
if mode == "" {
return
}
// USB on both sides above 30 MHz, which is every satellite worth the name —
// including the parts of a passband that would be an LSB band down on HF.
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
mode = "USB"
}
fm := adifModeToFlex(mode, freqHz)
if fm == "" {
return
}
f.mu.Lock()
s := f.slices[idx]
same := s != nil && strings.EqualFold(s.mode, fm)
if s != nil {
s.mode = fm
}
f.mu.Unlock()
if same {
return
}
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
}
// SatReceiveHz is where the downlink slice sits.
//
// From the cache, not from a read: SmartSDR pushes every slice change as it
// happens, so the cached value is what the radio said, and there is no round
// trip to pay for once a second.
func (f *Flex) SatReceiveHz() (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.satRX < 0 {
return 0, fmt.Errorf("flex: no downlink slice")
}
s := f.slices[f.satRX]
if s == nil || !s.inUse {
return 0, fmt.Errorf("flex: the downlink slice has gone")
}
return s.freqHz, nil
}
+181
View File
@@ -0,0 +1,181 @@
package cat
import (
"errors"
"fmt"
"strings"
"hamlog/internal/applog"
"hamlog/internal/cat/civ"
)
// Satellite operation on an Icom.
//
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
// the IC-9100 — and on those the right thing to do is ask the radio for it
// rather than build an imitation out of split. Their satellite mode pairs the
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
// and keeps the two dials linked the way the designers meant. Every other Icom
// has one receiver on one band: it can be tuned to the downlink, and that is
// the whole truth about what it can do on a cross-band satellite.
//
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
// misbehaving, the log lines below name every frame sent.
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
// because the radio has no second receiver and the two are on different bands.
//
// A distinct error rather than a silent half-success: a tracker that quietly
// stops transmitting where the operator expects it to is worse than one that
// says it cannot. The caller reports it once, not once per Doppler step.
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
// SetSatellite arms the rig's own satellite mode.
func (b *IcomSerial) SetSatellite(on bool) error {
if !b.satNative {
// Nothing to arm and nothing to break: the tuning path below does what
// this radio can do without any mode change. Refusing here would deny an
// operator the downlink, which is most of the value on a receive-heavy
// pass.
b.satOn = on
return nil
}
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
}
b.satOn = on
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
if on {
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
// loop, the logged frequency, the operator's dial — reads the selected
// band, and on a satellite the band worth reading is the one carrying the
// downlink.
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
}
return nil
}
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
if downHz <= 0 {
return fmt.Errorf("icom: no downlink frequency")
}
if !b.satNative {
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
}
// MAIN — the downlink.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
return fmt.Errorf("icom: could not select the main band: %w", err)
}
if err := b.SetFrequency(downHz); err != nil {
return err
}
if err := b.satSetMode(downMode, downHz); err != nil {
return err
}
// SUB — the uplink.
if upHz > 0 {
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
return fmt.Errorf("icom: could not select the sub band: %w", err)
}
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
merr := b.satSetMode(upMode, upHz)
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
// uplink as its frequency, and every band-dependent thing in OpsLog —
// the log, the antenna, the amplifier — would follow the transmitter
// onto the wrong band.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
applog.Printf("icom: could not return to the main band: %v", err)
}
if uerr != nil {
return uerr
}
if merr != nil {
return merr
}
}
return nil
}
// satSetMode sets the mode of whichever band is currently selected. An empty
// mode leaves it alone — a linear transponder is worked in one mode for a whole
// pass, and re-sending it every second is traffic for nothing.
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
mode = strings.TrimSpace(mode)
if mode == "" {
return nil
}
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
// is wrong here twice over: the dial may still be on the other band, and on
// satellites USB is the convention on both sides whatever the frequency.
code, data, err := b.modeCode(satSideband(mode))
if err != nil {
return err
}
return b.setModeBytes(mode, code, data)
}
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
// and the downlink, including the parts of a linear transponder that fall in
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
// 29 MHz among them — are still USB by convention, so there is no exception to
// make.
func satSideband(mode string) string {
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
return "USB"
}
return mode
}
// SatReceiveHz is where the receiver is now.
func (b *IcomSerial) SatReceiveHz() (int64, error) {
if b.satNative {
// The selected band is MAIN — see TuneSatellite, which always returns to
// it — so the ordinary frequency read is the downlink.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
applog.Printf("icom: sat readback could not select main: %v", err)
}
}
return b.readFreq()
}
// tuneSatSingleBand is every other Icom: one receiver, one band.
//
// The downlink is set, because that is what the operator is listening to. The
// uplink is set through split only when it is close enough to be on the same
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
// out of reach rather than quietly skipped.
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
if err := b.SetFrequency(downHz); err != nil {
return err
}
if err := b.satSetMode(downMode, downHz); err != nil {
return err
}
if upHz <= 0 {
return nil
}
// One megahertz apart is the working definition of "the same band" here: it
// covers a transponder's own passband and any sensible transverter pairing,
// and excludes every real cross-band satellite (145 / 435 MHz).
if abs64(upHz-downHz) > 1_000_000 {
return ErrSatUplinkUnreachable
}
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
return err
}
if !b.satOn {
return nil
}
return b.exec(civ.CmdSplit, boolByte(true))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
+11
View File
@@ -94,6 +94,13 @@ type IcomSerial struct {
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
// which have a real satellite mode of their own. Everything else gets the
// downlink and, where the uplink is reachable, split.
satNative bool
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
// once rather than on every Doppler step.
satOn bool
// Set when the rig rejects the waveform-output command in both shapes: it has
// no stream to give, and asking again on every enable is noise.
scopeUnsupported bool
@@ -284,6 +291,10 @@ func (b *IcomSerial) Connect() error {
// non-default address still RENDERS; this flag only drives the SET/read commands
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
// bands and a satellite mode that pairs them; no other Icom in this table
// does, and asking one that does not is a rejected frame per Doppler step.
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
+26 -3
View File
@@ -1,3 +1,5 @@
//go:build windows
package cat
import (
@@ -36,6 +38,14 @@ type OmniRig struct {
// on the main VFO) and read B — the frequency "never followed the knob",
// while it was following the other one all along.
ForceVFO string
// CWLower sends PM_CW_L rather than PM_CW_U when asked for CW.
//
// OmniRig has two CW modes and nothing says which one an .ini file calls
// plain CW. Icom rig files disagree: on some PM_CW_U is CI-V mode 0x03 (CW),
// on others it is 0x07 (CW-R). An operator clicking a CW spot on an IC-7610
// landed in CW-R every time and had to edit the rig file to get out of it.
// This is the setting that means he does not have to.
CWLower bool
omnirig *ole.IDispatch
rig *ole.IDispatch
@@ -76,7 +86,7 @@ type OmniRig struct {
// NewOmniRig creates a non-connected backend. Call Connect before use.
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
// reports, or "A"/"B" to override it — see the ForceVFO field.
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
func NewOmniRig(rigNum int, forceVFO string, cwLower bool) *OmniRig {
if rigNum < 1 || rigNum > 2 {
rigNum = 1
}
@@ -84,7 +94,7 @@ func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
if v != "A" && v != "B" {
v = ""
}
return &OmniRig{RigNum: rigNum, ForceVFO: v}
return &OmniRig{RigNum: rigNum, ForceVFO: v, CWLower: cwLower}
}
func (o *OmniRig) Name() string { return "omnirig" }
@@ -602,6 +612,13 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return nil
}
// SetCWLower chooses which of OmniRig's two CW bits means plain CW.
//
// Applied to the RUNNING backend, because the CAT link does not depend on it:
// dropping the rig — and with it WSJT-X's rigctl session — to change which bit
// a mode maps to would cost far more than it fixes.
func (o *OmniRig) SetCWLower(on bool) { o.CWLower = on }
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
// For SSB, the USB/LSB side is chosen from the rig's current frequency
// following worldwide convention (LSB below 14 MHz, USB above).
@@ -625,7 +642,13 @@ func (o *OmniRig) SetMode(mode string) error {
)
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
// Which bit means plain CW is a property of the RIG FILE, not of CW —
// see the CWLower field.
if o.CWLower {
bit, bitName = pmCWL, "PM_CW_L"
} else {
bit, bitName = pmCWU, "PM_CW_U"
}
case "SSB":
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
+2
View File
@@ -1,3 +1,5 @@
//go:build windows
package cat
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build windows
package cat
import (
+34
View File
@@ -0,0 +1,34 @@
//go:build !windows
package cat
import "errors"
// OmniRig is COM automation against a Windows-only application, so off Windows
// there is nothing to talk to. The backend still EXISTS here rather than being
// compiled out of app.go, because a settings database is portable: an operator
// who moves a profile from Windows to Linux keeps "omnirig" as their saved CAT
// backend, and OpsLog must start and say why the rig is silent instead of
// failing to build or panicking on a nil backend.
//
// The fix for those operators is a native backend (Icom, Yaesu, Kenwood/
// Elecraft, Flex, TCI, Xiegu all speak to the radio directly) or Hamlib.
type OmniRig struct{ CWLower bool }
var errOmniRigWindowsOnly = errors.New("OmniRig runs only on Windows — pick a native CAT backend (Icom, Yaesu, Kenwood/Elecraft, FlexRadio, TCI, Xiegu) in Settings ▸ CAT")
func NewOmniRig(rigNum int, forceVFO string, cwLower bool) *OmniRig {
return &OmniRig{CWLower: cwLower}
}
func (o *OmniRig) Name() string { return "omnirig" }
func (o *OmniRig) Connect() error { return errOmniRigWindowsOnly }
func (o *OmniRig) Disconnect() {}
func (o *OmniRig) ReadState() (RigState, error) { return RigState{}, errOmniRigWindowsOnly }
func (o *OmniRig) SetFrequency(hz int64) error { return errOmniRigWindowsOnly }
func (o *OmniRig) SetMode(mode string) error { return errOmniRigWindowsOnly }
func (o *OmniRig) SetPTT(on bool) error { return errOmniRigWindowsOnly }
// SetCWLower satisfies OmniRigController so the preference push at startup is a
// no-op here rather than a "backend does not support this" error in the log.
func (o *OmniRig) SetCWLower(on bool) { o.CWLower = on }
+2
View File
@@ -1,3 +1,5 @@
//go:build windows
package cat
import "testing"
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows
package cat
import "golang.org/x/sys/unix"
// setSocketReuse is the Unix half of the Windows SO_REUSEADDR above. Linux
// wants SO_REUSEPORT as well before two processes may share a bound UDP port;
// it is not defined on every Unix, so a refusal there is ignored.
func setSocketReuse(fd uintptr) error {
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
return err
}
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
return nil
}
+14
View File
@@ -0,0 +1,14 @@
//go:build windows
package cat
import "golang.org/x/sys/windows"
// setSocketReuse enables SO_REUSEADDR before bind, so discovery can listen on
// :4992 while SmartSDR is already listening for the same radio broadcast.
// Without it the second bind fails with WSAEADDRINUSE and the operator has to
// type the radio's IP by hand.
func setSocketReuse(fd uintptr) error {
return windows.SetsockoptInt(windows.Handle(fd),
windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
}
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
// TCI audio — receiving the radio's audio over the same WebSocket that carries
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import "fmt"
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
// The TCI control panel: what the radio already tells us, gathered up.
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import "testing"
-2
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import "testing"
+5
View File
@@ -84,6 +84,11 @@ type YaesuTXState struct {
// same shape as FlexController and IcomController.
type YaesuController interface {
YaesuState() YaesuTXState
// SetRTTYUpper is a preference, not a command — see Yaesu.SetRTTYUpper. It
// belongs here so a change of mind reaches the RUNNING rig: the link is not
// rebuilt for it, and until it was reachable this way the setting only took
// effect on the next launch.
SetRTTYUpper(bool)
RefreshYaesu() error
SetYaesuPower(int) error
SetYaesuMicGain(int) error
+16
View File
@@ -425,3 +425,19 @@ func TestYaesuRTTYSideband(t *testing.T) {
t.Errorf("CW = %q, want CW-U", got)
}
}
// The RTTY sideband is a preference the LINK does not depend on, so it is not
// in catLinkSig and the link is not rebuilt for it — which means the running
// client has to accept it. It did not, and the setting waited for the next
// launch while the rig went on choosing LSB.
func TestYaesuAcceptsTheRTTYSidebandWhileConnected(t *testing.T) {
var y YaesuController = &Yaesu{}
y.SetRTTYUpper(true)
if got := y.(*Yaesu).rttyUpper; !got {
t.Error("a running Yaesu ignored the RTTY sideband")
}
y.SetRTTYUpper(false)
if got := y.(*Yaesu).rttyUpper; got {
t.Error("it could not be turned back")
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ func Configured(svc Service, cfg ExternalServices) error {
return missing("Cloudlog / Wavelog", need...)
}
case ServiceLoTW:
add(set(cfg.LoTW.TQSLPath), "the path to tqsl.exe")
add(set(cfg.LoTW.TQSLPath), "the path to TQSL")
add(set(cfg.LoTW.StationLocation), "the TQSL station location")
if len(need) > 0 {
return missing("LoTW", need...)
+2 -25
View File
@@ -297,29 +297,6 @@ func ListStationLocations(stationDataPath string) ([]StationLocation, error) {
return out, nil
}
// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or ""
// if not found.
func DefaultTQSLPath() string {
for _, p := range []string{
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
`C:\Program Files\TrustedQSL\tqsl.exe`,
} {
if fileExists(p) {
return p
}
}
return ""
}
// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\
// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set.
func DefaultStationDataPath() string {
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "TrustedQSL", "station_data")
}
return ""
}
func fileExists(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
@@ -375,7 +352,7 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
case tqsl == "":
return UploadResult{}, fmt.Errorf("lotw: TQSL path not set")
case !fileExists(tqsl):
return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl)
return UploadResult{}, fmt.Errorf("lotw: TQSL not found at %q", tqsl)
case loc == "":
return UploadResult{}, fmt.Errorf("lotw: station location not set")
case strings.TrimSpace(adifRecord) == "":
@@ -515,7 +492,7 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
tqsl := strings.TrimSpace(cfg.TQSLPath)
loc := strings.TrimSpace(cfg.StationLocation)
if tqsl == "" || !fileExists(tqsl) {
return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)")
return "", fmt.Errorf("lotw: TQSL not found (set the TQSL path)")
}
if loc == "" {
return "", fmt.Errorf("lotw: pick a station location")
+66
View File
@@ -0,0 +1,66 @@
//go:build !windows
package extsvc
import (
"os"
"os/exec"
"path/filepath"
"runtime"
)
// DefaultTQSLPath finds TrustedQSL, or returns "" so the operator can point at
// it by hand.
//
// PATH is asked FIRST, unlike the Windows side where two fixed install folders
// are the whole story. On Linux tqsl comes from the distribution's package
// manager, a Flatpak or a self-built copy, and each puts it somewhere
// different; whichever one the operator installed is the one their shell finds.
// The fixed list below is only for a desktop session that started without a
// useful PATH.
func DefaultTQSLPath() string {
if p, err := exec.LookPath("tqsl"); err == nil && fileExists(p) {
return p
}
candidates := []string{
"/usr/bin/tqsl",
"/usr/local/bin/tqsl",
"/var/lib/flatpak/exports/bin/org.arrl.tqsl",
filepath.Join(os.Getenv("HOME"), ".local/share/flatpak/exports/bin/org.arrl.tqsl"),
}
if runtime.GOOS == "darwin" {
candidates = append(candidates, "/Applications/TrustedQSL/tqsl.app/Contents/MacOS/tqsl")
}
for _, p := range candidates {
if fileExists(p) {
return p
}
}
return ""
}
// DefaultStationDataPath returns TQSL's station_data location.
//
// ~/.tqsl is where the Unix build of TrustedQSL keeps its configuration. A
// Flatpak install redirects it into the sandbox
// (~/.var/app/org.arrl.tqsl/data/tqsl), so that is tried too — an operator on a
// Flatpak TQSL otherwise sees an empty station-location list with nothing to
// explain it.
func DefaultStationDataPath() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ""
}
for _, p := range []string{
filepath.Join(home, ".tqsl", "station_data"),
filepath.Join(home, ".var", "app", "org.arrl.tqsl", "data", "tqsl", "station_data"),
} {
if fileExists(p) {
return p
}
}
// Nothing found: name the ordinary location anyway. The settings field then
// shows the path TQSL would create on its first run, which is a better
// starting point for the operator than an empty box.
return filepath.Join(home, ".tqsl", "station_data")
}
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package extsvc
import (
"os"
"path/filepath"
)
// DefaultTQSLPath returns the usual tqsl.exe install path, or "" if not found.
func DefaultTQSLPath() string {
for _, p := range []string{
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
`C:\Program Files\TrustedQSL\tqsl.exe`,
} {
if fileExists(p) {
return p
}
}
return ""
}
// DefaultStationDataPath returns TQSL's station_data location
// (%APPDATA%\TrustedQSL\station_data), or "" if APPDATA isn't set.
func DefaultStationDataPath() string {
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "TrustedQSL", "station_data")
}
return ""
}
+54
View File
@@ -0,0 +1,54 @@
package udp
import "testing"
// Three decoders on one multicast group, which is the ordinary setup. MSHV is
// working a station; WSJT-X and JTDX are idle and say so once a second each.
//
// Read across the listener rather than per program, every one of those idle
// Status packets was a "the operator cleared the DX Call" — so OpsLog emptied
// the entry field, MSHV's next Status refilled it, and the entry blinked and
// the map zoomed at 1 Hz for as long as all three were running.
func TestDXClearIsPerProgram(t *testing.T) {
s := &Server{}
if s.noteDXCall("MSHV", "F5NNN") {
t.Fatal("taking up a station is not a clear")
}
// The idle ones, interleaved, as they arrive on the wire.
for i := 0; i < 3; i++ {
if s.noteDXCall("WSJT-X", "") {
t.Fatal("an idle WSJT-X was read as MSHV clearing its call")
}
if s.noteDXCall("JTDX", "") {
t.Fatal("an idle JTDX was read as MSHV clearing its call")
}
if s.noteDXCall("MSHV", "F5NNN") {
t.Fatal("MSHV repeating the same station is not a clear")
}
}
// MSHV's own clear is still an edge, and only once: the Status that follows
// is just as empty and must not re-clear a field the operator may have
// typed into since.
if !s.noteDXCall("MSHV", "") {
t.Error("MSHV clearing its own DX Call was not reported")
}
if s.noteDXCall("MSHV", "") {
t.Error("the clear repeated on the next identical Status")
}
}
// Each program's edge is its own: WSJT-X letting go says nothing about MSHV.
func TestDXClearOfOneProgramLeavesTheOthers(t *testing.T) {
s := &Server{}
s.noteDXCall("MSHV", "F5NNN")
s.noteDXCall("WSJT-X", "DL1ABC")
if !s.noteDXCall("WSJT-X", "") {
t.Error("WSJT-X clearing its own call should be reported")
}
if s.noteDXCall("MSHV", "F5NNN") {
t.Error("MSHV's unchanged call was disturbed by WSJT-X's clear")
}
}
@@ -0,0 +1,35 @@
package udp
import (
"net"
"testing"
)
// A "multicast" row whose group is not a multicast address.
//
// 127.0.0.1 in that box is the common mistake — it is the address every other
// field in every other program wants — and it used to fail the join on every
// interface with a Windows error about an address not being valid in its
// context. The row did not run and the message named nothing the operator had
// typed. Reported by an operator whose WSJT-X rows were dead for exactly this
// reason, while a third row on unicast worked perfectly beside them.
func TestOnlyRealMulticastGroupsAreJoined(t *testing.T) {
for _, tc := range []struct {
addr string
multicast bool
}{
{"224.0.0.1", true}, // the all-hosts group WSJT-X offers
{"239.255.0.1", true}, // the administratively-scoped range
{"127.0.0.1", false}, // loopback: the mistake
{"192.168.1.10", false},
{"0.0.0.0", false},
} {
ip := net.ParseIP(tc.addr)
if ip == nil {
t.Fatalf("%s does not parse", tc.addr)
}
if got := ip.IsMulticast(); got != tc.multicast {
t.Errorf("%s: IsMulticast() = %v, wanted %v", tc.addr, got, tc.multicast)
}
}
}
+53 -7
View File
@@ -235,7 +235,16 @@ type Server struct {
// lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker.
lastMode map[string]string
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
// lastDX is each program's last DX Call, to spot the moment it is cleared.
//
// PER PROGRAM, and that is the whole point of the map. Two or three decoders
// commonly share one listener — the multicast group on 2237 is the usual
// setup — and a single value meant WSJT-X's empty DX Call was read as MSHV
// clearing the station it was calling. One "cleared" per second, alternating
// with MSHV re-announcing the call: the entry field emptied and refilled at
// 1 Hz and the map zoomed in and out with it. "The operator cleared the DX
// call" is a statement about ONE program, never about a socket.
lastDX map[string]string
// badPkts counts datagrams this listener could not parse, so the diagnostic
// dump below stays bounded. A misconfigured port is not a one-off: the
@@ -301,7 +310,28 @@ func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
func (s *Server) start() error {
var conn *net.UDPConn
if s.cfg.Multicast {
// "Multicast" ticked with an address that is not one.
//
// 127.0.0.1 in the group box is the common mistake, and it is an
// understandable one — it is the address every other field in every other
// program wants. But a multicast group is 224.0.0.0 to 239.255.255.255, and
// joining anything else fails on every interface with a Windows error about
// an address not being valid in its context. The row then simply does not
// run, and an operator reads a setsockopt message that names nothing they
// typed.
//
// So it listens anyway, as unicast, which is what an address like that means
// — and says what it did. The row works, and the reason it is not multicast
// is in the log rather than in a kernel error code.
multicast := s.cfg.Multicast
if multicast {
if ip := net.ParseIP(strings.TrimSpace(s.cfg.MulticastGroup)); ip != nil && !ip.IsMulticast() {
applog.Printf("udp: [%s] %s is not a multicast address (those run 224.0.0.0-239.255.255.255) — listening on unicast :%d instead\n",
s.cfg.Name, ip, s.cfg.Port)
multicast = false
}
}
if multicast {
group := strings.TrimSpace(s.cfg.MulticastGroup)
if group == "" {
return fmt.Errorf("multicast enabled but group address is empty")
@@ -403,6 +433,25 @@ func (s *Server) run() {
// radios on different bands. The port is included — a program keeps its socket
// for as long as it runs, which is exactly the lifetime this has to be stable
// over.
// noteDXCall records a program's current DX Call and reports whether THIS
// program has just cleared one.
//
// A decoder sends Status every second whether anything changed or not, so the
// clear is an edge — a call, then none — and it is an edge in ONE program's
// stream. Several decoders commonly share a listener, and reading the edge
// across all of them made an idle WSJT-X look like MSHV abandoning the station
// it was calling, once a second, for as long as both were running.
func (s *Server) noteDXCall(inst, dx string) (cleared bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastDX == nil {
s.lastDX = map[string]string{}
}
prev := s.lastDX[inst]
s.lastDX[inst] = dx
return dx == "" && prev != ""
}
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
if id == "" {
return ""
@@ -623,12 +672,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
// server) — an idle app sends empty Status every second, and we must not
// re-clear (which would fight a manual entry) on each of those.
s.mu.Lock()
prev := s.lastDX
s.lastDX = w.DXCall
s.mu.Unlock()
if w.DXCall == "" && prev != "" {
if s.noteDXCall(inst, w.DXCall) {
ev.ClearCall = true
ev.ProgramID = inst // whose clear it is — the app filters on it
}
case ServiceADIF:
// JTAlert / GridTracker forward a text ADIF record after a QSO is
+369
View File
@@ -0,0 +1,369 @@
// Package easycomm drives azimuth/elevation rotator controllers that speak
// EasyComm II, over a raw TCP socket or a serial port.
//
// EasyComm is what satellite rotator controllers agreed on: SatPC32, Gpredict
// and Hamlib all speak it, so a controller that works with any of those works
// here. The dialect matters less than it looks — every command is a two-letter
// name with a number stuck to it, on one line, and a controller that does not
// recognise one ignores it.
//
// The subset used:
//
// AZ123.4 EL45.0<LF> point there
// AZ EL<LF> ask where it is — the reply is the same shape
// SA SE<LF> stop both axes
//
// Not every controller ANSWERS. A great many EasyComm boxes — the Arduino
// trackers above all — accept commands and never say a word back, which is
// perfectly legal in EasyComm I and common in II. So a silent controller is not
// treated as a broken one: the last commanded position is reported instead, and
// the rotator keeps being driven. Refusing to work with a write-only controller
// would rule out half the satellite stations in the hobby.
package easycomm
import (
"fmt"
"io"
"math"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
const (
dialTimeout = 3 * time.Second
ioTimeout = 1500 * time.Millisecond
// replyWait is how long a query waits before deciding the controller is one
// of the silent ones. Short: this runs once a second inside a pass, and a
// controller that is going to answer answers in milliseconds.
replyWait = 400 * time.Millisecond
)
// Client is one rotator controller. Exactly one of (Host, Port) or ComPort is
// used.
type Client struct {
Host string
Port int
ComPort string
Baud int
// MaxAz is how far the rotator turns: 360 or 450. A 450° rotator can follow
// a pass straight through north without unwinding, which is the difference
// between hearing the whole of an overhead pass and losing the middle of it.
MaxAz int
mu sync.Mutex
// lastAz/lastEl are what was last commanded — the answer for a controller
// that does not talk back.
lastAz, lastEl float64
commanded bool
// silent latches once a query has gone unanswered. Without it, a write-only
// controller costs a 400 ms wait on every single poll of a pass.
silent bool
}
// New builds a TCP client. There is no standard port; 4533 is Hamlib's rotctld
// convention and the usual default in the controllers' own setup screens.
func New(host string, port int, maxAz int) *Client {
if strings.TrimSpace(host) == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 4533
}
return &Client{Host: host, Port: port, MaxAz: normMaxAz(maxAz)}
}
// NewSerial builds a serial client.
func NewSerial(comPort string, baud int, maxAz int) *Client {
if baud <= 0 {
baud = 9600
}
return &Client{ComPort: comPort, Baud: baud, MaxAz: normMaxAz(maxAz)}
}
func normMaxAz(v int) int {
if v == 450 {
return 450
}
return 360
}
// Point commands the rotator to an azimuth and elevation.
//
// The azimuth is given in the rotator's own terms: on a 450° machine an
// azimuth past 360 is a real, reachable position, and asking for 010 when the
// rotator is sitting at 370 would send it the long way round through the whole
// scale — three quarters of a turn, in the middle of a pass, with the antenna
// pointing at the ground for most of it.
func (c *Client) Point(az, el float64) error {
az = c.wrapAz(az)
el = clamp(el, 0, 180)
if err := c.send(fmt.Sprintf("AZ%.1f EL%.1f", az, el), false); err != nil {
return err
}
c.mu.Lock()
c.lastAz, c.lastEl, c.commanded = az, el, true
c.mu.Unlock()
return nil
}
// Stop halts both axes.
func (c *Client) Stop() error { return c.send("SA SE", false) }
// Heading is where the rotator says it is.
//
// live is false when the answer is the last commanded position rather than a
// reading — the caller shows that differently, because "where I told it to go"
// and "where it is" are not the same claim and a stuck rotator must not be able
// to hide behind the first.
func (c *Client) Heading() (az, el float64, live bool, err error) {
c.mu.Lock()
silent, la, le, commanded := c.silent, c.lastAz, c.lastEl, c.commanded
c.mu.Unlock()
if silent {
if !commanded {
return 0, 0, false, fmt.Errorf("easycomm: the controller does not report its position")
}
return la, le, false, nil
}
line, err := c.query("AZ EL")
if err != nil {
// One silence is enough: a controller either answers or it does not, and
// this runs every second for the length of a pass.
c.mu.Lock()
c.silent = true
c.mu.Unlock()
if commanded {
return la, le, false, nil
}
return 0, 0, false, err
}
a, e, ok := parseHeading(line)
if !ok {
c.mu.Lock()
c.silent = true
c.mu.Unlock()
if commanded {
return la, le, false, nil
}
return 0, 0, false, fmt.Errorf("easycomm: could not read %q", line)
}
return a, e, true, nil
}
// wrapAz brings an azimuth into what this rotator can reach.
//
// On a 360° machine that is a plain modulo. On a 450° one the extra 90° is an
// OVERLAP — 370 and 10 are the same direction — and which of the two to use is
// decided by whichever is nearer where the rotator already is, so a pass
// crossing north continues instead of unwinding.
func (c *Client) wrapAz(az float64) float64 {
az = math.Mod(az, 360)
if az < 0 {
az += 360
}
if c.MaxAz != 450 {
return az
}
c.mu.Lock()
cur, known := c.lastAz, c.commanded
c.mu.Unlock()
if !known {
return az
}
alt := az + 360
if alt > 450 {
return az
}
if math.Abs(alt-cur) < math.Abs(az-cur) {
return alt
}
return az
}
// ── Transport ───────────────────────────────────────────────────────────────
type heldPort struct {
p serial.Port
openedAt time.Time
}
var (
portsMu sync.Mutex
openPorts = map[string]*heldPort{}
)
// bootSettle: an Arduino-based controller resets when its serial port is
// opened, and its bootloader then holds the processor for a second or more. A
// command sent into that window is simply lost — which is how a controller that
// answers a terminal perfectly reports nothing here.
const bootSettle = 2 * time.Second
func acquire(com string, baud int) (*heldPort, error) {
portsMu.Lock()
defer portsMu.Unlock()
if h, ok := openPorts[com]; ok && h.p != nil {
return h, nil
}
if baud <= 0 {
baud = 9600
}
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
if err != nil {
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
}
_ = sp.SetReadTimeout(150 * time.Millisecond)
h := &heldPort{p: sp, openedAt: time.Now()}
openPorts[com] = h
return h, nil
}
func drop(com string) {
portsMu.Lock()
defer portsMu.Unlock()
if h, ok := openPorts[com]; ok {
if h.p != nil {
_ = h.p.Close()
}
delete(openPorts, com)
}
}
// Close releases the serial port. TCP dials per command and holds nothing.
func (c *Client) Close() {
if c.ComPort != "" {
drop(c.ComPort)
}
}
func (c *Client) send(cmd string, wantReply bool) error {
_, err := c.exchange(cmd, wantReply)
return err
}
func (c *Client) query(cmd string) (string, error) { return c.exchange(cmd, true) }
func (c *Client) exchange(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
h, err := acquire(c.ComPort, c.Baud)
if err != nil {
return "", err
}
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
time.Sleep(wait)
}
conn = h.p
drain(h.p)
} else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect rotator %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
defer nc.Close()
conn = nc
}
// LF, not CR: EasyComm's own documents use a line feed, and the controllers
// that want CR accept either. The reverse is not true of every Arduino
// sketch out there.
if _, err := conn.Write([]byte(cmd + "\n")); err != nil {
if c.ComPort != "" {
drop(c.ComPort)
}
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
return "", nil
}
buf := make([]byte, 128)
var sb strings.Builder
deadline := time.Now().Add(replyWait)
for time.Now().Before(deadline) {
n, err := conn.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if strings.ContainsAny(sb.String(), "\r\n") {
break
}
}
if err != nil {
break
}
}
line := strings.TrimSpace(sb.String())
if line == "" {
return "", fmt.Errorf("no reply to %q", cmd)
}
return line, nil
}
func drain(sp serial.Port) {
buf := make([]byte, 256)
for {
n, err := sp.Read(buf)
if n == 0 || err != nil {
return
}
}
}
// parseHeading reads a controller's answer.
//
// The shapes in the wild differ more than the specification suggests —
// "AZ123.4 EL45.0", "AZ=123.4 EL=45.0", "+123.4+045.0", lower case, tabs — so
// this looks for the two labels and takes the number attached to each rather
// than trying to match a whole line.
func parseHeading(line string) (az, el float64, ok bool) {
up := strings.ToUpper(line)
az, aok := numberAfter(up, "AZ")
el, eok := numberAfter(up, "EL")
if !aok {
return 0, 0, false
}
// Elevation missing is not a broken reply: an azimuth-only controller
// answering an AZ EL query says what it has.
if !eok {
el = 0
}
return az, el, true
}
func numberAfter(s, label string) (float64, bool) {
i := strings.Index(s, label)
if i < 0 {
return 0, false
}
rest := strings.TrimLeft(s[i+len(label):], " \t=:")
end := 0
for end < len(rest) {
ch := rest[end]
if (ch >= '0' && ch <= '9') || ch == '.' || ((ch == '-' || ch == '+') && end == 0) {
end++
continue
}
break
}
if end == 0 {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSuffix(rest[:end], "."), 64)
if err != nil {
return 0, false
}
return v, true
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,92 @@
package easycomm
import "testing"
// Every one of these is a shape a real controller has been seen to answer with.
// The point of the parser is that none of them is special-cased.
func TestParseHeading(t *testing.T) {
for _, tc := range []struct {
line string
az, el float64
ok bool
}{
{"AZ123.4 EL45.0", 123.4, 45, true},
{"AZ=123.4 EL=45.0", 123.4, 45, true},
{"az 123.4 el 45.0", 123.4, 45, true},
{"AZ123.4\tEL45.0\r\n", 123.4, 45, true},
{"AZ012.0 EL000.0", 12, 0, true},
{"AZ370.5 EL05.5", 370.5, 5.5, true},
{"AZ123.4", 123.4, 0, true}, // azimuth-only controller
{"RPRT 0", 0, 0, false},
{"", 0, 0, false},
} {
az, el, ok := parseHeading(tc.line)
if ok != tc.ok {
t.Errorf("%q: ok=%v, wanted %v", tc.line, ok, tc.ok)
continue
}
if ok && (az != tc.az || el != tc.el) {
t.Errorf("%q: got %.1f/%.1f, wanted %.1f/%.1f", tc.line, az, el, tc.az, tc.el)
}
}
}
// The 450° overlap is the whole reason a satellite rotator is worth having: a
// pass crossing north must continue past 360 instead of unwinding through the
// entire scale with the antenna sweeping the ground.
func TestWrapAz450(t *testing.T) {
c := &Client{MaxAz: 450}
// Nothing commanded yet: no history to be near, so the plain bearing.
if got := c.wrapAz(10); got != 10 {
t.Errorf("first move: got %.1f, wanted 10", got)
}
c.lastAz, c.commanded = 350, true
// Crossing north: 370 is 20° away, 10 is 340° away.
if got := c.wrapAz(10); got != 370 {
t.Errorf("crossing north from 350: got %.1f, wanted 370", got)
}
// Coming back down the same way, the overlap stays the near answer.
c.lastAz = 370
if got := c.wrapAz(350); got != 350 {
t.Errorf("back from 370: got %.1f, wanted 350", got)
}
// Beyond the rotator's reach there is no overlap to use.
c.lastAz = 440
if got := c.wrapAz(100); got != 100 {
t.Errorf("past the end of the scale: got %.1f, wanted 100", got)
}
}
func TestWrapAz360(t *testing.T) {
c := &Client{MaxAz: 360}
c.lastAz, c.commanded = 350, true
if got := c.wrapAz(10); got != 10 {
t.Errorf("a 360 rotator has no overlap: got %.1f, wanted 10", got)
}
if got := c.wrapAz(-10); got != 350 {
t.Errorf("negative bearing: got %.1f, wanted 350", got)
}
if got := c.wrapAz(725); got != 5 {
t.Errorf("two turns and five degrees: got %.1f, wanted 5", got)
}
}
// A controller that never answers must not be treated as a broken one: the last
// commanded position is reported, marked as not live.
func TestSilentControllerReportsCommanded(t *testing.T) {
c := &Client{MaxAz: 360, silent: true}
if _, _, _, err := c.Heading(); err == nil {
t.Error("a silent controller with nothing commanded should say it cannot report")
}
c.lastAz, c.lastEl, c.commanded = 120, 30, true
az, el, live, err := c.Heading()
if err != nil {
t.Fatalf("after a command: %v", err)
}
if live {
t.Error("a commanded position must not be reported as a live reading")
}
if az != 120 || el != 30 {
t.Errorf("got %.1f/%.1f, wanted 120/30", az, el)
}
}
+88 -4
View File
@@ -15,10 +15,13 @@
//
// GS-232A subset used:
//
// Maaa<CR> move to azimuth aaa (000-450)
// S<CR> stop rotation
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// flavour); both are parsed.
// Maaa<CR> move to azimuth aaa (000-450)
// Waaa eee<CR> move to azimuth aaa AND elevation eee (az/el controllers)
// S<CR> stop rotation
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// flavour); both are parsed.
// C2<CR> query both axes — "+0aaa+0eee" / "AZ=aaa EL=eee"
// B<CR> query elevation alone, for the controllers that do not answer C2
package gs232
import (
@@ -242,3 +245,84 @@ func (c *Client) Heading() (az int, raw string, err error) {
az, _ = strconv.Atoi(m[1])
return az % 360, raw, nil
}
// --- Elevation: the az/el controllers ---
//
// The ERC-M (Easy Rotor Control, DF9GR) is the reason this half exists. It
// drives a Yaesu G-5500 — the az/el pair most satellite stations own — and
// emulates GS-232 over its USB port, so the same three commands that already
// pointed an azimuth rotator point a satellite antenna once elevation is added.
//
// A plain ERC or a microHAM ARCO answers the azimuth commands and ignores
// these; that is why the elevation capability is a property of the configured
// TYPE and not something probed at runtime. Asking a controller with no
// elevation motor where its elevation is gets an answer, and the answer is
// zero, for ever.
// GoToAzEl points an az/el controller at both axes in one command. GS-232's W
// takes the two angles separated by a space, azimuth first.
//
// Elevation is clamped to 0-180 rather than 0-90: a G-5500 goes past the zenith
// and keeps counting, which is how an overhead pass is followed without swinging
// the azimuth 180° through the middle of it.
func (c *Client) GoToAzEl(az, el int) error {
az = ((az % 360) + 360) % 360
if el < 0 {
el = 0
}
if el > 180 {
el = 180
}
_, err := c.roundTrip(fmt.Sprintf("W%03d %03d", az, el), false)
return err
}
// elRe matches the elevation half of a reply, in either flavour. The GS-232A
// form of C2 is "+0aaa+0eee" — two identically-shaped groups — so the azimuth
// is taken from the first match and the elevation from the second, which is
// what bothRe below does; this one is for the reply to a bare B.
var elRe = regexp.MustCompile(`(?:\+0|EL=)(\d{3})`)
// bothRe pulls both angles out of a C2 reply.
var bothRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})[^0-9+]*(?:\+0|EL=)(\d{3})`)
// Position queries both axes.
//
// C2 first, because one exchange is one chance for a serial line to go quiet.
// Controllers that answer C2 with the azimuth alone — some ERC firmware does —
// fall through to the two separate queries rather than reporting an elevation
// of zero, which would read as "the antenna is on the horizon" and is the one
// wrong answer that looks plausible.
func (c *Client) Position() (az, el int, raw string, err error) {
raw, err = c.roundTrip("C2", true)
if err == nil {
if m := bothRe.FindStringSubmatch(raw); m != nil {
a, _ := strconv.Atoi(m[1])
e, _ := strconv.Atoi(m[2])
return a % 360, e, raw, nil
}
}
a, azRaw, aerr := c.Heading()
if aerr != nil {
return 0, 0, azRaw, aerr
}
e, elRaw, eerr := c.Elevation()
if eerr != nil {
return a, 0, azRaw + " " + elRaw, eerr
}
return a, e, azRaw + " " + elRaw, nil
}
// Elevation queries the elevation axis alone.
func (c *Client) Elevation() (el int, raw string, err error) {
raw, err = c.roundTrip("B", true)
if err != nil {
return 0, raw, err
}
m := elRe.FindStringSubmatch(raw)
if m == nil {
return 0, raw, fmt.Errorf("unrecognised elevation reply %q", raw)
}
el, _ = strconv.Atoi(m[1])
return el, raw, nil
}
+65
View File
@@ -32,3 +32,68 @@ func TestAzimuthReplies(t *testing.T) {
}
}
}
// The az/el replies an ERC-M sends back to C2, in both flavours. The GS-232A
// form is two identical "+0nnn" groups running together with nothing between
// them, which is exactly the shape that makes a naive azimuth regex match the
// ELEVATION when the azimuth is read a second time.
func TestPositionReplies(t *testing.T) {
cases := []struct {
raw string
wantAz, wantEl int
}{
{"+0140+0032\r\n", 140, 32}, // GS-232A, the ERC-M's own form
{"+0000+0000\r", 0, 0}, // parked
{"AZ=140 EL=032\r\n", 140, 32}, // GS-232B flavour
{"AZ=005 EL=090\r\n", 5, 90}, // straight up
{"+0270+0180\r\n", 270, 180}, // past the zenith, still counting
{"\r\n+0075+0005\r\n", 75, 5}, // a leftover terminator ahead of it
{"+0450+0045\r\n", 90, 45}, // 450° mast in its overlap
}
for _, c := range cases {
m := bothRe.FindStringSubmatch(strings.TrimSpace(c.raw))
if m == nil {
t.Errorf("no position found in %q", c.raw)
continue
}
az, _ := strconv.Atoi(m[1])
el, _ := strconv.Atoi(m[2])
if az%360 != c.wantAz || el != c.wantEl {
t.Errorf("%q → az %d el %d, want az %d el %d", c.raw, az%360, el, c.wantAz, c.wantEl)
}
}
}
// A controller that answers C2 with the azimuth alone must NOT be read as
// "elevation zero" — that is a plausible-looking wrong answer, the antenna
// sitting on the horizon, and it would send the tracker chasing it.
func TestPositionRejectsAzimuthOnlyReply(t *testing.T) {
for _, raw := range []string{"+0140\r\n", "AZ=140\r\n", "?>\r\n"} {
if m := bothRe.FindStringSubmatch(strings.TrimSpace(raw)); m != nil {
t.Errorf("%q parsed as a two-axis reply (%v) — it is not one", raw, m[1:])
}
}
}
// The reply to a bare B, for the controllers that do not answer C2.
func TestElevationReplies(t *testing.T) {
cases := []struct {
raw string
want int
}{
{"+0032\r\n", 32},
{"EL=032\r\n", 32},
{"+0000\r", 0},
{"+0090\r\n", 90},
}
for _, c := range cases {
m := elRe.FindStringSubmatch(strings.TrimSpace(c.raw))
if m == nil {
t.Errorf("no elevation found in %q", c.raw)
continue
}
if got, _ := strconv.Atoi(m[1]); got != c.want {
t.Errorf("%q → %d, want %d", c.raw, got, c.want)
}
}
}
+71
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"net"
"strconv"
"strings"
"time"
)
@@ -85,6 +86,76 @@ func (c *Client) Heading() (az int, raw string, err error) {
return a, raw, nil
}
// Elevation queries PstRotator for the current elevation.
//
// Same shape as Heading, and the same port+1 listener — but a great many
// PstRotator setups drive an azimuth-only rotator and answer nothing at all,
// which is why the caller is expected to ask once and stop rather than wait a
// second and a half per poll for a reply that is never coming.
//
// The reply is matched on its LABEL and not on "the first number in it": AZ?
// and EL? both report on the same port, so taking the first integer of whatever
// arrives would happily read an azimuth as an elevation.
func (c *Client) Elevation() (el int, raw string, err error) {
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
if err != nil {
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
}
defer pc.Close()
if err := c.send("<PST>EL?</PST>"); err != nil {
return 0, "", fmt.Errorf("query PstRotator: %w", err)
}
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
buf := make([]byte, 512)
n, _, rerr := pc.ReadFrom(buf)
if rerr != nil {
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
}
raw = string(buf[:n])
v, ok := parseLabelled(raw, "EL", "AZ")
if !ok {
return 0, raw, fmt.Errorf("no elevation in reply %q", raw)
}
return v, raw, nil
}
// parseLabelled reads the number attached to a label — "EL:45", "EL 45",
// "<PST><ELEVATION>45</ELEVATION></PST>".
//
// The number is the first one AFTER the label, and false is returned when the
// label is absent — which is how an answer to the other question gets refused
// rather than read as this one.
func parseLabelled(s, label, other string) (int, bool) {
up := strings.ToUpper(s)
i := strings.Index(up, label)
if i < 0 {
return 0, false
}
// A reply carrying BOTH labels is answering the other question first; only
// what follows our own label counts.
rest := up[i+len(label):]
if j := strings.Index(rest, other); j >= 0 {
rest = rest[:j]
}
j := 0
for j < len(rest) && (rest[j] < '0' || rest[j] > '9') {
j++
}
k := j
for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' {
k++
}
if k == j {
return 0, false
}
n, err := strconv.Atoi(rest[j:k])
if err != nil {
return 0, false
}
return n, true
}
// parseAzimuth extracts the first integer found in a PstRotator reply
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
// it to [0,360).
+355
View File
@@ -0,0 +1,355 @@
package sat
import (
_ "embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// The frequency side of a satellite: what to listen on, what to transmit on,
// and how the two are tied together.
//
// The elements say where a bird is; this says what to do with the radio when it
// is there. They are separate on purpose — the elements change every few days
// and come from a feed, while a transponder plan changes when a satellite is
// commanded into another mode, which is a matter for the operator and AMSAT's
// published chart.
//
// The shipped list is a STARTING POINT, not an authority: satellites are
// switched between modes, transponders are turned off for a season, and new
// ones fly. It is 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.
//go:embed birds.json
var shippedBirds []byte
// BirdsName is the editable copy in the data directory.
const BirdsName = "satellites.json"
// Transponder is one usable path through a satellite.
type Transponder struct {
Label string `json:"label"`
Mode string `json:"mode"` // ADIF: FM, SSB, CW, DATA
// The downlink and uplink passbands, in Hz. A single frequency (an FM
// repeater, a beacon) sets only the "lo" of each side.
DownLo int64 `json:"down_lo"`
DownHi int64 `json:"down_hi,omitempty"`
UpLo int64 `json:"up_lo,omitempty"`
UpHi int64 `json:"up_hi,omitempty"`
// Inverting: the transponder turns the passband over, so tuning UP the
// downlink means going DOWN the uplink. Getting this backwards puts the
// operator's transmission at the far end of the passband from the station
// they can hear — which is the classic first evening on a linear bird.
Inverting bool `json:"inverting,omitempty"`
// CTCSS is the subaudible tone an FM uplink needs, in Hz. Zero = none.
CTCSS float64 `json:"ctcss,omitempty"`
}
// Linear reports a transponder with a passband rather than a single channel.
func (t Transponder) Linear() bool { return t.DownHi > t.DownLo && t.UpHi > t.UpLo }
// UplinkFor is where to transmit in order to be heard at downHz on the
// downlink.
//
// On a channel (FM) the answer is the uplink frequency, whatever the operator
// is tuned to. On a linear transponder it is a position in the passband — the
// same distance in from the edge, and from the OTHER edge when the transponder
// inverts.
func (t Transponder) UplinkFor(downHz int64) int64 {
if t.UpLo <= 0 {
return 0 // receive-only: a beacon, or a downlink we have no way to answer
}
if !t.Linear() {
return t.UpLo
}
if downHz < t.DownLo {
downHz = t.DownLo
}
if downHz > t.DownHi {
downHz = t.DownHi
}
offset := downHz - t.DownLo
if t.Inverting {
return t.UpHi - offset
}
return t.UpLo + offset
}
// DownlinkFor is the inverse: where a station transmitting at upHz comes out.
// It exists for the operator who tunes the uplink first — rarer, but the split
// has to be consistent whichever end they take hold of.
func (t Transponder) DownlinkFor(upHz int64) int64 {
if !t.Linear() {
return t.DownLo
}
if upHz < t.UpLo {
upHz = t.UpLo
}
if upHz > t.UpHi {
upHz = t.UpHi
}
if t.Inverting {
return t.DownLo + (t.UpHi - upHz)
}
return t.DownLo + (upHz - t.UpLo)
}
// Centre is the middle of the downlink passband — where to park when the
// operator picks a satellite and has not yet chosen a frequency in it.
func (t Transponder) Centre() int64 {
if !t.Linear() {
return t.DownLo
}
return t.DownLo + (t.DownHi-t.DownLo)/2
}
// Bird is one satellite's frequency plan.
type Bird struct {
Name string `json:"name"`
// NORAD is the catalog number, and the only exact way to find this
// satellite's elements: the feed, AMSAT and the operator all spell the NAME
// differently, while the number is carried inside the TLE itself. Aliases
// remain for the entries that predate it and for a hand-written plan.
NORAD int `json:"norad,omitempty"`
Aliases []string `json:"aliases,omitempty"`
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
// QO-100 is the reason the flag exists, and it changes what the whole
// tracking side does — there is nothing to predict and nothing to follow.
Geostationary bool `json:"geostationary,omitempty"`
Transponders []Transponder `json:"transponders"`
}
// Matches reports whether a name from an element feed is this satellite.
//
// The same rules Find uses, exposed for the other direction: the caller holds a
// bird and is scanning an element set spelled by somebody else.
func (b Bird) Matches(feedName string) bool {
cands := []string{feedName}
if i := strings.IndexByte(feedName, '('); i > 0 {
cands = append(cands, feedName[:i], strings.Trim(feedName[i:], "()"))
}
names := append([]string{b.Name}, b.Aliases...)
if i := strings.IndexByte(b.Name, '('); i > 0 {
names = append(names, b.Name[:i], strings.Trim(b.Name[i:], "()"))
}
for _, n := range names {
ln := loose(n)
if ln == "" {
continue
}
for _, c := range cands {
if ln == loose(c) {
return true
}
}
}
return false
}
// Birds is the frequency plan for every satellite the station knows.
type Birds struct {
mu sync.RWMutex
list []Bird
byKey map[string]int // name and aliases, loosely normalised → index in list
}
// loose is the matching form of a satellite name: upper case, letters and
// digits only.
//
// Feeds, AMSAT and operators all spell the same bird differently — "ES'HAIL 2",
// "ESHAIL-2", "Es'hail 2" — and none of them is wrong. Comparing the letters and
// digits alone is what lets the frequency plan meet the element set without a
// dozen aliases per satellite.
func loose(name string) string {
var b strings.Builder
for _, r := range strings.ToUpper(name) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
// LoadBirds reads the plan from the data directory, writing the shipped copy
// there first if there is none.
//
// A file the operator has broken is NOT overwritten: it is reported and the
// shipped list is used for this session, so a stray comma costs a correction
// rather than the corrections of the last two years.
func LoadBirds(dir string) (*Birds, error) {
b := &Birds{}
path := filepath.Join(dir, BirdsName)
data, err := os.ReadFile(path)
switch {
case err == nil:
if perr := b.parse(data); perr != nil {
_ = b.parse(shippedBirds)
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
}
// New satellites reach an EXISTING station too.
//
// The operator's copy is written once, on the first run, and was then
// theirs for ever — which meant a release that added nine Tevel-2
// satellites reached nobody who had already opened the tab. Merging on
// each load fixes that without taking anything back: a satellite the
// operator already has is left exactly as it is, edits included, and
// only the ones they have never seen are added. Deleting a bird from the
// file therefore brings it back, which is the price of the trade — and
// the cheaper half of it, since an unwanted satellite is one row and a
// missing one is a pass nobody can work.
if n := b.addMissing(shippedBirds); n > 0 {
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
_ = os.WriteFile(path, append(out, '\n'), 0o644)
}
}
return b, nil
case os.IsNotExist(err):
if perr := b.parse(shippedBirds); perr != nil {
return nil, perr
}
if werr := os.MkdirAll(dir, 0o755); werr == nil {
_ = os.WriteFile(path, shippedBirds, 0o644)
}
return b, nil
default:
_ = b.parse(shippedBirds)
return b, err
}
}
func (b *Birds) parse(data []byte) error {
var list []Bird
if err := json.Unmarshal(data, &list); err != nil {
return err
}
byKey := make(map[string]int, len(list)*3)
put := func(name string, i int) {
if k := loose(name); k != "" {
// First writer wins: a satellite's own name must never be displaced by
// another bird's alias.
if _, seen := byKey[k]; !seen {
byKey[k] = i
}
}
}
for i, bird := range list {
put(bird.Name, i)
}
for i, bird := range list {
for _, a := range bird.Aliases {
put(a, i)
}
// "RADFXSAT (FOX-1B)" is one string in the feed and two names to an
// operator; index both halves so either spelling finds the bird.
if j := strings.IndexByte(bird.Name, '('); j > 0 {
put(bird.Name[:j], i)
put(strings.Trim(bird.Name[j:], "()"), i)
}
}
b.mu.Lock()
defer b.mu.Unlock()
b.list, b.byKey = list, byKey
return nil
}
// Find looks a satellite up by name or alias.
//
// Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, so the
// bracketed halves are tried on their own before giving up — that is how most
// feed names differ from the name on the chart.
func (b *Birds) Find(name string) (Bird, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
try := func(s string) (Bird, bool) {
if i, ok := b.byKey[loose(s)]; ok {
return b.list[i], true
}
return Bird{}, false
}
if bird, ok := try(name); ok {
return bird, true
}
if i := strings.IndexByte(name, '('); i > 0 {
if bird, ok := try(name[:i]); ok {
return bird, true
}
if bird, ok := try(strings.Trim(name[i:], "()")); ok {
return bird, true
}
}
return Bird{}, false
}
// All lists the plan, in name order.
func (b *Birds) All() []Bird {
b.mu.RLock()
defer b.mu.RUnlock()
out := append([]Bird(nil), b.list...)
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Len is how many satellites carry a frequency plan.
func (b *Birds) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.list)
}
// addMissing appends the satellites in `shipped` that this list does not already
// hold, and reports how many were added.
//
// "Already hold" is by catalog number first and by the loose name second, so an
// operator who renamed a bird, or who has it under the feed's spelling, does not
// get a second copy of it. Nothing existing is touched: their frequencies, their
// labels and their corrections all stand.
func (b *Birds) addMissing(shipped []byte) int {
var list []Bird
if err := json.Unmarshal(shipped, &list); err != nil {
return 0
}
b.mu.Lock()
have := make(map[int]bool, len(b.list))
for _, x := range b.list {
if x.NORAD != 0 {
have[x.NORAD] = true
}
}
added := 0
for _, cand := range list {
if cand.NORAD != 0 && have[cand.NORAD] {
continue
}
known := false
for _, name := range append([]string{cand.Name}, cand.Aliases...) {
if k := loose(name); k != "" {
if _, ok := b.byKey[k]; ok {
known = true
break
}
}
}
if known {
continue
}
b.list = append(b.list, cand)
if cand.NORAD != 0 {
have[cand.NORAD] = true
}
if k := loose(cand.Name); k != "" {
b.byKey[k] = len(b.list) - 1
}
added++
}
b.mu.Unlock()
return added
}
+706
View File
@@ -0,0 +1,706 @@
[
{
"name": "AO-123",
"norad": 61781,
"aliases": [
"ASRTU-1 (AO-123)"
],
"transponders": [
{
"label": "Mode V/U - FM Transceiver",
"mode": "FM",
"down_lo": 435400000,
"up_lo": 145850000
}
]
},
{
"name": "AO-27",
"norad": 22825,
"aliases": [
"EYESAT A (AO-27)"
],
"transponders": [
{
"label": "Mode V/U FM",
"mode": "FM",
"down_lo": 436795000,
"up_lo": 145850000
}
]
},
{
"name": "AO-7",
"norad": 7530,
"aliases": [
"AMSAT-OSCAR 7",
"OSCAR 7"
],
"transponders": [
{
"label": "Mode B linear (inverting)",
"mode": "SSB",
"down_lo": 145925000,
"down_hi": 145975000,
"up_lo": 432125000,
"up_hi": 432175000,
"inverting": true
},
{
"label": "Mode A linear",
"mode": "SSB",
"down_lo": 29400000,
"down_hi": 29500000,
"up_lo": 145850000,
"up_hi": 145950000
}
]
},
{
"name": "AO-73",
"norad": 39444,
"aliases": [
"FUNCUBE-1",
"FUNCUBE 1"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145950000,
"down_hi": 145970000,
"up_lo": 435130000,
"up_hi": 435150000,
"inverting": true
}
]
},
{
"name": "AO-91",
"norad": 43017,
"aliases": [
"RADFXSAT",
"FOX-1B",
"RADFXSAT (FOX-1B)"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 145960000,
"up_lo": 435250000,
"ctcss": 67
}
]
},
{
"name": "BEESAT-1",
"norad": 35933,
"transponders": [
{
"label": "Digipeater (Mobitex, idle mode: 10 sec interval)",
"mode": "DATA",
"down_lo": 435950000,
"up_lo": 435950000
}
]
},
{
"name": "CO-65",
"norad": 32785,
"aliases": [
"CUTE-1.7+APD II (CO-65)"
],
"transponders": [
{
"label": "Mode L/U Digipeater",
"mode": "DATA",
"down_lo": 437475000,
"up_lo": 1267600000
}
]
},
{
"name": "CROCUBE",
"norad": 62394,
"transponders": [
{
"label": "Mode U/U - GFSK9k6 - Digipeater - AX.25",
"mode": "DATA",
"down_lo": 436775000,
"up_lo": 436775000
}
]
},
{
"name": "CSS",
"norad": 48274,
"transponders": [
{
"label": "3A V/V digipeater AFSK-FM 1200",
"mode": "DATA",
"down_lo": 145825000,
"up_lo": 145825000
},
{
"label": "1A V/V crew voice NFM",
"mode": "FM",
"down_lo": 145985000,
"up_lo": 145850000
},
{
"label": "2B U/V FM repeater NFM",
"mode": "FM",
"down_lo": 145985000,
"up_lo": 435075000
},
{
"label": "4A V/V imaging SSTV-FM",
"mode": "FM",
"down_lo": 145985000,
"up_lo": 145850000
},
{
"label": "1B U/U crew voice NFM",
"mode": "FM",
"down_lo": 436510000,
"up_lo": 435050000
},
{
"label": "2A V/U FM repeater NFM",
"mode": "FM",
"down_lo": 436510000,
"up_lo": 145875000
},
{
"label": "4B U/U imaging SSTV-FM",
"mode": "FM",
"down_lo": 436510000,
"up_lo": 435050000
},
{
"label": "3B U/U digipeater AFSK-FM 1200",
"mode": "DATA",
"down_lo": 437550000,
"up_lo": 437550000
}
]
},
{
"name": "ESEO",
"norad": 43792,
"transponders": [
{
"label": "FM",
"mode": "FM",
"down_lo": 145895000,
"up_lo": 1263500000
}
]
},
{
"name": "FLORIPASAT-1",
"norad": 44885,
"transponders": [
{
"label": "Mode U/U GFSK2k4 Repeater",
"mode": "DATA",
"down_lo": 436100000,
"up_lo": 436100000
}
]
},
{
"name": "FO-29",
"norad": 24278,
"aliases": [
"JAS-2",
"FUJI-OSCAR 29"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 435800000,
"down_hi": 435900000,
"up_lo": 145900000,
"up_hi": 146000000,
"inverting": true
}
]
},
{
"name": "FORESAIL-1P",
"norad": 66778,
"transponders": [
{
"label": "Mode U/U - GMSK9k6 - Digipeater - Skylink",
"mode": "DATA",
"down_lo": 437125000,
"up_lo": 437125000
}
]
},
{
"name": "GRBBETA",
"norad": 60237,
"transponders": [
{
"label": "Mode V/V - GFSK9k6 - Digipeater - AX.25",
"mode": "DATA",
"down_lo": 145935000,
"up_lo": 145935000
},
{
"label": "Mode U/U - GFSK9k6 - Digipeater - AX.25",
"mode": "DATA",
"down_lo": 436785000,
"up_lo": 436785000
}
]
},
{
"name": "IO-117",
"norad": 53109,
"aliases": [
"GREENCUBE",
"MEZTLI"
],
"transponders": [
{
"label": "Digipeater (1200 bd GMSK)",
"mode": "DATA",
"down_lo": 435310000,
"up_lo": 435310000
}
]
},
{
"name": "IO-86",
"norad": 40931,
"aliases": [
"LAPAN-A2",
"LAPAN-ORARI"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 435880000,
"up_lo": 145880000,
"ctcss": 88.5
}
]
},
{
"name": "ISAT",
"norad": 43879,
"transponders": [
{
"label": "MODE U/U DSTAR VOICE",
"mode": "FM",
"down_lo": 435525000,
"up_lo": 437325000
}
]
},
{
"name": "ISS (ZARYA)",
"norad": 25544,
"aliases": [
"ISS",
"ZARYA",
"ARISS"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 437800000,
"up_lo": 145990000,
"ctcss": 67
},
{
"label": "APRS digipeater",
"mode": "DATA",
"down_lo": 145825000,
"up_lo": 145825000
},
{
"label": "SSTV",
"mode": "FM",
"down_lo": 145800000
}
]
},
{
"name": "JO-97",
"norad": 43803,
"aliases": [
"JY1SAT",
"JY1-SAT"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145855000,
"down_hi": 145875000,
"up_lo": 435100000,
"up_hi": 435120000,
"inverting": true
}
]
},
{
"name": "KNACKSAT-2",
"norad": 67683,
"transponders": [
{
"label": "Mode V/V - FSK9k6 - Digipeater - AX.25 G3RUH",
"mode": "DATA",
"down_lo": 145825000,
"up_lo": 145825000
}
]
},
{
"name": "KOSEN-1",
"norad": 49402,
"transponders": [
{
"label": "Mode HF/U - Onboard SDR",
"mode": "DATA",
"down_lo": 435525000,
"up_lo": 21125000,
"up_hi": 21150000
}
]
},
{
"name": "LASARSAT",
"norad": 62391,
"transponders": [
{
"label": "Mode U/U - GFSK9k6 - Digipeater - AX.25",
"mode": "DATA",
"down_lo": 436925000,
"up_lo": 436925000
}
]
},
{
"name": "LILACSAT-2",
"norad": 40908,
"transponders": [
{
"label": "APRS Digipeater",
"mode": "DATA",
"down_lo": 144390000,
"up_lo": 144390000
}
]
},
{
"name": "NO-44",
"norad": 26931,
"aliases": [
"PCSAT (NO-44)"
],
"transponders": [
{
"label": "Mode V/V APRS AFSK",
"mode": "DATA",
"down_lo": 145825000,
"up_lo": 145825000
}
]
},
{
"name": "PO-101",
"norad": 43678,
"aliases": [
"DIWATA-2",
"DIWATA-2B"
],
"transponders": [
{
"label": "FM voice repeater (scheduled)",
"mode": "FM",
"down_lo": 145900000,
"up_lo": 437500000,
"ctcss": 141.3
}
]
},
{
"name": "QB50P1",
"norad": 40025,
"transponders": [
{
"label": "Linear Transponder",
"mode": "SSB",
"down_lo": 145935000,
"down_hi": 145965000,
"up_lo": 435047000,
"up_hi": 435077000,
"inverting": true
}
]
},
{
"name": "QMR-KWT-2",
"norad": 67291,
"transponders": [
{
"label": "V/U FM Transponder CTCSS 67.0 Hz",
"mode": "FM",
"down_lo": 436950000,
"up_lo": 145920000,
"ctcss": 67
}
]
},
{
"name": "QO-100",
"norad": 43700,
"aliases": [
"ES'HAIL 2",
"ESHAIL 2",
"ES'HAIL-2"
],
"geostationary": true,
"transponders": [
{
"label": "Narrowband linear",
"mode": "SSB",
"down_lo": 10489550000,
"down_hi": 10489800000,
"up_lo": 2400050000,
"up_hi": 2400300000
},
{
"label": "Wideband (DATV)",
"mode": "DATA",
"down_lo": 10491000000,
"down_hi": 10499000000,
"up_lo": 2401500000,
"up_hi": 2409500000
}
]
},
{
"name": "RS-44",
"norad": 44909,
"aliases": [
"DOSAAF-85"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 435640000,
"down_hi": 435680000,
"up_lo": 145965000,
"up_hi": 146005000,
"inverting": true
}
]
},
{
"name": "SO-50",
"norad": 27607,
"aliases": [
"SAUDISAT 1C",
"SAUDISAT 1C (SO-50)"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436795000,
"up_lo": 145850000,
"ctcss": 67
}
]
},
{
"name": "SONATE-2",
"norad": 59112,
"transponders": [
{
"label": "Mode V/V - APRS digipeater",
"mode": "DATA",
"down_lo": 145825000,
"up_lo": 145825000
}
]
},
{
"name": "TAURUS-1",
"norad": 44530,
"transponders": [
{
"label": "Mode V/U FM 67.0 PL",
"mode": "FM",
"down_lo": 436760000,
"up_lo": 145820000,
"ctcss": 67
}
]
},
{
"name": "TEVEL2-1",
"norad": 63217,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-2",
"norad": 63219,
"transponders": [
{
"label": "Mode V/U - FM Transponder - Beacon",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-3",
"norad": 63218,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-4",
"norad": 63213,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-5",
"norad": 63214,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-6",
"norad": 63215,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-7",
"norad": 63238,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-8",
"norad": 63239,
"transponders": [
{
"label": "Mode V/U - FM Transponder - Beacon",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TEVEL2-9",
"norad": 63237,
"transponders": [
{
"label": "Mode V/U - FM Transponder",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000
}
]
},
{
"name": "TO-108",
"norad": 44881,
"aliases": [
"CAS-6",
"TIANQIN-1"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145915000,
"down_hi": 145935000,
"up_lo": 435270000,
"up_hi": 435290000,
"inverting": true
}
]
},
{
"name": "UKUBE-1",
"norad": 40074,
"transponders": [
{
"label": "Inverting linear transponder",
"mode": "SSB",
"down_lo": 145930000,
"down_hi": 145950000,
"up_lo": 435074300,
"up_hi": 435094300,
"inverting": true
}
]
},
{
"name": "XIWANG-1 (HOPE-1)",
"norad": 36122,
"transponders": [
{
"label": "Mode V/U FM",
"mode": "FM",
"down_lo": 435675000,
"up_lo": 145825000
}
]
}
]
+270
View File
@@ -0,0 +1,270 @@
package sat
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The shipped list has to be readable and consistent — it is embedded, so a
// mistake in it is a mistake in every build.
func TestShippedBirds(t *testing.T) {
b := &Birds{}
if err := b.parse(shippedBirds); err != nil {
t.Fatalf("birds.json does not parse: %v", err)
}
if b.Len() < 5 {
t.Fatalf("only %d satellites shipped", b.Len())
}
for _, bird := range b.All() {
if len(bird.Transponders) == 0 {
t.Errorf("%s has no transponder", bird.Name)
}
for _, tr := range bird.Transponders {
if tr.DownLo <= 0 {
t.Errorf("%s / %s: no downlink", bird.Name, tr.Label)
}
if tr.DownHi != 0 && tr.DownHi <= tr.DownLo {
t.Errorf("%s / %s: downlink passband runs backwards", bird.Name, tr.Label)
}
if tr.UpHi != 0 && tr.UpHi <= tr.UpLo {
t.Errorf("%s / %s: uplink passband runs backwards", bird.Name, tr.Label)
}
// A linear transponder whose two passbands are different widths cannot
// map one onto the other, and the split would drift across the pass.
if tr.Linear() && (tr.DownHi-tr.DownLo) != (tr.UpHi-tr.UpLo) {
t.Errorf("%s / %s: passbands are %d and %d Hz wide",
bird.Name, tr.Label, tr.DownHi-tr.DownLo, tr.UpHi-tr.UpLo)
}
}
}
}
func TestFindByAlias(t *testing.T) {
b := &Birds{}
if err := b.parse(shippedBirds); err != nil {
t.Fatal(err)
}
// Every spelling on the left is one an operator or a feed actually uses.
for _, tc := range []struct{ query, want string }{
{"AO-91", "AO-91"},
{"RADFXSAT (FOX-1B)", "AO-91"},
{"radfxsat", "AO-91"},
{"ISS (ZARYA)", "ISS (ZARYA)"},
{"ISS", "ISS (ZARYA)"},
{"SAUDISAT 1C (SO-50)", "SO-50"},
{"so 50", "SO-50"},
{"QO-100", "QO-100"},
{"ESHAIL-2", "QO-100"},
{"Es'hail 2", "QO-100"},
} {
got, ok := b.Find(tc.query)
if !ok {
t.Errorf("%q was not found", tc.query)
continue
}
if got.Name != tc.want {
t.Errorf("%q found %q, wanted %q", tc.query, got.Name, tc.want)
}
}
if _, ok := b.Find("NOAA 15"); ok {
t.Error("a weather satellite should not carry an amateur frequency plan")
}
}
// Matches is the other direction: a bird in hand, scanning a feed's names.
func TestBirdMatches(t *testing.T) {
b := Bird{Name: "AO-91", Aliases: []string{"RADFXSAT", "FOX-1B"}}
for _, feed := range []string{"AO-91", "RADFXSAT (FOX-1B)", "radfxsat", "FOX 1B"} {
if !b.Matches(feed) {
t.Errorf("%q was not recognised as AO-91", feed)
}
}
for _, feed := range []string{"AO-92", "NOAA 15", "FOX-1A"} {
if b.Matches(feed) {
t.Errorf("%q was wrongly taken for AO-91", feed)
}
}
// A bracketed catalogue name matched from the other side.
iss := Bird{Name: "ISS (ZARYA)"}
if !iss.Matches("ISS") || !iss.Matches("ZARYA") {
t.Error("the ISS was not recognised by either half of its catalogue name")
}
}
// The uplink maths is the part that matters on the air: a station worked at one
// end of an inverting transponder has to be answered at the other.
func TestUplinkFor(t *testing.T) {
inv := Transponder{
DownLo: 435800000, DownHi: 435900000,
UpLo: 145900000, UpHi: 146000000,
Inverting: true,
}
straight := Transponder{
DownLo: 29400000, DownHi: 29500000,
UpLo: 145850000, UpHi: 145950000,
}
fm := Transponder{DownLo: 436795000, UpLo: 145850000}
for _, tc := range []struct {
name string
tr Transponder
down int64
want int64
}{
{"inverting, bottom of the downlink", inv, 435800000, 146000000},
{"inverting, top of the downlink", inv, 435900000, 145900000},
{"inverting, 30 kHz up", inv, 435830000, 145970000},
{"straight, bottom", straight, 29400000, 145850000},
{"straight, 25 kHz up", straight, 29425000, 145875000},
{"FM channel ignores the tuned downlink", fm, 436798000, 145850000},
{"below the passband is clamped", inv, 435700000, 146000000},
{"above the passband is clamped", inv, 436000000, 145900000},
} {
if got := tc.tr.UplinkFor(tc.down); got != tc.want {
t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want)
}
}
// Receive-only: a beacon has nothing to answer on.
if got := (Transponder{DownLo: 145800000}).UplinkFor(145800000); got != 0 {
t.Errorf("a receive-only transponder gave an uplink of %d", got)
}
}
// Whichever end the operator takes hold of, the pair has to agree.
func TestDownlinkForRoundTrip(t *testing.T) {
for _, tr := range []Transponder{
{DownLo: 435800000, DownHi: 435900000, UpLo: 145900000, UpHi: 146000000, Inverting: true},
{DownLo: 29400000, DownHi: 29500000, UpLo: 145850000, UpHi: 145950000},
} {
for _, down := range []int64{tr.DownLo, tr.Centre(), tr.DownHi} {
if got := tr.DownlinkFor(tr.UplinkFor(down)); got != down {
t.Errorf("inverting=%v: %d → uplink → %d", tr.Inverting, down, got)
}
}
}
}
func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
dir := t.TempDir()
b, err := LoadBirds(dir)
if err != nil {
t.Fatal(err)
}
shipped := b.Len()
path := filepath.Join(dir, BirdsName)
if _, err := os.Stat(path); err != nil {
t.Fatalf("the editable copy was not written: %v", err)
}
// An operator's own list is kept, AND the shipped satellites they have never
// seen are added to it.
//
// The merge is what carries a new satellite to a station that has already
// run OpsLog once: without it, the copy written on the very first launch was
// the operator's list for ever, and a release adding nine Tevel-2 birds
// reached nobody. What it must never do is take something back — so the
// operator's own satellite, and their correction to a shipped one, both have
// to survive it.
mine := `[
{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]},
{"name":"SO-50","norad":27607,"transponders":[{"label":"corrected","mode":"FM","down_lo":436796000,"up_lo":145850000,"ctcss":74.4}]}
]`
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
t.Fatal(err)
}
b, err = LoadBirds(dir)
if err != nil {
t.Fatal(err)
}
if b.Len() < shipped {
t.Fatalf("the shipped satellites were not merged in: %d, expected at least %d", b.Len(), shipped)
}
if _, ok := b.Find("MY-SAT"); !ok {
t.Error("the operator's own satellite was dropped by the merge")
}
// Their correction stands: same tone, same frequency, not the shipped one.
got, ok := b.Find("SO-50")
if !ok {
t.Fatal("SO-50 vanished")
}
if len(got.Transponders) != 1 || got.Transponders[0].CTCSS != 74.4 || got.Transponders[0].DownLo != 436796000 {
t.Errorf("the operator's correction to SO-50 was overwritten: %+v", got.Transponders)
}
// And a shipped satellite they had never seen is now there.
if _, ok := b.Find("AO-7"); !ok {
t.Error("a shipped satellite was not added to the operator's list")
}
// And a broken one falls back without destroying what they wrote.
if err := os.WriteFile(path, []byte("[{oops"), 0o644); err != nil {
t.Fatal(err)
}
b, err = LoadBirds(dir)
if err == nil {
t.Error("a broken list was accepted silently")
}
if b.Len() != shipped {
t.Errorf("the shipped list did not take over: %d satellites", b.Len())
}
if data, _ := os.ReadFile(path); string(data) != "[{oops" {
t.Error("the operator's broken file was overwritten")
}
}
// The shipped plan is generated (cmd/satgen) from public databases, so it is
// worth its own guard, on top of TestShippedBirds above: a bad regeneration
// should fail here rather than mistune an antenna on somebody first pass.
func TestGeneratedBirdsAreSane(t *testing.T) {
var list []Bird
if err := json.Unmarshal(shippedBirds, &list); err != nil {
t.Fatalf("birds.json does not parse: %v", err)
}
// The generator joins three feeds. If one of them answered with nothing, the
// output silently shrinks, and this is where that shows up.
if len(list) < 30 {
t.Errorf("only %d satellites shipped — the generator probably ran against an empty feed", len(list))
}
seenNORAD := map[int]string{}
seenName := map[string]bool{}
for _, b := range list {
if b.Name == "" {
t.Error("a satellite with no name")
}
if seenName[loose(b.Name)] {
t.Errorf("%s appears twice", b.Name)
}
seenName[loose(b.Name)] = true
// Two entries for one catalog number is one satellite the operator can
// pick twice, with two different sets of frequencies.
if b.NORAD != 0 {
if other, dup := seenNORAD[b.NORAD]; dup {
t.Errorf("NORAD %d is both %s and %s", b.NORAD, other, b.Name)
}
seenNORAD[b.NORAD] = b.Name
}
for _, tp := range b.Transponders {
// An amateur satellite works between 15 m and 24 GHz. Anything outside
// that is a units mistake, and a units mistake is how a rig ends up
// commanded somewhere it cannot go.
for _, hz := range []int64{tp.DownLo, tp.DownHi, tp.UpLo, tp.UpHi} {
if hz != 0 && (hz < 21_000_000 || hz > 24_000_000_000) {
t.Errorf("%s: %d Hz is not an amateur satellite frequency", b.Name, hz)
}
}
// Inversion only means something across a passband. The code ignores
// the flag on a channel, but a file that claims an FM repeater inverts
// will mislead whoever reads it next.
if tp.Inverting && !tp.Linear() {
t.Errorf("%s: %q is a channel and cannot invert", b.Name, tp.Label)
}
switch tp.Mode {
case "FM", "SSB", "CW", "DATA":
default:
t.Errorf("%s: %q is not an ADIF mode the log can store", b.Name, tp.Mode)
}
}
}
}
+393
View File
@@ -0,0 +1,393 @@
// Package sat is where a satellite is, where it will be, and what that does to
// a frequency.
//
// Three things live here and nothing else: the orbital elements a station keeps
// (Store), the sky as seen from that station (Track, Passes), and the Doppler
// shift the motion imposes (Shift). The radio, the rotator and the screen are
// all somebody else's business — they are handed numbers by the app layer.
//
// The propagation itself is SGP4 from github.com/akhenakh/sgp4 (Apache-2.0,
// pure Go): the model everyone in this hobby uses, and the one the TLEs are
// built for. Writing it again would be writing it worse.
package sat
import (
"fmt"
"math"
"sort"
"strings"
"sync"
"time"
"github.com/akhenakh/sgp4"
)
// speedOfLightKmS is the constant every Doppler correction here is built on.
const speedOfLightKmS = 299792.458
// Observer is the ground station: where the antenna is, in degrees and metres.
type Observer struct {
Lat, Lon float64
AltM float64
}
// Position is a satellite seen from the ground at one instant.
//
// The two halves answer different questions and both are wanted: where the
// thing IS (for the map) and where to POINT (for the rotator and the Doppler).
type Position struct {
Name string `json:"name"`
At time.Time `json:"at"`
// Sub-satellite point and height — the map's half.
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
AltKm float64 `json:"alt_km"`
Footprint float64 `json:"footprint_km"` // radius of the visibility circle
// Look angles — the station's half.
Az float64 `json:"az"`
El float64 `json:"el"`
RangeKm float64 `json:"range_km"`
RangeRate float64 `json:"range_rate"` // km/s, positive = receding
}
// Visible reports whether the satellite is above the horizon.
//
// Zero degrees, not a courtesy margin: an operator with a clear take-off works
// a pass from the moment it rises, and a station in a valley knows its own
// horizon better than this package ever will.
func (p Position) Visible() bool { return p.El > 0 }
// Pass is one crossing of the sky, from rise to set.
type Pass struct {
Name string `json:"name"`
AOS time.Time `json:"aos"`
LOS time.Time `json:"los"`
AOSAz float64 `json:"aos_az"`
LOSAz float64 `json:"los_az"`
MaxEl float64 `json:"max_el"`
MaxElAz float64 `json:"max_el_az"`
MaxElAt time.Time `json:"max_el_at"`
Duration float64 `json:"duration_s"`
}
// Element is one satellite's orbital elements, as they were published.
//
// The raw lines are kept beside the parsed form because they are what gets
// written to the cache and what an operator pastes in by hand for a bird that
// is not in any feed yet — a freshly launched one, above all, which is exactly
// when everybody wants to hear it.
type Element struct {
Name string `json:"name"`
NORAD int `json:"norad"`
Line1 string `json:"line1"`
Line2 string `json:"line2"`
// Epoch is when these elements were computed. Their accuracy falls away
// from it, which is why the store knows how old they are.
Epoch time.Time `json:"epoch"`
tle *sgp4.TLE
}
// Age is how long ago these elements were computed.
func (e Element) Age() time.Duration {
if e.Epoch.IsZero() {
return 0
}
return time.Since(e.Epoch)
}
// ParseElement reads one satellite from its two or three TLE lines.
func ParseElement(name, line1, line2 string) (Element, error) {
name = strings.TrimSpace(name)
line1 = strings.TrimSpace(line1)
line2 = strings.TrimSpace(line2)
if line1 == "" || line2 == "" {
return Element{}, fmt.Errorf("sat: %q has no orbital elements", name)
}
raw := line1 + "\n" + line2
if name != "" {
raw = name + "\n" + raw
}
t, err := sgp4.ParseTLE(raw)
if err != nil {
return Element{}, fmt.Errorf("sat: %q: %w", name, err)
}
if name == "" {
name = strings.TrimSpace(t.Name)
}
return Element{
Name: name,
NORAD: t.SatelliteNumber,
Line1: line1,
Line2: line2,
Epoch: tleEpoch(t),
tle: t,
}, nil
}
// tleEpoch turns the two-digit year and fractional day of a TLE into a time.
//
// The pivot is the one the format itself defines: 57 and above is the twentieth
// century, below it the twenty-first. It matters for the AGE of the elements,
// which is how an operator knows whether to trust a prediction.
func tleEpoch(t *sgp4.TLE) time.Time {
if t == nil || t.EpochDay <= 0 {
return time.Time{}
}
year := t.EpochYear
switch {
case year >= 57 && year <= 99:
year += 1900
case year < 57:
year += 2000
}
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
return start.Add(time.Duration((t.EpochDay - 1) * float64(24*time.Hour)))
}
// Store holds the elements a station tracks. Safe for concurrent use: the app
// refreshes it from a feed while the tracking loop reads it several times a
// second.
type Store struct {
mu sync.RWMutex
byKey map[string]Element
order []string // insertion order, so a listing reads like the feed
fetch time.Time
}
func NewStore() *Store { return &Store{byKey: map[string]Element{}} }
// key is how a satellite is addressed. Case and spacing vary between feeds and
// between the operator's typing; the NORAD number would be exact but is not
// what anybody says out loud.
func key(name string) string { return strings.ToUpper(strings.TrimSpace(name)) }
// Put adds or replaces one satellite's elements.
func (s *Store) Put(e Element) {
if e.tle == nil || e.Name == "" {
return
}
k := key(e.Name)
s.mu.Lock()
defer s.mu.Unlock()
if _, had := s.byKey[k]; !had {
s.order = append(s.order, k)
}
s.byKey[k] = e
}
// Get returns one satellite's elements.
func (s *Store) Get(name string) (Element, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.byKey[key(name)]
return e, ok
}
// GetNORAD returns one satellite's elements by catalog number.
//
// The exact join, and the only one that stays exact. A name is written
// differently by every party involved — the feed says "RADFXSAT (FOX-1B)", the
// operator says "AO-91", AMSAT's chart says both — and matching on letters and
// digits gets most of them and quietly misses the rest. The catalog number is
// in the TLE itself and is what a frequency plan should carry.
func (s *Store) GetNORAD(n int) (Element, bool) {
if n <= 0 {
return Element{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, k := range s.order {
if e := s.byKey[k]; e.NORAD == n {
return e, true
}
}
return Element{}, false
}
// Names lists what the store holds, in the order it arrived.
func (s *Store) Names() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.order))
for _, k := range s.order {
out = append(out, s.byKey[k].Name)
}
return out
}
// Len is how many satellites are known.
func (s *Store) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.byKey)
}
// FetchedAt is when the elements were last loaded from a feed, zero if never.
func (s *Store) FetchedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.fetch
}
// Replace swaps the whole set — what a feed refresh does. The order of the new
// set is kept, and the fetch time is stamped.
func (s *Store) Replace(els []Element, at time.Time) {
byKey := make(map[string]Element, len(els))
order := make([]string, 0, len(els))
for _, e := range els {
if e.tle == nil || e.Name == "" {
continue
}
k := key(e.Name)
if _, had := byKey[k]; !had {
order = append(order, k)
}
byKey[k] = e
}
s.mu.Lock()
defer s.mu.Unlock()
s.byKey, s.order, s.fetch = byKey, order, at
}
// Track is where one satellite is, seen from one station, at one instant.
func (s *Store) Track(name string, obs Observer, at time.Time) (Position, error) {
e, ok := s.Get(name)
if !ok {
return Position{}, fmt.Errorf("sat: %q is not in the element set", name)
}
return e.Track(obs, at)
}
// Track is the same for elements already in hand.
func (e Element) Track(obs Observer, at time.Time) (Position, error) {
if e.tle == nil {
return Position{}, fmt.Errorf("sat: %q has no usable elements", e.Name)
}
loc := &sgp4.Location{Latitude: obs.Lat, Longitude: obs.Lon, Altitude: obs.AltM}
eci, err := e.tle.FindPositionAtTime(at.UTC())
if err != nil {
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
}
// The state vector carries the position AND the velocity, which is what the
// look angle needs for the range rate — and the range rate is the whole of
// the Doppler shift.
sv := &sgp4.StateVector{
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
}
o, err := sv.GetLookAngle(loc, at.UTC())
if err != nil {
return Position{}, fmt.Errorf("sat: %q look angle: %w", e.Name, err)
}
return Position{
Name: e.Name,
At: at.UTC(),
Lat: o.SatellitePos.Latitude,
Lon: o.SatellitePos.Longitude,
AltKm: o.SatellitePos.Altitude,
Footprint: footprintKm(o.SatellitePos.Altitude),
Az: o.LookAngles.Azimuth,
El: o.LookAngles.Elevation,
RangeKm: o.LookAngles.Range,
RangeRate: o.LookAngles.RangeRate,
}, nil
}
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
// sphere, and a metre of flattening does not show at that scale.
const earthRadiusKm = 6371.0
// footprintKm is the radius of the circle from which the satellite is above the
// horizon: the ground distance to where it sits exactly on it.
func footprintKm(altKm float64) float64 {
if altKm <= 0 {
return 0
}
return earthRadiusKm * math.Acos(earthRadiusKm/(earthRadiusKm+altKm))
}
// Passes lists the crossings of the sky between two instants.
//
// minEl drops the passes not worth waiting for: a bird that scrapes three
// degrees over the horizon is a line in a table that will never be a QSO, and
// on a busy evening those are most of the list.
func (s *Store) Passes(name string, obs Observer, from, to time.Time, minEl float64) ([]Pass, error) {
e, ok := s.Get(name)
if !ok {
return nil, fmt.Errorf("sat: %q is not in the element set", name)
}
if !to.After(from) {
return nil, fmt.Errorf("sat: the window ends before it starts")
}
// Thirty seconds: fine enough that the rise and set times are right to a few
// seconds, coarse enough that a day of predictions for a dozen satellites
// stays instant.
details, err := e.tle.GeneratePasses(obs.Lat, obs.Lon, obs.AltM, from.UTC(), to.UTC(), 30)
if err != nil {
return nil, fmt.Errorf("sat: %q passes: %w", e.Name, err)
}
out := make([]Pass, 0, len(details))
for _, d := range details {
if d.MaxElevation < minEl {
continue
}
out = append(out, Pass{
Name: e.Name,
AOS: d.AOS.UTC(),
LOS: d.LOS.UTC(),
AOSAz: d.AOSAzimuth,
LOSAz: d.LOSAzimuth,
MaxEl: d.MaxElevation,
MaxElAz: d.MaxElevationAz,
MaxElAt: d.MaxElevationTime.UTC(),
Duration: d.Duration.Seconds(),
})
}
return out, nil
}
// NextPasses is Passes over several satellites at once, in time order — the
// question an operator actually asks: what is coming, and when.
func (s *Store) NextPasses(names []string, obs Observer, from time.Time, window time.Duration, minEl int) []Pass {
var all []Pass
for _, n := range names {
ps, err := s.Passes(n, obs, from, from.Add(window), float64(minEl))
if err != nil {
continue // a satellite whose elements are missing is simply not listed
}
all = append(all, ps...)
}
sort.Slice(all, func(i, j int) bool { return all[i].AOS.Before(all[j].AOS) })
return all
}
// Shift is the Doppler-corrected pair for one moment.
type Shift struct {
DownHz int64 `json:"down_hz"` // where to LISTEN for a nominal downlink
UpHz int64 `json:"up_hz"` // where to TRANSMIT for a nominal uplink
}
// Doppler corrects a nominal uplink/downlink pair for the satellite's motion.
//
// Two corrections, opposite in sign, and that is the part worth being careful
// about: the DOWNLINK is what we receive, so it arrives shifted and we tune to
// meet it — approaching (negative range rate) means a higher frequency. The
// UPLINK is what the satellite receives, so we must transmit shifted the other
// way for it to land on the transponder's nominal input.
//
// Zero in, zero out: a satellite with no uplink (a beacon) is not given an
// invented one.
func Doppler(p Position, downHz, upHz int64) Shift {
f := -p.RangeRate / speedOfLightKmS // fraction, positive when approaching
var s Shift
if downHz > 0 {
s.DownHz = downHz + int64(math.Round(float64(downHz)*f))
}
if upHz > 0 {
s.UpHz = upHz - int64(math.Round(float64(upHz)*f))
}
return s
}
+172
View File
@@ -0,0 +1,172 @@
package sat
import (
"math"
"testing"
"time"
)
// A real ISS element set, and the answers a second tracker agrees with. The
// point is not the third decimal — it is that the observer, the epoch and the
// look angle are wired the right way round, which is exactly what silently
// comes out mirrored or an hour late.
const (
issName = "ISS (ZARYA)"
issLine1 = "1 25544U 98067A 24298.54791435 .00016717 00000+0 30074-3 0 9991"
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
)
func issElement(t *testing.T) Element {
t.Helper()
e, err := ParseElement(issName, issLine1, issLine2)
if err != nil {
t.Fatalf("parse: %v", err)
}
return e
}
func TestElementCarriesItsIdentityAndEpoch(t *testing.T) {
e := issElement(t)
if e.NORAD != 25544 {
t.Errorf("NORAD = %d, want 25544", e.NORAD)
}
// Day 298.548 of 2024 — the day the elements were computed.
want := time.Date(2024, 10, 24, 13, 9, 0, 0, time.UTC)
if d := e.Epoch.Sub(want); d > time.Minute || d < -time.Minute {
t.Errorf("epoch = %s, want about %s", e.Epoch.Format(time.RFC3339), want.Format(time.RFC3339))
}
}
// The satellite is somewhere, that somewhere is on Earth's scale, and the look
// angles are self-consistent: a bird below the horizon is further away than one
// overhead, and the footprint is a plausible circle.
func TestTrackIsSaneFromAKnownStation(t *testing.T) {
e := issElement(t)
obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35} // JN18, Paris
at := time.Date(2024, 10, 24, 14, 0, 0, 0, time.UTC)
p, err := e.Track(obs, at)
if err != nil {
t.Fatalf("track: %v", err)
}
if p.Lat < -90 || p.Lat > 90 || p.Lon < -180 || p.Lon > 180 {
t.Errorf("sub-satellite point off the planet: %.3f %.3f", p.Lat, p.Lon)
}
if p.AltKm < 300 || p.AltKm > 500 {
t.Errorf("altitude %.1f km — the ISS is not there", p.AltKm)
}
if p.Az < 0 || p.Az >= 360 || p.El < -90 || p.El > 90 {
t.Errorf("look angles out of range: az %.1f el %.1f", p.Az, p.El)
}
// A satellite on the FAR side of the planet is still at a distance — up to
// two Earth radii plus its height — so the useful invariant is the one that
// holds when it is actually up: above the horizon it cannot be further away
// than the slant range to its own footprint edge.
if p.RangeKm < 300 || p.RangeKm > 13200 {
t.Errorf("range %.0f km is not this orbit seen from the ground", p.RangeKm)
}
if p.El > 0 && p.RangeKm > 2600 {
t.Errorf("visible at %.1f° yet %.0f km away", p.El, p.RangeKm)
}
// ~2000 km of visibility circle at 420 km up.
if p.Footprint < 1500 || p.Footprint > 2600 {
t.Errorf("footprint %.0f km", p.Footprint)
}
}
// Twelve hours of ISS passes over a European station: there are always several,
// they rise before they set, and the filter keeps its promise.
func TestPassesRiseBeforeTheySetAndRespectTheFloor(t *testing.T) {
s := NewStore()
s.Put(issElement(t))
obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35}
from := time.Date(2024, 10, 24, 12, 0, 0, 0, time.UTC)
all, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 0)
if err != nil {
t.Fatalf("passes: %v", err)
}
if len(all) == 0 {
t.Fatal("no ISS pass in twelve hours over Paris")
}
for _, p := range all {
if !p.LOS.After(p.AOS) {
t.Errorf("%s: sets (%s) before it rises (%s)", p.Name, p.LOS, p.AOS)
}
if p.MaxEl <= 0 || p.MaxEl > 90 {
t.Errorf("max elevation %.1f", p.MaxEl)
}
if p.MaxElAt.Before(p.AOS) || p.MaxElAt.After(p.LOS) {
t.Errorf("the highest point falls outside the pass")
}
}
high, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 30)
if err != nil {
t.Fatalf("passes: %v", err)
}
if len(high) > len(all) {
t.Error("the elevation floor let MORE passes through")
}
for _, p := range high {
if p.MaxEl < 30 {
t.Errorf("a %.1f° pass survived a 30° floor", p.MaxEl)
}
}
}
// The two corrections go in OPPOSITE directions, and that is the whole of it:
// the downlink arrives shifted so we tune to meet it, while the uplink has to
// leave shifted the other way to land on the transponder's nominal input.
func TestDopplerCorrectsBothWaysRoundTheRightWay(t *testing.T) {
const down, up = 145_950_000, 435_250_000
approaching := Position{RangeRate: -7.0} // km/s, coming towards us
receding := Position{RangeRate: +7.0}
a := Doppler(approaching, down, up)
if a.DownHz <= down {
t.Errorf("approaching: listen at %d, expected above %d", a.DownHz, down)
}
if a.UpHz >= up {
t.Errorf("approaching: transmit at %d, expected below %d", a.UpHz, up)
}
r := Doppler(receding, down, up)
if r.DownHz >= down {
t.Errorf("receding: listen at %d, expected below %d", r.DownHz, down)
}
if r.UpHz <= up {
t.Errorf("receding: transmit at %d, expected above %d", r.UpHz, up)
}
// Size, not just sign: 7 km/s on 145.950 MHz is about 3.4 kHz.
if d := math.Abs(float64(a.DownHz - down)); d < 3000 || d > 3800 {
t.Errorf("shift of %.0f Hz on 2 m at 7 km/s", d)
}
// Stationary is untouched, and an absent uplink is not invented.
if s := Doppler(Position{}, down, 0); s.DownHz != down || s.UpHz != 0 {
t.Errorf("a still satellite was corrected: %+v", s)
}
}
func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
s := NewStore()
e := issElement(t)
at := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
s.Replace([]Element{e}, at)
if s.Len() != 1 || s.Names()[0] != issName {
t.Errorf("store holds %v", s.Names())
}
if !s.FetchedAt().Equal(at) {
t.Errorf("fetched at %s", s.FetchedAt())
}
// Case and spacing vary between feeds and typists; the name is not a
// password.
if _, ok := s.Get("iss (zarya)"); !ok {
t.Error("a satellite could not be found under its own name in another case")
}
if _, err := s.Track("NOTHING", Observer{}, at); err == nil {
t.Error("an unknown satellite was tracked anyway")
}
}
+230
View File
@@ -0,0 +1,230 @@
package sat
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Where the elements come from, and where they are kept.
//
// Celestrak's amateur group is the list every tracker in this hobby uses; the
// PE0SAT mirror is there for the day Celestrak is down or rate-limiting, which
// it does when a hundred trackers all wake up at the top of the hour.
const (
FeedCelestrak = "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle"
FeedPE0SAT = "http://tle.pe0sat.nl/kepler/amateur.txt"
// CacheName is the file kept in the data directory. Plain TLE text, so an
// operator can open it, read it, and paste a line into a tracker that is not
// this one.
CacheName = "satellites.tle"
// StaleAfter is when elements stop being worth trusting silently. SGP4 drifts
// a few hundred metres a day for a low orbit, which is nothing for a pass
// prediction and everything for a rotator at high elevation — so the age is
// SHOWN rather than enforced, and this is only the point at which OpsLog
// offers to fetch again.
StaleAfter = 3 * 24 * time.Hour
)
// ParseTLESet reads a whole feed or cache file: three lines per satellite —
// name, then the two element lines — or two where the name is absent.
//
// A malformed satellite is SKIPPED, not 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 of what was dropped is returned so the app can say
// so instead of quietly holding a shorter list.
func ParseTLESet(r io.Reader) (els []Element, skipped int, err error) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
var pending []string
flush := func() {
defer func() { pending = nil }()
var name, l1, l2 string
switch len(pending) {
case 3:
name, l1, l2 = pending[0], pending[1], pending[2]
case 2:
l1, l2 = pending[0], pending[1]
default:
if len(pending) > 0 {
skipped++
}
return
}
e, perr := ParseElement(name, l1, l2)
if perr != nil {
skipped++
return
}
els = append(els, e)
}
for sc.Scan() {
line := strings.TrimRight(sc.Text(), " \t\r")
if strings.TrimSpace(line) == "" {
flush()
continue
}
// A "1 " or "2 " line is an element line; anything else starts a new
// satellite. That rule reads both the three-line and the two-line form
// without the file having to say which it is.
isElement := len(line) > 2 && (line[0] == '1' || line[0] == '2') && line[1] == ' '
if !isElement && len(pending) > 0 {
flush()
}
pending = append(pending, line)
if len(pending) == 3 || (len(pending) == 2 && strings.HasPrefix(pending[0], "1 ")) {
flush()
}
}
flush()
if err := sc.Err(); err != nil {
return els, skipped, fmt.Errorf("sat: reading the element set: %w", err)
}
if len(els) == 0 {
return nil, skipped, fmt.Errorf("sat: no usable elements in that set (%d entries refused)", skipped)
}
return els, skipped, nil
}
// Fetcher loads element sets from the feeds and keeps a copy on disk.
type Fetcher struct {
Dir string // where the cache file lives — the app's data directory
Feeds []string // tried in order; the first that answers wins
Timeout time.Duration // per feed
Logf func(string, ...any)
}
// NewFetcher builds one with the usual feeds.
func NewFetcher(dir string) *Fetcher {
return &Fetcher{
Dir: dir,
Feeds: []string{FeedCelestrak, FeedPE0SAT},
Timeout: 20 * time.Second,
Logf: func(string, ...any) {},
}
}
func (f *Fetcher) cachePath() string { return filepath.Join(f.Dir, CacheName) }
// LoadCache reads the elements kept from last time, with the file's own
// modification time as the fetch time.
//
// This is what makes the first screen after a launch a full one: an operator who
// opens the satellite tab on a train, or on a shack PC with no internet, still
// gets last week's elements — which are perfectly good for knowing what passes
// tonight — instead of an empty list and a spinner.
func (f *Fetcher) LoadCache() ([]Element, time.Time, error) {
p := f.cachePath()
fh, err := os.Open(p)
if err != nil {
return nil, time.Time{}, err
}
defer fh.Close()
els, skipped, err := ParseTLESet(fh)
if err != nil {
return nil, time.Time{}, err
}
at := time.Time{}
if st, serr := os.Stat(p); serr == nil {
at = st.ModTime()
}
if skipped > 0 {
f.Logf("sat: %d cached entries were unusable and were skipped", skipped)
}
return els, at, nil
}
// Fetch downloads a fresh set and writes the cache.
//
// The cache is only replaced once a feed has produced usable elements: a feed
// that answers with an error page, a captive-portal login or an empty file must
// not take away the set the station already had.
func (f *Fetcher) Fetch(ctx context.Context) ([]Element, error) {
var lastErr error
for _, url := range f.Feeds {
els, body, err := f.fetchOne(ctx, url)
if err != nil {
f.Logf("sat: %s: %v", shortHost(url), err)
lastErr = err
continue
}
if err := f.writeCache(body); err != nil {
// Not fatal: the elements are in hand and the station can track
// tonight. Only the next cold start loses by it, and it says so.
f.Logf("sat: could not write the element cache: %v", err)
}
f.Logf("sat: %d satellites from %s", len(els), shortHost(url))
return els, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no feed configured")
}
return nil, fmt.Errorf("sat: could not fetch the element set: %w", lastErr)
}
func (f *Fetcher) fetchOne(ctx context.Context, url string) ([]Element, []byte, error) {
to := f.Timeout
if to <= 0 {
to = 20 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, to)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, err
}
// Named, because Celestrak asks that clients identify themselves and answers
// an anonymous flood with a rate limit.
req.Header.Set("User-Agent", "OpsLog satellite tracker")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, nil, err
}
els, skipped, err := ParseTLESet(strings.NewReader(string(body)))
if err != nil {
return nil, nil, err
}
if skipped > 0 {
f.Logf("sat: %s: %d entries were unusable and were skipped", shortHost(url), skipped)
}
return els, body, nil
}
func (f *Fetcher) writeCache(body []byte) error {
if strings.TrimSpace(f.Dir) == "" {
return fmt.Errorf("no data directory")
}
if err := os.MkdirAll(f.Dir, 0o755); err != nil {
return err
}
// Written beside and renamed: a power cut mid-write must not leave a
// half-file that parses as twenty satellites instead of two hundred.
tmp := f.cachePath() + ".tmp"
if err := os.WriteFile(tmp, body, 0o644); err != nil {
return err
}
return os.Rename(tmp, f.cachePath())
}
// shortHost is a feed's host, for a log line that fits.
func shortHost(url string) string {
s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
if i := strings.IndexAny(s, "/?"); i > 0 {
s = s[:i]
}
return s
}
+87
View File
@@ -0,0 +1,87 @@
package sat
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Both shapes of the same file: three lines per satellite, and the two-line
// form some feeds still serve. A reader that only understood one of them would
// come back empty from a mirror and look like a network fault.
func TestParseTLESetReadsBothShapes(t *testing.T) {
three := issName + "\n" + issLine1 + "\n" + issLine2 + "\n"
els, skipped, err := ParseTLESet(strings.NewReader(three))
if err != nil || len(els) != 1 || skipped != 0 {
t.Fatalf("three-line: %d sats, %d skipped, err %v", len(els), skipped, err)
}
if els[0].Name != issName {
t.Errorf("name %q", els[0].Name)
}
two := issLine1 + "\n" + issLine2 + "\n"
els, _, err = ParseTLESet(strings.NewReader(two))
if err != nil || len(els) != 1 {
t.Fatalf("two-line: %d sats, err %v", len(els), err)
}
if els[0].NORAD != 25544 {
t.Errorf("a nameless entry lost its identity: %+v", els[0])
}
}
// One bad satellite must not cost the operator the other hundred and
// ninety-nine — but the count of what was dropped has to come back, or a
// silently shorter list reads as a complete one.
func TestParseTLESetSkipsWhatItCannotRead(t *testing.T) {
feed := strings.Join([]string{
"JUNK SATELLITE",
"1 99999U 00000A 24298.00000000 .00000000 00000+0 00000+0 0 0000", // bad checksum
"2 99999 00.0000 000.0000 0000000 000.0000 000.0000 00.00000000000000",
"",
issName, issLine1, issLine2,
}, "\n")
els, skipped, err := ParseTLESet(strings.NewReader(feed))
if err != nil {
t.Fatalf("the whole feed was refused for one bad entry: %v", err)
}
if len(els) != 1 || els[0].Name != issName {
t.Errorf("kept %d satellites: %+v", len(els), els)
}
if skipped != 1 {
t.Errorf("skipped = %d, want 1 — a silently shorter list reads as a complete one", skipped)
}
// Nothing usable at all IS an error: an error page or a captive-portal login
// parses as zero satellites, and that must never replace a good set.
if _, _, err := ParseTLESet(strings.NewReader("<html>login required</html>")); err == nil {
t.Error("an HTML error page was accepted as an element set")
}
}
// The cache is what makes the first screen after a launch a full one — on a
// train, or on a shack PC with no internet.
func TestCacheRoundTrip(t *testing.T) {
dir := t.TempDir()
f := NewFetcher(dir)
f.Logf = func(string, ...any) {}
body := issName + "\n" + issLine1 + "\n" + issLine2 + "\n"
if err := f.writeCache([]byte(body)); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, CacheName)); err != nil {
t.Fatalf("the cache file is not where an operator would look for it: %v", err)
}
// And no leftovers: the temp file is renamed, not copied.
if _, err := os.Stat(filepath.Join(dir, CacheName+".tmp")); err == nil {
t.Error("the half-written file was left behind")
}
els, at, err := f.LoadCache()
if err != nil || len(els) != 1 {
t.Fatalf("load: %d sats, err %v", len(els), err)
}
if at.IsZero() {
t.Error("the cache has no age, so nothing can say whether to trust it")
}
}
+54 -4
View File
@@ -3,6 +3,7 @@ package main
import (
"embed"
"os"
"strconv"
"strings"
"time"
@@ -58,7 +59,17 @@ func acquireInstance(wait bool) bool {
if !wait {
return false
}
deadline := time.Now().Add(20 * time.Second)
// Forty-five seconds, not twenty.
//
// The instance we are waiting for is allowed THIRTY to shut down — see
// armExitWatchdog, which force-exits it at that point — because it closes a
// remote logbook, a CAT session and sometimes a backup on the way out. A
// twenty-second patience was therefore shorter than the wait it existed for,
// and on a station where shutdown ran long the new instance gave up while
// the old one was still finishing: no new window after an update, and a
// leftover OpsLog in the task manager. This is the backstop; --wait-pid
// below is the real answer.
deadline := time.Now().Add(45 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(300 * time.Millisecond)
if acquireSingleInstance() {
@@ -68,6 +79,23 @@ func acquireInstance(wait bool) bool {
return false
}
// waitPidArg reads "--wait-pid N": the process this one must outlive.
func waitPidArg(args []string) int {
for i, a := range args {
if a == "--wait-pid" && i+1 < len(args) {
if n, err := strconv.Atoi(args[i+1]); err == nil {
return n
}
}
if v, ok := strings.CutPrefix(a, "--wait-pid="); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
}
return 0
}
// processStart is stamped on the very first instruction of main, before the
// single-instance guard and before anything else runs.
//
@@ -90,6 +118,20 @@ func main() {
// to free instead of bailing out. Then clear the old exe it left behind.
bootLogLaunch()
postUpdate := hasFlag(os.Args[1:], "--post-update")
// The instance that started us is still shutting down. Wait for it to
// actually END — a kernel wait on its handle, which finishes the instant it
// does — rather than hoping the mutex frees inside a fixed window. This is
// what the old PowerShell helper did, and losing it is what left an operator
// with no window after an update and the previous OpsLog still in the task
// manager.
if pid := waitPidArg(os.Args[1:]); pid > 0 {
bootLog("waiting for the previous instance (pid %d) to exit", pid)
if waitForProcessExit(pid, 60*time.Second) {
bootLog("the previous instance is gone")
} else {
bootLog("the previous instance (pid %d) is STILL running after 60s — trying anyway", pid)
}
}
// A self-relaunch (database switch) races its own parent: the new process
// regularly wins the start against the old one's teardown, and the operator
// got "OpsLog is already running" for following instructions. Same patience
@@ -100,7 +142,16 @@ func main() {
// window, no data folder, no log, which is indistinguishable from a
// program that died on its first instruction.
bootLog("another instance already holds the single-instance mutex - exiting")
fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.")
if postUpdate {
// After an update the ordinary message is a lie by omission: the
// operator did not start a second copy, the update did, and what
// they need to know is that the PREVIOUS version never finished
// closing.
fatalBox("OpsLog", "The previous version of OpsLog has not finished closing, so the updated one cannot start.\n\n"+
"Close the leftover OpsLog.exe in the Task Manager, then start OpsLog again — the update is already installed.")
} else {
fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.")
}
return
}
bootLog("single-instance mutex acquired")
@@ -111,8 +162,7 @@ func main() {
// OpsLog had was inside the folder it could not create.
if err := checkDataDirWritable(); err != nil {
bootLog("FATAL %v", err)
fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+
"\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator.")
fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+dataDirAdvice)
return
}
if postUpdate {
+79
View File
@@ -0,0 +1,79 @@
//go:build linux
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// hideConsole has nothing to hide on Linux: a child started from a GUI process
// inherits no console, so no window can flash.
func hideConsole(cmd *exec.Cmd) {}
// runningProcessNames returns the set of lowercase executable names currently
// running, read straight from /proc rather than by shelling out to `ps` — the
// output format of `ps` varies between distributions and busybox, /proc does
// not.
//
// Two names are recorded per process: the kernel's comm (truncated to 15
// characters, which is why it cannot be the only one — "gridtracker-bin" and
// "wsjtx-improved" both hit the limit) and the basename of the real executable
// behind /proc/<pid>/exe. Either may be what the operator configured.
func runningProcessNames() map[string]bool {
out := map[string]bool{}
entries, err := os.ReadDir("/proc")
if err != nil {
applog.Printf("autostart: cannot read /proc: %v", err)
return out
}
for _, e := range entries {
if !e.IsDir() {
continue
}
pid := e.Name()
if _, err := strconv.Atoi(pid); err != nil {
continue // not a process directory
}
if comm, err := os.ReadFile("/proc/" + pid + "/comm"); err == nil {
if n := strings.ToLower(strings.TrimSpace(string(comm))); n != "" {
out[n] = true
}
}
// The exe symlink is unreadable for processes owned by another user;
// that is expected and not worth a log line.
if exe, err := os.Readlink("/proc/" + pid + "/exe"); err == nil {
if n := strings.ToLower(filepath.Base(strings.TrimSuffix(exe, " (deleted)"))); n != "" {
out[n] = true
}
}
}
return out
}
// closeProcess asks the process to close. SIGTERM is the Unix equivalent of the
// polite WM_CLOSE used on Windows: WSJT-X and friends get to save their state
// instead of being shot with SIGKILL.
func closeProcess(pid int) ([]byte, error) {
p, err := os.FindProcess(pid)
if err != nil {
return nil, err
}
return nil, p.Signal(syscall.SIGTERM)
}
// executableFilters is what the "choose a program" dialog offers. Nothing to
// filter on here: a Linux program is a file with the executable bit, and its
// name carries no extension — wsjtx, gridtracker, flrig. An *.exe filter would
// show the operator an empty folder.
func executableFilters() []wruntime.FileFilter {
return []wruntime.FileFilter{{DisplayName: "All files", Pattern: "*"}}
}
+68
View File
@@ -0,0 +1,68 @@
//go:build windows
package main
import (
"os/exec"
"strconv"
"strings"
"syscall"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// hideConsole keeps a helper process from flashing a console window. OpsLog is
// a GUI application, and every `tasklist`/`taskkill`/`powershell` it runs would
// otherwise blink a black box in front of the operator mid-QSO.
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
}
// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
out := map[string]bool{}
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
hideConsole(cmd)
data, err := cmd.Output()
if err != nil {
applog.Printf("autostart: tasklist failed: %v", err)
return out
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// CSV row: "image.exe","PID",... — take the first quoted field.
field := line
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
field = line[1 : i+1]
}
field = strings.Trim(field, "\"")
if field != "" {
out[strings.ToLower(field)] = true
}
}
return out
}
// closeProcess asks the process to close. `taskkill` without /F sends WM_CLOSE,
// so WSJT-X and friends get to save their state instead of being shot.
func closeProcess(pid int) ([]byte, error) {
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
hideConsole(cmd)
return cmd.CombinedOutput()
}
// executableFilters is what the "choose a program" dialog offers. Windows knows
// a program by its extension.
func executableFilters() []wruntime.FileFilter {
return []wruntime.FileFilter{
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
}
}
+6
View File
@@ -34,6 +34,12 @@ func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
// calls applyAutoCall, which re-reads them and clears the target.
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
// The elements and the frequency plan are FILES, shared by every
// profile — there is one sky. What is per profile (the favourites, the
// minimum elevation, the locator) is read live on every call, so a
// switch is already reflected without rebuilding anything. The tracker,
// which does transmit, IS stopped by reloadAfterProfileSwitch.
"startSatellites": "one sky: the elements and the plan are shared files, and the per-profile settings are read live",
}
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")

Some files were not shown because too many files have changed in this diff Show More