Compare commits

...
20 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
88 changed files with 5120 additions and 1007 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. |
+455 -113
View File
@@ -63,6 +63,7 @@ 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"
@@ -121,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"
@@ -483,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"`
@@ -2026,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.
@@ -2979,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)
@@ -8363,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
}
@@ -8469,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
}
@@ -8585,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),
@@ -11136,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",
@@ -11143,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 {
@@ -11183,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
@@ -16292,9 +16359,25 @@ func (a *App) reloadCAT() {
if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return nil })
}
// Nothing about the link changed → leave it connected. See catLinkSig.
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
@@ -16316,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)
@@ -16873,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
@@ -16891,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
@@ -16928,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"
@@ -16968,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) {
@@ -17058,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)
@@ -17080,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
@@ -17117,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.
@@ -17149,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)
@@ -17171,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 {
@@ -17183,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
@@ -17263,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.
@@ -17284,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
@@ -17419,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":
@@ -17463,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")
@@ -19820,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
@@ -19837,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") {
+147 -88
View File
@@ -34,15 +34,22 @@ const (
keySatGrid = "sat.grid" // locator override ("" = the station's own)
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
// The az/el rotator. Its own settings rather than the HF rotator's: a
// satellite station's elevation rotator is a different machine on a
// different port, and an operator who has both must not have to choose.
// The az/el rotator.
keySatRotOn = "sat.rot_enabled"
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
// which many stations already run in front of their controller. Its own port
// key because it is a different program on a different port from an EasyComm
// controller, and an operator who tries both must not lose the first setting
// to the second.
// WHICH rotor, out of the ones configured in Settings ▸ Rotator — the key
// flattenRotors gives it. How to reach it is that list's business, not
// this page's: describing one mast in two places is how a station ends up
// working on HF and not on a pass.
keySatRotID = "sat.rot_id"
// Follow the azimuth and leave the elevation alone. See SatSettings.RotAzOnly.
keySatRotAzOnly = "sat.rot_az_only"
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
keySatRotStep = "sat.rot_step" // degrees of change worth a command
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
// The satellite page used to configure its own EasyComm or PstRotator link.
// These keys are read once by migrateSatRotator, which turns what they hold
// into a real entry in the rotator list, and are never written again.
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
@@ -51,9 +58,6 @@ const (
keySatRotCOM = "sat.rot_com"
keySatRotBaud = "sat.rot_baud"
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
keySatRotStep = "sat.rot_step" // degrees of change worth a command
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
)
// customTLEName holds elements the operator pasted in by hand.
@@ -74,18 +78,33 @@ type SatSettings struct {
AltM int `json:"alt_m"`
// The az/el rotator.
RotOn bool `json:"rot_on"`
RotType string `json:"rot_type"`
RotPstPort int `json:"rot_pst_port"`
RotTransport string `json:"rot_transport"`
RotHost string `json:"rot_host"`
RotPort int `json:"rot_port"`
RotCOM string `json:"rot_com"`
RotBaud int `json:"rot_baud"`
RotMaxAz int `json:"rot_max_az"`
RotMinEl int `json:"rot_min_el"`
RotStep int `json:"rot_step"`
RotPark bool `json:"rot_park"`
//
// RotID names one of the rotors configured in Settings ▸ Rotator — the
// key flattenRotors gives it. Everything about HOW to reach that rotator
// (backend, host, COM port, baud, 360/450) belongs to the rotator list and
// is deliberately not repeated here.
//
// What IS here is the tracking policy, which is the satellite page's own
// business and means nothing to a rotor turned by hand: below which
// elevation not to bother, how far the antenna must be off before a command
// is worth sending, and whether to park at the end.
RotOn bool `json:"rot_on"`
RotID string `json:"rot_id"`
// RotAzOnly follows the satellite in azimuth and leaves the elevation
// alone — which is how most stations that work satellites actually do it.
//
// A pass at the far edge of the footprint never climbs above ten or fifteen
// degrees, and a beam on a plain azimuth rotator points straight through it:
// the beamwidth covers the whole thing. Refusing to track for want of an
// elevation motor turned the feature off for every operator who has a tower
// and no az/el mast, which is nearly all of them.
//
// It also rescues an az/el station whose elevation motor has failed, and it
// is why the rotor list stops being filtered when this is set.
RotAzOnly bool `json:"rot_az_only"`
RotMinEl int `json:"rot_min_el"`
RotStep int `json:"rot_step"`
RotPark bool `json:"rot_park"`
}
// SatTransponder is one path through a satellite, as the UI needs it.
@@ -189,6 +208,11 @@ type SatPassInfo struct {
// PC with no internet as much as on one with. The fetch is the slow, optional
// half and never blocks a launch.
func (a *App) startSatellites() {
// Before anything else reads the rotator choice: an operator upgrading from
// the version where the satellite page held its own rotator link must find
// that mast already in the list and already selected.
a.migrateSatRotator()
dir := a.dataDir
birds, err := sat.LoadBirds(dir)
if err != nil {
@@ -239,47 +263,24 @@ func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
// ── Settings ────────────────────────────────────────────────────────────────
func (a *App) satSettings() SatSettings {
// The rotator defaults are the common case, not a blank form: EasyComm over
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
// beam with any gain at all is well inside the beamwidth and keeps a pass
// from being a command a second.
// A five-degree step, which on a beam with any gain at all is well inside
// the beamwidth and keeps a pass from being a command a second.
out := SatSettings{
MinEl: 10, WindowH: 24, AutoTLE: true,
RotType: satRotEasycomm, RotPstPort: 12000,
RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
RotMinEl: 0, RotStep: 5,
}
if a.settings == nil {
return out
}
m, err := a.settings.GetMany(a.ctx,
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
keySatRotOn, keySatRotID, keySatRotAzOnly, keySatRotMinEl, keySatRotStep, keySatRotPark)
if err != nil {
return out
}
out.RotOn = m[keySatRotOn] == "1"
if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
out.RotType = ty
}
if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
out.RotPstPort = v
}
if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
out.RotTransport = tr
}
out.RotHost = strings.TrimSpace(m[keySatRotHost])
if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 {
out.RotPort = v
}
out.RotCOM = strings.TrimSpace(m[keySatRotCOM])
if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 {
out.RotBaud = v
}
if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 {
out.RotMaxAz = 450
}
out.RotID = strings.TrimSpace(m[keySatRotID])
out.RotAzOnly = m[keySatRotAzOnly] == "1"
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
out.RotMinEl = v
}
@@ -337,46 +338,22 @@ func (a *App) SaveSatSettings(s SatSettings) error {
seen[strings.ToUpper(n)] = true
favs = append(favs, n)
}
if s.RotType != satRotPst {
s.RotType = satRotEasycomm
}
if s.RotPstPort <= 0 || s.RotPstPort > 65535 {
s.RotPstPort = 12000
}
if s.RotTransport != "tcp" {
s.RotTransport = "serial"
}
if s.RotMaxAz != 450 {
s.RotMaxAz = 360
}
if s.RotStep < 1 || s.RotStep > 30 {
s.RotStep = 5
}
if s.RotPort <= 0 || s.RotPort > 65535 {
s.RotPort = 4533
}
if s.RotBaud < 1200 || s.RotBaud > 115200 {
s.RotBaud = 9600
}
for k, v := range map[string]string{
keySatFavorites: strings.Join(favs, ","),
keySatMinEl: strconv.Itoa(s.MinEl),
keySatWindowH: strconv.Itoa(s.WindowH),
keySatAutoTLE: boolStr(s.AutoTLE),
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
keySatAltM: strconv.Itoa(s.AltM),
keySatRotOn: boolStr(s.RotOn),
keySatRotType: s.RotType,
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
keySatRotTransport: s.RotTransport,
keySatRotHost: strings.TrimSpace(s.RotHost),
keySatRotPort: strconv.Itoa(s.RotPort),
keySatRotCOM: strings.TrimSpace(s.RotCOM),
keySatRotBaud: strconv.Itoa(s.RotBaud),
keySatRotMaxAz: strconv.Itoa(s.RotMaxAz),
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
keySatRotStep: strconv.Itoa(s.RotStep),
keySatRotPark: boolStr(s.RotPark),
keySatFavorites: strings.Join(favs, ","),
keySatMinEl: strconv.Itoa(s.MinEl),
keySatWindowH: strconv.Itoa(s.WindowH),
keySatAutoTLE: boolStr(s.AutoTLE),
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
keySatAltM: strconv.Itoa(s.AltM),
keySatRotOn: boolStr(s.RotOn),
keySatRotID: strings.TrimSpace(s.RotID),
keySatRotAzOnly: boolStr(s.RotAzOnly),
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
keySatRotStep: strconv.Itoa(s.RotStep),
keySatRotPark: boolStr(s.RotPark),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -661,6 +638,11 @@ func (a *App) GetSatelliteNames() []string {
// The feed's name and the operator's name for the same satellite are routinely
// different, and the element set is keyed by the feed's.
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
// The catalog number first: it is exact, and it is what the generated plan
// carries. Everything below is for the hand-written entries that have none.
if e, ok := store.GetNORAD(b.NORAD); ok {
return e, true
}
if e, ok := store.Get(b.Name); ok {
return e, true
}
@@ -978,3 +960,80 @@ func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (Sa
out.Visible = p.Visible()
return out, nil
}
// migrateSatRotator moves a pre-list satellite rotator into Settings ▸ Rotator.
//
// Until now the satellite page configured its own EasyComm or PstRotator link,
// separately from the rotator list every other backend lived in. An operator who
// had set one up must not open OpsLog to an empty dropdown and a mast that no
// longer turns — so the old keys are read once, turned into a real rotor in the
// list, and the satellite page is pointed at it.
//
// Runs once. The legacy keys are cleared afterwards so a second run cannot add
// the same mast a second time, and so the next reader of this file is not left
// wondering which of the two copies is live.
func (a *App) migrateSatRotator() {
if a.settings == nil {
return
}
m, err := a.settings.GetMany(a.ctx,
keySatRotID, keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz)
if err != nil {
return
}
if strings.TrimSpace(m[keySatRotID]) != "" {
return // already migrated, or configured since
}
legacy := strings.TrimSpace(m[keySatRotType])
if legacy == "" {
return // the satellite rotator was never configured
}
atoi := func(s string) int { n, _ := strconv.Atoi(s); return n }
dev := RotatorDevice{
ID: fmt.Sprintf("rotor-sat-%d", time.Now().Unix()),
Name: "Satellite",
MaxAz: atoi(m[keySatRotMaxAz]),
// A satellite rotor carries a fixed antenna, not a motorized Ultrabeam
// or SteppIR: showing it pattern paths would be showing it something it
// cannot do.
Motorized: false,
}
switch legacy {
case satRotPst:
dev.Type = "pst"
dev.Host = strings.TrimSpace(m[keySatRotHost])
dev.Port = atoi(m[keySatRotPstPort])
// It was in the satellite settings, so it has elevation by construction.
dev.HasElevation = true
default:
dev.Type = "easycomm"
dev.Transport = strings.TrimSpace(m[keySatRotTransport])
dev.Host = strings.TrimSpace(m[keySatRotHost])
dev.Port = atoi(m[keySatRotPort])
dev.ComPort = strings.TrimSpace(m[keySatRotCOM])
dev.Baud = atoi(m[keySatRotBaud])
}
list, err := a.GetRotators()
if err != nil {
applog.Printf("satellite: cannot read the rotator list to migrate the satellite rotator: %v", err)
return
}
list = append(list, dev)
if err := a.SaveRotators(list); err != nil {
applog.Printf("satellite: cannot save the migrated satellite rotator: %v", err)
return
}
if err := a.settings.Set(a.ctx, keySatRotID, dev.ID); err != nil {
applog.Printf("satellite: migrated the rotator but could not select it: %v", err)
return
}
// Clear the old keys so this cannot run twice.
for _, k := range []string{keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz} {
_ = a.settings.Set(a.ctx, k, "")
}
applog.Printf("satellite: the %s rotator configured on the satellite page is now %q in Settings ▸ Rotator", legacy, dev.Name)
}
+212 -39
View File
@@ -1,16 +1,18 @@
package main
// The two ways a satellite station points its antenna.
// How a satellite station points its antenna.
//
// Some operators drive their az/el rotator directly — EasyComm II, what
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
// them and a dozen different controllers and handles az AND el; for those,
// OpsLog talking to the controller itself would be a second program fighting
// PstRotator over the same cable.
// 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.
//
// So both, behind one small interface, chosen in Settings. Neither is more
// "correct" than the other: the right one is whichever the station already has
// working.
// 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"
@@ -18,8 +20,9 @@ import (
"strings"
"sync"
"hamlog/internal/rotator/easycomm"
"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
@@ -33,41 +36,172 @@ type satRotator interface {
Close()
}
// The rotator kinds, as stored.
// 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 the configured controller.
func newSatRotator(s SatSettings) (satRotator, error) {
switch s.RotType {
case satRotPst:
if strings.TrimSpace(s.RotHost) == "" && s.RotPort <= 0 {
return nil, fmt.Errorf("no address for PstRotator")
}
return &pstSatRotator{c: pst.New(s.RotHost, s.RotPstPort), maxAz: s.RotMaxAz}, nil
default:
if s.RotTransport == "tcp" {
if strings.TrimSpace(s.RotHost) == "" {
return nil, fmt.Errorf("no address for the rotator")
}
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
}
if strings.TrimSpace(s.RotCOM) == "" {
return nil, fmt.Errorf("no COM port for the rotator")
}
return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
// 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 EasyComm needs 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.
// 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
@@ -87,12 +221,7 @@ func (p *pstSatRotator) Point(az, el float64) error {
if a < 0 {
a += 360
}
if el < 0 {
el = 0
}
if el > 180 {
el = 180
}
el = clampEl(el)
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
return err
}
@@ -138,3 +267,47 @@ func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
// 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() {}
+17 -3
View File
@@ -69,6 +69,7 @@ type satTracker struct {
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
@@ -101,6 +102,10 @@ type SatTrackStatus struct {
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.
@@ -131,13 +136,15 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
// left alone, so it gets one command rather than a loop.
set := a.satSettings()
if set.RotOn {
r, rerr := newSatRotator(set)
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
}
}
@@ -193,7 +200,7 @@ func (a *App) TestSatelliteRotator() (string, error) {
if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off")
}
c, err := newSatRotator(set)
c, err := a.newSatRotator(set)
if err != nil {
return "", err
}
@@ -415,7 +422,14 @@ func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
return
}
}
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
// 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 {
+61
View File
@@ -45,3 +45,64 @@ func TestSatModeLetters(t *testing.T) {
}
}
}
// 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() {}
+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.
+56
View File
@@ -1,4 +1,60 @@
[
{
"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": "",
+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."
+12 -1
View File
@@ -2594,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
@@ -3887,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(); };
+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 && (
<>
+16 -2
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.
@@ -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>
+13 -5
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>
+253 -22
View File
@@ -60,7 +60,7 @@ type Track = {
az: number; el: number; visible: boolean;
radio: string; // "sat" | "downlink-only" | ""
error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
};
const MAP_VIEW_SAT = 'opslog.satMapView';
@@ -128,6 +128,73 @@ const MODE_COLOUR: Record<string, string> = {
DATA: 'var(--warning)',
};
// escapeHtml, because a satellite name comes from data/satellites.json, which
// the operator edits by hand. A stray "<" there must not be able to break the
// tooltip it lands in.
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
// satTip is what hovering a satellite on the map says.
//
// The dot alone answered "there it is" and nothing else — the name, an
// elevation and an 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 pass is here, and reading it costs a hover instead of
// selecting the bird and looking somewhere else on the screen.
function satTip(p: Position, pass: Pass | undefined, t: (k: string) => string): string {
const row = (label: string, value: string) =>
`<div class="sat-tip-row"><span>${label}</span><span>${value}</span></div>`;
const out: string[] = [`<div class="sat-tip-name">${escapeHtml(p.name)}</div>`];
if (p.el > 0) {
out.push(row(t('sat.tipEl'), `${fmtDeg(p.el)}`));
out.push(row(t('sat.tipAz'), `${fmtDeg(p.az)} ${compass(p.az)}`));
} else {
out.push(`<div class="sat-tip-note">${t('sat.tipBelow')}</div>`);
}
// Closing or opening: the sign of the range rate is the difference between a
// pass about to start being useful and one already going away.
const trend = p.range_rate < -0.05 ? ' ↓' : p.range_rate > 0.05 ? ' ↑' : '';
out.push(row(t('sat.tipRange'), fmtKm(p.range_km) + trend));
out.push(row(t('sat.tipAlt'), fmtKm(p.alt_km)));
if (pass) {
const aos = Date.parse(pass.aos), los = Date.parse(pass.los), now = Date.now();
if (now >= aos && now < los) {
out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${fmtCountdown(los - now)}`));
} else {
out.push(row(t('sat.tipAos'), `${hhmm(pass.aos)} · ${fmtCountdown(aos - now)} · ${compass(pass.aos_az)}`));
out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${compass(pass.los_az)}`));
}
out.push(row(t('sat.tipMaxEl'), `${fmtDeg(pass.max_el)} ${compass(pass.max_el_az)}`));
} else {
// No pass inside the prediction window. Worth saying: an empty space here
// reads as a bug, and "nothing in the next 24 hours" is an answer.
out.push(`<div class="sat-tip-note">${t('sat.tipNoPass')}</div>`);
}
return out.join('');
}
// ModeBadge says FM or SSB where it cannot be missed.
//
// The mode used to be one word in a grey 11-pixel footnote under the
// frequencies, and it is not a footnote: 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 that has to be walked as the Doppler moves. An operator who reads the
// wrong one calls into silence.
function ModeBadge({ mode, className }: { mode?: string; className?: string }) {
if (!mode) return null;
const colour = MODE_COLOUR[mode] ?? 'var(--muted-foreground)';
return (
<span
className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border', className)}
style={{ color: colour, borderColor: `color-mix(in srgb, ${colour} 45%, transparent)`, background: `color-mix(in srgb, ${colour} 14%, transparent)` }}
>
{mode}
</span>
);
}
function ModeDot({ mode }: { mode: string }) {
const colour = MODE_COLOUR[mode];
if (!colour) return null;
@@ -147,6 +214,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [tpIdx, setTpIdx] = useState(0);
const [positions, setPositions] = useState<Position[]>([]);
const [passes, setPasses] = useState<Pass[]>([]);
// The next pass per satellite, for the map tooltips. The list is already
// ordered by AOS across every bird, so the first entry for a name is its next
// one — no second prediction run for what is already on screen.
const nextPassOf = useMemo(() => {
const m = new Map<string, Pass>();
for (const p of passes) if (!m.has(p.name)) m.set(p.name, p);
return m;
}, [passes]);
const [tuning, setTuning] = useState<Tuning | null>(null);
const [pass, setPass] = useState<PassInfo | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
@@ -171,6 +246,19 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
}, [birds]);
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
// The satellites you follow that have NO pass in the table.
//
// They are the reason the table was not the whole list: QO-100 never has a
// pass because it never sets, a bird whose elements have not arrived cannot
// be predicted at all, and one whose next pass falls outside the prediction
// window is simply beyond it. Left out, those three looked like satellites
// OpsLog had lost — so they are listed at the end, each saying which of the
// three it is, and clicking one selects it exactly like a pass row.
const idle = useMemo(() => {
const withPass = new Set(passes.map((p) => p.name));
return shown.filter((b) => !withPass.has(b.name));
}, [shown, passes]);
const tp = bird?.transponders?.[tpIdx] ?? null;
// The mode each satellite is worked in, for the pass table's dot. Its first
// transponder: on a bird that has two, the first is the one it is known for.
@@ -315,6 +403,15 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
// Where the pointer is over the map, in container pixels.
//
// The satellite layer is rebuilt every five seconds as the birds move, and a
// rebuilt marker is a new marker: the tooltip the operator was reading closed
// itself, over and over, which made the hover detail useless exactly when it
// was being used. Knowing where the pointer is lets the redraw reopen the
// tooltip of the dot it is still on — and only that one, so nothing is left
// hanging open once the mouse has moved away.
const mouseRef = useRef<L.Point | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
const saved = useRef(loadMapView(MAP_VIEW_SAT));
@@ -366,6 +463,8 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const c = m.getCenter();
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
});
m.on('mousemove', (e: L.LeafletMouseEvent) => { mouseRef.current = e.containerPoint; });
m.on('mouseout', () => { mouseRef.current = null; });
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
@@ -430,23 +529,73 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
if (!wanted.has(p.name) && p.name !== sel) continue;
const chosen = p.name === sel;
const up = p.el > 0;
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#94a3b8';
// The footprint is the honest answer to "can I hear it": everything inside
// the circle has the satellite above its horizon.
L.circle([p.lat, p.lon], {
radius: p.footprint_km * 1000,
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
}).addTo(layer);
//
// Drawn for the SELECTED bird only. A footprint is thousands of kilometres
// across, so a dozen of them overlap into a wash of circles that hides the
// coastline, the ground track and the satellites themselves — and the
// question it answers is only ever asked about the one being worked.
if (chosen) {
L.circle([p.lat, p.lon], {
radius: p.footprint_km * 1000,
color: colour, weight: 1.2, opacity: 0.7,
fillColor: colour, fillOpacity: 0.1,
}).addTo(layer);
}
// Two rings and not one. The map is a street map on one station and a
// dark satellite image on the next, and a single-stroke dot disappears
// into one of them — a pale marker on pale terrain, a grey one on a black
// ocean. A dark halo under a white ring reads on both, which is what an
// unselected satellite needs: it is precisely the one nobody is looking
// straight at.
const r = chosen ? 7 : up ? 6 : 5;
L.circleMarker([p.lat, p.lon], {
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
radius: r + 1.5, color: '#000', weight: 2, opacity: 0.45,
fill: false, interactive: false,
}).addTo(layer);
const dot = L.circleMarker([p.lat, p.lon], {
radius: r, color: '#fff', weight: 2,
fillColor: colour, fillOpacity: 1,
})
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
.bindTooltip(satTip(p, nextPassOf.get(p.name), t), {
direction: 'top', className: 'sat-tip', offset: [0, -6],
})
.on('click', () => setSel(p.name))
.addTo(layer);
// Was the pointer on this dot before the redraw replaced it? Then put the
// tooltip back, with the numbers it has just refreshed.
const map = mapRef.current;
if (map && mouseRef.current) {
const at = map.latLngToContainerPoint([p.lat, p.lon]);
if (at.distanceTo(mouseRef.current) <= r + 3) dot.openTooltip();
}
// A name beside the ones that are UP. The map can carry a dozen birds and
// labelling them all is a map nobody can read; the two or three above the
// horizon are the ones an operator is choosing between right now, and
// hovering each grey dot in turn to find them is the work this saves.
//
// Its own non-interactive marker rather than a permanent tooltip on the
// dot: Leaflet keeps ONE tooltip per layer, so a permanent label would
// take the place of the hover detail — and the detail is the point.
if (up || chosen) {
L.marker([p.lat, p.lon], {
icon: L.divIcon({
className: 'sat-name-label',
html: `<span>${escapeHtml(p.name)}</span>`,
iconSize: [0, 0],
iconAnchor: [-(r + 5), 6],
}),
interactive: false, keyboard: false,
}).addTo(layer);
}
}
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown, nextPassOf, t]);
// ── Render ───────────────────────────────────────────────────────────────
@@ -483,11 +632,27 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
value={tpIdx}
onChange={(e) => setTpIdx(Number(e.target.value))}
>
{/* The mode belongs in the choice itself. A satellite with an FM
repeater and a linear transponder offers two labels that both
read like a name, and picking the wrong one is a whole pass
spent on the wrong kind of radio. */}
{bird!.transponders!.map((x, i) => (
<option key={i} value={i}>{x.label}</option>
<option key={i} value={i}>
{x.label} {x.mode}{x.ctcss ? ` ${x.ctcss.toFixed(1)}` : ''}
</option>
))}
</select>
)}
{/* Also in the header, so the mode and the tone survive hiding the
readout column — which is exactly what an operator does when they
want the map full width during a pass. */}
<ModeBadge mode={tp?.mode} />
{tp?.mode === 'FM' && !!tp.ctcss && (
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums border border-caution/45 bg-caution/10 text-caution"
title={t('sat.toneHint')}>
{tp.ctcss.toFixed(1)}
</span>
)}
<Button
size="sm"
variant={tracking?.on ? 'default' : 'outline'}
@@ -568,7 +733,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
inPass ? 'border-success/60' : 'border-border')}>
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
<ModeBadge mode={tp?.mode} className="self-center" />
</div>
{bird?.geostationary ? (
@@ -659,7 +824,15 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
@@ -667,17 +840,52 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{/* What to tune. */}
<div className="rounded-lg border border-border bg-card p-2">
{/* The mode leads, because it decides everything below it. */}
<div className="flex items-center gap-1.5 mb-1.5">
<ModeBadge mode={tp?.mode} />
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
<div className="flex-1" />
{tp?.inverting && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span>
)}
{tp?.linear && (
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
</span>
)}
{bird?.geostationary && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
)}
</div>
<div className="space-y-1">
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
{!!tp?.mode && <span>{tp.mode}</span>}
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
{tp?.inverting && <span>{t('sat.inverting')}</span>}
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
{bird?.geostationary && <span>{t('sat.geo')}</span>}
</div>
{/* The tone, on the FM birds, with the same weight as a frequency.
It IS one, as far as the outcome goes: a repeater called without
its tone does not answer, and the operator hears an empty
channel and concludes the satellite is not up. Said explicitly
when there is none, too — a blank line cannot tell "no tone"
from "OpsLog does not know". */}
{tp?.mode === 'FM' && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('sat.tone')}
</span>
{tp.ctcss ? (
<>
<span className="text-base font-semibold tabular-nums text-caution">
{tp.ctcss.toFixed(1)} Hz
</span>
<span className="text-[10px] text-muted-foreground">{t('sat.toneHint')}</span>
</>
) : (
<span className="text-[11px] text-muted-foreground">{t('sat.toneNone')}</span>
)}
</div>
)}
</div>
{/* What is coming. */}
@@ -686,10 +894,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{t('sat.nextPasses')}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{passes.length === 0 && (
{passes.length === 0 && idle.length === 0 && (
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
)}
{passes.length > 0 && (
{(passes.length > 0 || idle.length > 0) && (
// A real table, so the name column takes the width the longest
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
// a fixed one — and the rest keeps its columns lined up under
@@ -738,6 +946,29 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
</tr>
);
})}
{/* The rest of what you follow, so the table IS the list:
nothing you can select is missing from it. */}
{idle.map((b) => {
const why = b.geostationary ? t('sat.alwaysUp')
: !b.has_elements ? t('sat.noElements')
: t('sat.noPassWindow');
return (
<tr
key={`idle-${b.name}`}
onClick={() => setSel(b.name)}
className={cn('cursor-pointer hover:bg-accent/50 border-t border-border/40',
b.name === sel && 'bg-accent/40')}
>
<td className="px-2 py-1 whitespace-nowrap">
<span className={cn('font-medium', !b.has_elements && 'text-muted-foreground')}>{b.name}</span>
<ModeDot mode={modeOf(b.name)} />
</td>
<td colSpan={4} className="px-2 py-1 text-right text-muted-foreground whitespace-nowrap">
{why}
</td>
</tr>
);
})}
</tbody>
</table>
)}
+162 -122
View File
@@ -13,13 +13,13 @@ import {
GetChaseSettings, SaveChaseSettings,
GetAudioMonitorPref,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotatorTypes,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetPSUSettings, SavePSUSettings,
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
GetSatSettings, SaveSatSettings, TestSatelliteRotator, ListSatelliteRotors,
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
@@ -1859,7 +1859,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// IC-7610 the moment they chose Other.
const [icomCustom, setIcomCustom] = useState(false);
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', omnirig_cw_lower: false, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, yaesu_rtty_usb: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
@@ -1871,13 +1871,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// else writes the backend (loading a profile, an older settings file).
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
// Choosing a radio TURNS CAT ON.
//
// The master switch sits above this dropdown, and leaving it off while the
// operator picks their brand, types the IP and runs the detector — which found
// their radio and printed its name — is a trap: every one of those gestures
// means "connect to this". A Flex 6700 owner did exactly that, saved six
// times, and got no link and no error. They can still untick it; nothing here
// ever turns CAT off on its own.
const applyCatBrand = (id: string) => {
const b = CAT_BRANDS.find((x) => x.id === id);
if (!b) return;
// Keep the connection when the new brand offers it, otherwise take its
// first — picking Flex from Kenwood-over-USB has to land on something.
const link = b.links.includes(catLink) ? catLink : b.links[0];
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link, enabled: true } as any));
};
const applyCatLink = (link: string) => {
const b = CAT_BRANDS.find((x) => x.id === catBrand);
@@ -1888,6 +1896,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
// What each rotator backend is and what it can do, straight from Go. The
// panel used to hold its own copy of the list — labels, default ports, which
// ones offer a COM port — and a second copy of that knowledge is a copy that
// drifts. It also carries the answer the satellite page needs: which
// interfaces drive an elevation axis.
const [rotTypes, setRotTypes] = useState<any[]>([]);
// The rotors the satellite page may choose between. Read from the same list,
// so a mast is described once.
const [satRotors, setSatRotors] = useState<any[]>([]);
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
// Whether the presets have actually been READ back yet.
//
@@ -1914,7 +1931,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// Satellites: the observer and the az/el rotator. The rest of the satellite
// settings (favourites, the pass window) are set in the tab itself, where
// they are used.
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_type: 'easycomm', rot_pst_port: 12000, rot_transport: 'serial', rot_host: '', rot_port: 4533, rot_com: '', rot_baud: 9600, rot_max_az: 360, rot_min_el: 0, rot_step: 5, rot_park: false });
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_id: '', rot_az_only: false, rot_min_el: 0, rot_step: 5, rot_park: false });
const [satTest, setSatTest] = useState('');
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
@@ -2509,6 +2526,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
await reloadClusterServers();
setCatCfg(c);
setRotors((r ?? []) as any);
try { setRotTypes(((await GetRotatorTypes()) ?? []) as any[]); } catch {}
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
// Loaded HERE, in the loader that runs on mount — not only in the
// event-driven one below. Missing from this one, the state stayed empty
// on a normal open and Save then wrote an empty list over the operator's
@@ -2571,6 +2590,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
try { setRotTypes(((await GetRotatorTypes()) ?? []) as any[]); } catch {}
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
@@ -2772,6 +2793,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotators(rotors as any);
// The satellite page picks from this list. Re-read it after a save so a
// rotor added on the Rotator panel can be chosen straight away, without
// closing the window first.
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
// Only once they have been read back — see rotorPresetsLoaded.
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
await SaveUltrabeamSettings(ultrabeam as any);
@@ -3578,6 +3603,14 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
{t('cat.enable')}
</label>
{/* Said where it is happening. Everything below this line can be filled
in perfectly the right radio, the right address, the detector
finding it by name and none of it connects while this is off. */}
{!catCfg.enabled && (
<div className="rounded-md border border-warning-border bg-warning-muted px-3 py-2 text-xs text-warning-muted-foreground">
{t('cat.disabledNotice')}
</div>
)}
{/* BRAND, then CONNECTION.
The backend list mixed the two: "Icom (USB)" and "Icom (network)"
@@ -3640,6 +3673,16 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
</div>
)}
{catCfg.backend === 'omnirig' && (
<div className="space-y-1 col-span-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.omnirig_cw_lower}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, omnirig_cw_lower: !!c }))} />
<span>{t('cat.omnirigCwLower')}</span>
</label>
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigCwLowerHint')}</p>
</div>
)}
{catCfg.backend === 'flex' && (
<>
<div className="space-y-1">
@@ -3653,7 +3696,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div>
<div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port, enabled: true }))} />
</div>
{/* What OpsLog DOES with a Flex panadapter spots, decode spots,
the DAX switch for voice messages moved to Settings
@@ -4647,113 +4690,55 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{!!satCfg.rot_on && (
<>
{/* Who drives the mast. Not a detail: a station already running
PstRotator must NOT have OpsLog on the same cable as well. */}
<div className="grid grid-cols-4 gap-3">
{/* Two columns wide: "OpsLog (EasyComm II)" does not fit in a
third of the row, and a truncated choice is a choice an
operator cannot read. */}
<div className="space-y-1 col-span-2">
<Label>{t('satset.rotType')}</Label>
<Select value={satCfg.rot_type || 'easycomm'} onValueChange={(v) => set('rot_type', v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
{/* WHICH rotor, not how to reach it. Every interface is
described once, in Settings Rotator; this page only picks
one of them. Describing one mast in two places is how a
station ends up working on HF and not on a pass. */}
<div className="space-y-1 max-w-md">
<Label>{t('satset.rotPick')}</Label>
<div className="flex items-center gap-2">
<Select value={satCfg.rot_id || '_'} onValueChange={(v) => set('rot_id', v === '_' ? '' : v)}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('satset.rotPickNone')} /></SelectTrigger>
<SelectContent>
<SelectItem value="easycomm">{t('satset.rotEasycomm')}</SelectItem>
<SelectItem value="pstrotator">{t('satset.rotPst')}</SelectItem>
{satRotors.length === 0 && <SelectItem value="_" disabled>{t('satset.rotNoneConfigured')}</SelectItem>}
{/* The azimuth-only rotors are always LISTED. Without
the switch below they are greyed and say why an
operator who owns one rotator and does not see it
concludes OpsLog cannot find it, where "azimuth
only" beside it teaches the real thing. With the
switch on, every rotor is fair game. */}
{satRotors.map((r: any) => (
<SelectItem key={r.key} value={r.key} disabled={!r.has_el && !satCfg.rot_az_only}>
{(r.name || r.type) + (r.has_el ? '' : `${t('satset.rotAzOnlyTag')}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9"
onClick={() => ListSatelliteRotors().then((r) => setSatRotors((r ?? []) as any[])).catch(() => {})}>
</Button>
</div>
{satCfg.rot_type === 'pstrotator' && (
<>
<div className="space-y-1">
<Label>{t('satset.rotHost')}</Label>
<Input className="font-mono" placeholder="127.0.0.1"
value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t('satset.rotPstPort')}</Label>
<Input className="font-mono" value={String(satCfg.rot_pst_port ?? 12000)}
onChange={(e) => set('rot_pst_port', num(e.target.value))} />
</div>
</>
)}
</div>
{satCfg.rot_type === 'pstrotator' && (
<p className="text-xs text-muted-foreground">{t('satset.rotPstHint')}</p>
)}
<div className={cn('grid grid-cols-3 gap-3', satCfg.rot_type === 'pstrotator' && 'hidden')}>
<div className="space-y-1">
<Label>{t('satset.rotLink')}</Label>
<Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="serial">{t('satset.rotSerial')}</SelectItem>
<SelectItem value="tcp">{t('satset.rotTcp')}</SelectItem>
</SelectContent>
</Select>
</div>
{satCfg.rot_transport === 'tcp' ? (
<>
<div className="space-y-1">
<Label>{t('satset.rotHost')}</Label>
<Input className="font-mono" placeholder="127.0.0.1"
value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t('satset.rotPort')}</Label>
<Input className="font-mono" value={String(satCfg.rot_port ?? 4533)}
onChange={(e) => set('rot_port', num(e.target.value))} />
</div>
</>
) : (
<>
<div className="space-y-1">
<Label>{t('satset.rotCom')}</Label>
<div className="flex items-center gap-2">
<Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
</Button>
</div>
</div>
<div className="space-y-1">
<Label>{t('satset.rotBaud')}</Label>
<Select value={String(satCfg.rot_baud || 9600)} onValueChange={(v) => set('rot_baud', parseInt(v, 10) || 9600)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
<SelectItem key={b} value={String(b)}>{b}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
<p className="text-xs text-muted-foreground">{t('satset.rotPickHint')}</p>
{!satCfg.rot_az_only && satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
<p className="text-xs text-[var(--warning)]">{t('satset.rotNoElAtAll')}</p>
)}
</div>
{/* Azimuth only. Not a fallback it is how most stations that
work satellites are actually built, and refusing to track
without an elevation motor turned the feature off for every
operator with a tower and no az/el mast. */}
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!satCfg.rot_az_only}
onCheckedChange={(c) => set('rot_az_only', !!c)} />
<span>
{t('satset.rotAzOnly')}
<span className="block text-xs text-muted-foreground">{t('satset.rotAzOnlyHint')}</span>
</span>
</label>
<div className="grid grid-cols-3 gap-3">
{/* The rotator's range is ours to know only when we drive the
controller. PstRotator knows which machine is on the other
end and does its own overlap; two programs each deciding to
go the long way round is how an antenna unwinds mid-pass. */}
{satCfg.rot_type !== 'pstrotator' && (
<div className="space-y-1">
<Label>{t('satset.rotRange')}</Label>
<Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="360">360°</SelectItem>
<SelectItem value="450">450°</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label>{t('satset.rotMinEl')}</Label>
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
@@ -5112,7 +5097,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const addRotor = () => setRotors((l) => [...l, {
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
transport: 'tcp', com_port: '', baud: 9600,
transport: 'tcp', com_port: '', baud: 9600, max_az: 360,
} as any]);
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
@@ -5127,18 +5112,45 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const dev = d as any;
const isRG = dev.type === 'rotgenius';
const isARCO = dev.type === 'arco';
const isERC = dev.type === 'erc';
const isDCU1 = dev.type === 'dcu1';
// A SPID has a COM port and nothing else — no network transport to
// offer, which is the whole point of driving it without PstRotator.
const isSPID = dev.type === 'spid';
const isSerialCap = isARCO || isDCU1 || isSPID; // COM-port or serial-over-IP controllers
const isEasycomm = dev.type === 'easycomm';
const isSerialCap = isARCO || isERC || isDCU1 || isSPID || isEasycomm; // COM-port or serial-over-IP controllers
const transport = dev.transport ?? 'tcp';
// What this backend can do, from Go. The panel asks rather than
// deciding: the same table answers the satellite page's question
// about which rotors have an elevation axis, and two tables would
// eventually disagree about one rotor.
const info = rotTypes.find((k) => k.id === (dev.type ?? 'pst'));
// PstRotator is the only backend where the elevation belongs to the
// station rather than to the protocol — it forwards EL happily to a
// mast that has no elevation motor, so only the operator knows.
const elOptional = !!info?.elevation_optional;
const hasEl = elOptional ? !!dev.has_elevation
: (isSPID ? (dev.spid_model || 'rot2prog') !== 'rot1prog' : !!info?.elevation);
// The mast's range is ours to know only when OpsLog drives the
// controller. PstRotator knows which machine is on the other end and
// does its own overlap; two programs each deciding to go the long
// way round is how an antenna unwinds mid-pass.
const ownsOverlap = isERC || isEasycomm;
return (
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
<div className="flex items-center gap-2">
<Compass className="size-4 text-primary shrink-0" />
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
onChange={(e) => patch(i, { name: e.target.value })} />
{/* Which axes this interface drives, beside the interface
itself. Without it the only way to find out is to pick a
rotor on the satellite page and be told no. */}
<span className={cn('shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium border',
hasEl ? 'border-[var(--success)]/40 text-[var(--success)] bg-[var(--success)]/10'
: 'border-border text-muted-foreground bg-muted/40')}
title={hasEl ? t('rot.capAzElHint') : t('rot.capAzHint')}>
{hasEl ? t('rot.capAzEl') : t('rot.capAz')}
</span>
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
onClick={() => removeRotor(i)} title={t('rot.remove')}>
<Trash2 className="size-4" />
@@ -5147,17 +5159,28 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
(placeholder must match the ARCO's LAN menu), PstRotator 12000. */}
{/* The list, the labels, each backend's default port and its
default baud all come from Go see rotatorTypes. A
SPID's 600 baud and an ERC-M's 19200 are not typos, and a
wrong rate reads exactly like a dead controller. */}
<Select value={dev.type ?? 'pst'}
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' || v === 'spid' ? { transport: 'serial' } : {}), ...(v === 'spid' ? { baud: 600, spid_model: 'rot2prog' } : {}) })}>
onValueChange={(v) => {
const k = rotTypes.find((x) => x.id === v);
patch(i, {
type: v as any,
port: k?.default_port || 12000,
baud: k?.default_baud || 9600,
// A backend with no network transport must not be left
// pointing at a TCP host it cannot use.
...(k && !k.network ? { transport: 'serial' } : {}),
...(v === 'spid' ? { spid_model: 'rot2prog' } : {}),
} as any);
}}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC)</SelectItem>
<SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
<SelectItem value="spid">SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)</SelectItem>
{rotTypes.map((k) => (
<SelectItem key={k.id} value={k.id}>{k.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -5190,8 +5213,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</Select>
</div>
)}
{/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
{isSerialCap && !isSPID && (
{/* Offered only when the backend really has both. A SPID has a
COM port and nothing else, which is the whole point of
driving it without PstRotator in front. */}
{!!info?.serial && !!info?.network && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
@@ -5228,10 +5253,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{/* A SPID runs at 600 or 1200 baud not a typo, a pulse
controller has nothing to say quickly. Offering only the
usual rates would have left it permanently mute. */}
<Select value={String(dev.baud || (isSPID ? 600 : 9600))} onValueChange={(v) => patch(i, { baud: Number(v) })}>
<Select value={String(dev.baud || info?.default_baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600]).map((b) => (
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600, 115200]).map((b) => (
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
))}
</SelectContent>
@@ -5248,20 +5273,35 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<div className="space-y-1">
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
fallback={info?.default_port || 12000} className="font-mono" />
</div>
</div>
)}
{!isRG && !isSerialCap && (
{elOptional && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
This rotator supports elevation (VHF / satellite)
{t('rot.hasElevation')}
</label>
)}
{/* 360 or 450, and only for the backends OpsLog drives itself. */}
{ownsOverlap && (
<div className="space-y-1 max-w-[10rem]">
<Label>{t('rot.range')}</Label>
<Select value={String(dev.max_az === 450 ? 450 : 360)} onValueChange={(v) => patch(i, { max_az: parseInt(v, 10) } as any)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="360">360°</SelectItem>
<SelectItem value="450">450°</SelectItem>
</SelectContent>
</Select>
</div>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
{isERC && <p className="text-xs text-muted-foreground">{t('rot.ercHint')}</p>}
{isEasycomm && <p className="text-xs text-muted-foreground">{t('rot.easycommHint')}</p>}
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
{multi && (
<div className="space-y-1 max-w-xs">
File diff suppressed because one or more lines are too long
+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;
}
+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.18';
export const APP_VERSION = '0.27.20';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -583,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>>;
@@ -831,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>>;
+8
View File
@@ -1098,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']();
}
@@ -1594,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']();
}
+58 -16
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;
@@ -4136,14 +4170,8 @@ export namespace main {
grid: string;
alt_m: number;
rot_on: boolean;
rot_type: string;
rot_pst_port: number;
rot_transport: string;
rot_host: string;
rot_port: number;
rot_com: string;
rot_baud: number;
rot_max_az: number;
rot_id: string;
rot_az_only: boolean;
rot_min_el: number;
rot_step: number;
rot_park: boolean;
@@ -4161,14 +4189,8 @@ export namespace main {
this.grid = source["grid"];
this.alt_m = source["alt_m"];
this.rot_on = source["rot_on"];
this.rot_type = source["rot_type"];
this.rot_pst_port = source["rot_pst_port"];
this.rot_transport = source["rot_transport"];
this.rot_host = source["rot_host"];
this.rot_port = source["rot_port"];
this.rot_com = source["rot_com"];
this.rot_baud = source["rot_baud"];
this.rot_max_az = source["rot_max_az"];
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"];
@@ -4266,6 +4288,7 @@ export namespace main {
rot_az: number;
rot_el: number;
rot_live: boolean;
rot_az_only: boolean;
static createFrom(source: any = {}) {
return new SatTrackStatus(source);
@@ -4290,6 +4313,7 @@ export namespace main {
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"];
}
}
@@ -4360,6 +4384,24 @@ export namespace main {
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;
+1
View File
@@ -9,6 +9,7 @@ require (
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
+2
View File
@@ -29,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 (
+17
View File
@@ -1145,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 {
+4 -6
View File
@@ -1,5 +1,3 @@
//go:build windows
package cat
import (
@@ -71,10 +69,10 @@ type Flex struct {
// 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
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)
+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
},
+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"
+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 ""
}
@@ -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)
}
}
}
+22 -1
View File
@@ -310,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")
+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 -1
View File
@@ -114,7 +114,12 @@ func (t Transponder) Centre() int64 {
// Bird is one satellite's frequency plan.
type Bird struct {
Name string `json:"name"`
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
@@ -190,6 +195,22 @@ func LoadBirds(dir string) (*Birds, error) {
_ = 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 {
@@ -283,3 +304,52 @@ func (b *Birds) Len() int {
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
}
+566 -304
View File
@@ -1,6 +1,312 @@
[
{
"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",
@@ -27,135 +333,9 @@
}
]
},
{
"name": "SO-50",
"aliases": [
"SAUDISAT 1C",
"SAUDISAT 1C (SO-50)"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436795000,
"up_lo": 145850000,
"ctcss": 67
}
]
},
{
"name": "AO-91",
"aliases": [
"RADFXSAT",
"FOX-1B",
"RADFXSAT (FOX-1B)"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 145960000,
"up_lo": 435250000,
"ctcss": 67
}
]
},
{
"name": "IO-86",
"aliases": [
"LAPAN-A2",
"LAPAN-ORARI"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 435880000,
"up_lo": 145880000,
"ctcss": 88.5
}
]
},
{
"name": "PO-101",
"aliases": [
"DIWATA-2",
"DIWATA-2B"
],
"transponders": [
{
"label": "FM voice repeater (scheduled)",
"mode": "FM",
"down_lo": 145900000,
"up_lo": 437500000,
"ctcss": 141.3
}
]
},
{
"name": "AO-7",
"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": "FO-29",
"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": "AO-73",
"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": "JO-97",
"norad": 43803,
"aliases": [
"JY1SAT",
"JY1-SAT"
@@ -173,24 +353,117 @@
]
},
{
"name": "RS-44",
"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": [
"DOSAAF-85"
"PCSAT (NO-44)"
],
"transponders": [
{
"label": "Linear (inverting)",
"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": 435640000,
"down_hi": 435680000,
"up_lo": 145965000,
"up_hi": 146005000,
"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",
@@ -217,199 +490,176 @@
]
},
{
"name": "TEVEL-1",
"name": "RS-44",
"norad": 44909,
"aliases": [
"TEVEL 1"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-2",
"aliases": [
"TEVEL 2"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-3",
"aliases": [
"TEVEL 3"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-4",
"aliases": [
"TEVEL 4"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-5",
"aliases": [
"TEVEL 5"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-6",
"aliases": [
"TEVEL 6"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-7",
"aliases": [
"TEVEL 7"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "TEVEL-8",
"aliases": [
"TEVEL 8"
],
"transponders": [
{
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436400000,
"up_lo": 145970000,
"ctcss": 67
}
]
},
{
"name": "EO-88",
"aliases": [
"NAYIF-1",
"FUNCUBE-5"
"DOSAAF-85"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145960000,
"down_hi": 145990000,
"up_lo": 435015000,
"up_hi": 435045000,
"down_lo": 435640000,
"down_hi": 435680000,
"up_lo": 145965000,
"up_hi": 146005000,
"inverting": true
}
]
},
{
"name": "AO-109",
"name": "SO-50",
"norad": 27607,
"aliases": [
"RADFXSAT-2",
"FOX-1E"
"SAUDISAT 1C",
"SAUDISAT 1C (SO-50)"
],
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145860000,
"down_hi": 145880000,
"up_lo": 435750000,
"up_hi": 435770000,
"inverting": true
"label": "FM voice repeater",
"mode": "FM",
"down_lo": 436795000,
"up_lo": 145850000,
"ctcss": 67
}
]
},
{
"name": "CAS-4A",
"aliases": [
"ZHUHAI-1 OVS-1A",
"OVS-1A"
],
"name": "SONATE-2",
"norad": 59112,
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145860000,
"down_hi": 145880000,
"up_lo": 435210000,
"up_hi": 435230000,
"inverting": true
"label": "Mode V/V - APRS digipeater",
"mode": "DATA",
"down_lo": 145825000,
"up_lo": 145825000
}
]
},
{
"name": "CAS-4B",
"aliases": [
"ZHUHAI-1 OVS-1B",
"OVS-1B"
],
"name": "TAURUS-1",
"norad": 44530,
"transponders": [
{
"label": "Linear (inverting)",
"mode": "SSB",
"down_lo": 145905000,
"down_hi": 145925000,
"up_lo": 435270000,
"up_hi": 435290000,
"inverting": true
"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"
@@ -427,17 +677,29 @@
]
},
{
"name": "IO-117",
"aliases": [
"GREENCUBE",
"MEZTLI"
],
"name": "UKUBE-1",
"norad": 40074,
"transponders": [
{
"label": "Digipeater (1200 bd GMSK)",
"mode": "DATA",
"down_lo": 435310000,
"up_lo": 435310000
"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
}
]
}
+86 -4
View File
@@ -1,6 +1,7 @@
package sat
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -158,8 +159,19 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
t.Fatalf("the editable copy was not written: %v", err)
}
// An operator's own list is what gets used from then on.
mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]`
// 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)
}
@@ -167,8 +179,23 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if b.Len() != 1 {
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
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.
@@ -186,3 +213,58 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
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)
}
}
}
}
+21
View File
@@ -186,6 +186,27 @@ func (s *Store) Get(name string) (Element, bool) {
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()
+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: "*.*"},
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"reflect"
"strings"
"testing"
)
// The sent side must never start at Y.
//
// Y means "already sent", so the uploader skips the contact for ever. An
// operator whose eQSL default was Y had a logbook that never reached eQSL and
// nothing on screen to explain it — the log said "not eligible, EQSLSent
// already Y" and that is the only place it was ever said.
func TestNoConfirmationDefaultsToAlreadySent(t *testing.T) {
d := defaultQSLDefaults()
v := reflect.ValueOf(d)
for i := 0; i < v.NumField(); i++ {
name := v.Type().Field(i).Name
val := strings.ToUpper(strings.TrimSpace(v.Field(i).String()))
if val == "Y" {
t.Errorf("%s defaults to Y — every new QSO would be skipped by its uploader for ever", name)
}
}
}
// Every field has a default. A service added without one comes back blank,
// which is not a status anybody chose — HAMLOG.online arrived that way and
// every existing profile got nothing for it.
func TestEveryConfirmationHasADefault(t *testing.T) {
d := defaultQSLDefaults()
v := reflect.ValueOf(d)
for i := 0; i < v.NumField(); i++ {
if strings.TrimSpace(v.Field(i).String()) == "" {
t.Errorf("%s has no default — add it to defaultQSLDefaults", v.Type().Field(i).Name)
}
}
}
// A profile saved before a service existed has no value stored for it, and
// gets the shipped one rather than a blank.
func TestStoredBlanksAreFilledFromTheDefaults(t *testing.T) {
// What an old profile looks like: the services it knew about are set, the
// ones added later are empty.
stored := QSLDefaults{
QSLSent: "N", QSLRcvd: "N",
EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
QRZComStatus: "R", QRZComCfm: "N",
}
got := fillMissingQSLDefaults(stored)
if got.HamlogStatus != "R" || got.HamlogCfm != "N" || got.HamqthStatus != "R" {
t.Errorf("services added later were left blank: hamlog=%q/%q hamqth=%q",
got.HamlogStatus, got.HamlogCfm, got.HamqthStatus)
}
// And a value the operator DID choose is untouched.
chosen := QSLDefaults{QSLSent: "I", EQSLSent: "N"}
filled := fillMissingQSLDefaults(chosen)
if filled.QSLSent != "I" || filled.EQSLSent != "N" {
t.Errorf("a chosen status was overwritten: %q / %q", filled.QSLSent, filled.EQSLSent)
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
func TestWaitPidArg(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want int
}{
{"after the flag", []string{"--post-update", "--wait-pid", "4321"}, 4321},
{"joined with =", []string{"--wait-pid=4321"}, 4321},
{"absent", []string{"--post-update"}, 0},
{"flag with nothing after it", []string{"--wait-pid"}, 0},
{"not a number", []string{"--wait-pid", "later"}, 0},
} {
if got := waitPidArg(tc.args); got != tc.want {
t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want)
}
}
}
// Every relaunch has to tell the new process which one to wait for.
//
// The auto-update relaunch lost that when its PowerShell helper was removed —
// the helper had waited for the pid, and nothing took over the job — and an
// operator was left with no window after an update and the previous OpsLog
// still running. This keeps the two spawn sites honest: if a relaunch is added
// without --wait-pid, it is the same bug again.
func TestEveryRelaunchPassesItsPid(t *testing.T) {
spawn := regexp.MustCompile(`exec\.Command\(exe, "--(post-update|relaunch)"[^)]*\)`)
for _, file := range []string{"update.go", "app.go"} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, call := range spawn.FindAllString(string(src), -1) {
if !strings.Contains(call, "--wait-pid") {
t.Errorf("%s: %s does not tell the new instance which process to wait for", file, call)
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package main
import "testing"
// Which rotors the satellite page may offer. Getting this wrong is not a
// cosmetic fault: a rotor listed as az/el that has no elevation motor is a
// tracker sending W commands into a controller that ignores them, and a pass
// spent wondering why the antenna never lifts.
func TestRotorHasElevation(t *testing.T) {
cases := []struct {
name string
dev RotatorDevice
want bool
}{
{"ERC-M drives both axes of a G-5500", RotatorDevice{Type: "erc"}, true},
{"EasyComm is an az/el protocol", RotatorDevice{Type: "easycomm"}, true},
{"an ARCO is azimuth only", RotatorDevice{Type: "arco"}, false},
{"a Rotator Genius is azimuth only", RotatorDevice{Type: "rotgenius"}, false},
{"a DCU-1 is azimuth only", RotatorDevice{Type: "dcu1"}, false},
// SPID: the dialect decides. Rot1Prog has no elevation in its reply
// format at all, so offering it would be offering a rotor that cannot
// answer the question.
{"SPID Rot2Prog has elevation", RotatorDevice{Type: "spid", SpidModel: "rot2prog"}, true},
{"SPID defaults to Rot2Prog", RotatorDevice{Type: "spid"}, true},
{"SPID Rot1Prog does not", RotatorDevice{Type: "spid", SpidModel: "rot1prog"}, false},
// PstRotator: the elevation belongs to the station, not the protocol.
// PstRotator will happily forward EL to a controller that has no
// elevation motor, so only the operator can answer this one.
{"PstRotator with elevation declared", RotatorDevice{Type: "pst", HasElevation: true}, true},
{"PstRotator without", RotatorDevice{Type: "pst"}, false},
// An unknown type falls back to PstRotator, and must not fall back to
// "has elevation" with it.
{"an unknown backend", RotatorDevice{Type: "nonsense"}, false},
}
for _, c := range cases {
if got := rotorHasElevation(c.dev); got != c.want {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
// The satellite page stores a rotor KEY, not a list index: deleting the first
// rotor must not silently point the tracker at a different mast.
func TestFlattenRotorsKeys(t *testing.T) {
devs := []RotatorDevice{
{ID: "a", Name: "HF", Type: "pst"},
{ID: "b", Name: "Sat", Type: "erc"},
{ID: "c", Name: "RG", Type: "rotgenius", Dual: true, Name2: "RG 2"},
}
got := flattenRotors(devs)
want := []struct {
key string
hasEl bool
}{
{"a", false},
{"b", true},
{"c", false},
{"c#2", false}, // the second port of a Dual Rotator Genius
}
if len(got) != len(want) {
t.Fatalf("got %d logical rotors, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].Key != w.key || got[i].HasEl != w.hasEl {
t.Errorf("rotor %d: key %q el %v, want key %q el %v", i, got[i].Key, got[i].HasEl, w.key, w.hasEl)
}
}
}
// Each backend's default baud, because a blanket 9600 is silence on two of
// them: a SPID runs at 600 and an ERC-M ships at 19200, and a wrong rate reads
// exactly like a dead controller.
func TestRotorDefaultBaud(t *testing.T) {
cases := map[string]int{"spid": 600, "erc": 19200, "dcu1": 4800, "arco": 9600, "easycomm": 9600}
for typ, want := range cases {
if got := rotorTypeInfo(typ).DefaultBaud; got != want {
t.Errorf("%s: default baud %d, want %d", typ, got, want)
}
}
}
+13
View File
@@ -12,3 +12,16 @@ func onSomeMonitorImpl(x, y, w, h int) bool { return true }
func clampToVisible(x, y, w, h int) (int, int, bool) { return x, y, false }
func logMonitorLayout() {}
// monitorRects cannot be answered off Windows: Wails exposes no monitor
// geometry, and X11/Wayland disagree about whether a client may even ask. The
// empty list is the "cannot tell" the callers above already handle by trusting
// the operator's saved position.
func monitorRects() []screenRect { return nil }
// describeMonitors still has to say something in the log — a window nobody can
// see is reported as "OpsLog did not start", and the log line is the first
// thing looked at.
func describeMonitors(rects []screenRect) string {
return "monitor layout unavailable on this platform"
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# linux-setup.sh — check what a Linux build of OpsLog needs, then build it.
#
# Run it from the repository root: ./scripts/linux-setup.sh
#
# It never installs anything itself. It prints the one command your distribution
# needs and stops, because a script that runs sudo on somebody else's machine is
# a script nobody should run. Once the dependencies are in, run it again and it
# builds.
set -u
red() { printf '\033[31m%s\033[0m\n' "$*"; }
grn() { printf '\033[32m%s\033[0m\n' "$*"; }
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
head_() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
missing=0
note() { red " MISSING: $*"; missing=1; }
ok() { grn " ok: $*"; }
head_ "Distribution"
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
echo " ${PRETTY_NAME:-unknown}"
family="${ID_LIKE:-$ID}"
else
echo " unknown (no /etc/os-release)"
family=""
fi
head_ "Build dependencies"
have() { command -v "$1" >/dev/null 2>&1; }
pkg() { pkg-config --exists "$1" 2>/dev/null; }
have gcc || have cc || note "a C compiler (Wails links against the system WebKit, so cgo is required here)"
have pkg-config || note "pkg-config"
pkg gtk+-3.0 || note "GTK 3 development headers"
# Wails 2.10+ builds against webkit2gtk-4.1; Debian 12 and older still ship 4.0,
# which works with an extra build tag. Detect which one is present rather than
# telling the operator to guess.
webkit_tags=""
if pkg webkit2gtk-4.1; then
ok "webkit2gtk-4.1"
elif pkg webkit2gtk-4.0; then
ok "webkit2gtk-4.0 (older — will build with -tags webkit2_40)"
webkit_tags="-tags webkit2_40"
else
note "webkit2gtk development headers (4.1 preferred, 4.0 accepted)"
fi
# Node, and its VERSION — this is the trap on an LTS base. Ubuntu 22.04 (so
# Linux Mint 21) ships node 12, and Vite needs 18. The build fails deep inside
# the frontend with a syntax error that says nothing about the real cause, so
# catch it here where it can be named.
if have node; then
nodemaj=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
if [ "${nodemaj:-0}" -ge 18 ]; then
ok "node $(node -v)"
else
note "node 18 or newer (you have $(node -v) — the distribution package is too old for Vite)"
ylw " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs"
ylw " or use nvm: https://github.com/nvm-sh/nvm"
fi
else
note "node 18+"
fi
have npm || note "npm"
head_ "Go"
if have go; then
gov=$(go version | awk '{print $3}')
ok "$gov"
# 1.25 is what go.mod asks for. No distribution ships it yet — Ubuntu 22.04
# (Mint 21) has 1.18, Ubuntu 24.04 (Mint 22) has 1.22 — so `apt install
# golang-go` gets you a Go that cannot build this repository at all.
if ! go version | grep -Eq 'go1\.(2[5-9]|[3-9][0-9])'; then
note "go 1.25+ — $gov is too old for go.mod, and no apt package is new enough"
ylw " wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz"
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz"
ylw " echo 'export PATH=/usr/local/go/bin:\$HOME/go/bin:\$PATH' >> ~/.profile # then log out and back in"
fi
else
note "go 1.25+ — from https://go.dev/dl/, NOT from apt (no distribution ships 1.25 yet)"
fi
head_ "Wails CLI"
WAILS="$(command -v wails || true)"
[ -z "$WAILS" ] && [ -x "$HOME/go/bin/wails" ] && WAILS="$HOME/go/bin/wails"
if [ -n "$WAILS" ]; then
ok "$($WAILS version 2>/dev/null | head -1)"
else
note "the wails CLI: go install github.com/wailsapp/wails/v2/cmd/[email protected]"
ylw " (then make sure ~/go/bin is on your PATH)"
fi
if [ "$missing" -ne 0 ]; then
head_ "Install these first"
case "$family" in
*debian*|*ubuntu*)
echo " sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev"
echo " (on Debian 12 and older: libwebkit2gtk-4.0-dev)"
echo
echo " Go and node are NOT in that line on purpose — the apt versions are too"
echo " old on every current Ubuntu/Mint base. See the notes above for both." ;;
*fedora*|*rhel*)
echo " sudo dnf install gcc-c++ pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel nodejs npm" ;;
*arch*)
echo " sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm" ;;
*suse*)
echo " sudo zypper install -t pattern devel_basis && sudo zypper install pkg-config gtk3-devel webkit2gtk3-soup2-devel nodejs npm" ;;
*)
echo " a C compiler, pkg-config, GTK 3 and webkit2gtk development packages, node and npm" ;;
esac
echo
echo "Then run this script again."
exit 1
fi
head_ "Serial ports"
# The single most common reason a rig that works in WSJT-X refuses to open here.
if id -nG | tr ' ' '\n' | grep -qx 'dialout\|uucp'; then
ok "you are in the serial group"
else
ylw " You are NOT in the 'dialout' group (or 'uucp' on Arch/Fedora)."
ylw " CAT, keyers, rotators and amplifiers will fail with \"permission denied\"."
ylw " sudo usermod -aG dialout \$USER # then log out and back in"
fi
head_ "Sound server"
if have pactl && pactl info >/dev/null 2>&1; then
ok "$(pactl info | sed -n 's/^Server Name: //p')"
else
ylw " No PulseAudio/PipeWire server answered. The voice keyer, the QSO"
ylw " recorder and the CW decoder will report they cannot reach it."
ylw " Everything else works without one."
fi
head_ "Building"
echo " wails build $webkit_tags"
# shellcheck disable=SC2086
"$WAILS" build $webkit_tags || { red "Build failed."; exit 1; }
head_ "Done"
grn " ./build/bin/OpsLog"
echo
echo " First run creates build/bin/data/ beside the binary — a fresh, empty"
echo " logbook. Do NOT copy config.json over from Windows: it holds a Windows"
echo " path. Point OpsLog at your real logbook from Settings ▸ Database."
echo
echo " If the window never opens, look at:"
echo " ~/.cache/OpsLog/startup.log (before the window)"
echo " build/bin/data/opslog.log (after it)"
+10
View File
@@ -44,3 +44,13 @@ func TestTidySerialPortsIgnoresCase(t *testing.T) {
t.Errorf("got %v, want [COM3]", got)
}
}
// On Linux the ports are paths, and the same trap is waiting there:
// /dev/ttyUSB10 sorted lexically lands between USB1 and USB2.
func TestTidySerialPortsSortsUnixNamesNaturally(t *testing.T) {
got := tidySerialPorts([]string{"/dev/ttyUSB10", "/dev/ttyUSB2", "/dev/ttyACM0", "/dev/ttyUSB1"})
want := []string{"/dev/ttyACM0", "/dev/ttyUSB1", "/dev/ttyUSB2", "/dev/ttyUSB10"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("got %v, want %v", got, want)
}
}
+99
View File
@@ -0,0 +1,99 @@
//go:build linux && !bindings
// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND
// RUNNING this binary. With the guard active, a normal OpsLog already running on
// the dev machine holds the lock, the generator's process exits instantly, and
// no bindings are produced. Excluding the guard from that build keeps generation
// working while shipping builds still get it.
package main
import (
"os"
"path/filepath"
"strconv"
"syscall"
"time"
"hamlog/internal/applog"
)
// The Linux half of the single-instance guard. Windows uses a named mutex; here
// it is an advisory lock (flock) held on a file for as long as the process
// lives.
//
// A lock and not a pid file, because a pid file is wrong exactly when it
// matters: OpsLog killed by the OOM killer, or crashing on a bad rig response,
// leaves its pid behind and every later launch refuses to start. The kernel
// drops an flock when the process ends however it ends, so there is no stale
// state to clean up and no "delete this file to start again" for the operator
// to discover.
var instanceLock *os.File
// instanceLockPath prefers XDG_RUNTIME_DIR (/run/user/1000) — per user, and
// emptied when the session ends, which is what a runtime lock wants. The cache
// directory is the fallback for the sessions that do not set it (a bare TTY, an
// ssh -X login).
func instanceLockPath() string {
dir := os.Getenv("XDG_RUNTIME_DIR")
if dir == "" {
dir = bootLogDir()
}
dir = filepath.Join(dir, "OpsLog")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
}
return filepath.Join(dir, "instance.lock")
}
// acquireSingleInstance reports whether this process now owns the instance
// lock. Safe to call repeatedly: the retry loop in acquireInstance does, and a
// second flock on a second descriptor of the same file would conflict with the
// one we already hold.
func acquireSingleInstance() bool {
if instanceLock != nil {
return true
}
path := instanceLockPath()
if path == "" {
applog.Printf("single-instance: no writable folder for the lock — the guard is off for this run")
return true // fail open: refusing to start is worse than a possible duplicate
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
applog.Printf("single-instance: cannot open %s (%v) — the guard is off for this run", path, err)
return true
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = f.Close()
return false // another OpsLog holds it
}
// The pid is written for the operator's benefit, not ours — it is what a
// "which process is holding this?" question needs. The lock itself is the
// kernel's, and does not depend on the contents.
_ = f.Truncate(0)
_, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n")
_ = f.Sync()
instanceLock = f // deliberately never closed: closing releases the lock
return true
}
// waitForProcessExit waits for pid to disappear, up to timeout, and reports
// whether it did. Signal 0 asks the kernel "does this process exist?" without
// touching it.
//
// EPERM means it exists and belongs to somebody else — still running, as far as
// the caller is concerned. Only ESRCH is gone.
func waitForProcessExit(pid int, timeout time.Duration) bool {
if pid <= 0 {
return true
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if err := syscall.Kill(pid, 0); err == syscall.ESRCH {
return true
}
time.Sleep(100 * time.Millisecond)
}
return syscall.Kill(pid, 0) == syscall.ESRCH
}
+8 -1
View File
@@ -1,9 +1,16 @@
//go:build !windows || bindings
//go:build (!windows && !linux) || bindings
package main
import "time"
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
// runs this binary, and a real OpsLog already running would otherwise make it
// exit before Wails could reflect the bindings.
func acquireSingleInstance() bool { return true }
// waitForProcessExit has nothing to wait on off Windows: there is no
// single-instance guard there either, so the relaunch never has to queue behind
// the old process.
func waitForProcessExit(pid int, timeout time.Duration) bool { return true }
+33
View File
@@ -10,6 +10,7 @@ package main
import (
"errors"
"time"
"unsafe"
"golang.org/x/sys/windows"
@@ -68,3 +69,35 @@ func focusExistingWindow() {
showWindow.Call(hwnd, swRestore)
setForeground.Call(hwnd)
}
// waitForProcessExit blocks until the process with this pid is gone, or the
// timeout runs out. Reports whether it actually went.
//
// This is what the auto-update relaunch needs, and it is the piece that was
// lost when the PowerShell helper went. The old instance is allowed thirty
// seconds to shut down (see armExitWatchdog) — it closes a remote logbook, a
// CAT session, sometimes a backup — while the new one was only patient with the
// mutex for twenty. On a station where shutdown took longer than that, the new
// process gave up and exited, and the operator was left with the old one still
// running and no new window: exactly the report.
//
// Waiting on a handle rather than sleeping a fixed time is also the honest
// version: it ends the instant the old process ends, however long or short that
// is, and it is a plain kernel wait — nothing that looks like a script starting
// another program.
func waitForProcessExit(pid int, timeout time.Duration) bool {
if pid <= 0 {
return true
}
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
// Already gone, or not ours to wait on. Either way there is nothing to
// wait for — and refusing to launch over an unexpected permission error
// would be worse than starting.
return true
}
defer windows.CloseHandle(h)
ms := uint32(timeout / time.Millisecond)
ev, err := windows.WaitForSingleObject(h, ms)
return err == nil && ev == uint32(windows.WAIT_OBJECT_0)
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.18"
appVersion = "0.27.20"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
+18 -54
View File
@@ -11,7 +11,6 @@ import (
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -209,8 +208,11 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
// this?" — but since we launch the exe programmatically that prompt never shows,
// and the launch is silently blocked. This is exactly why the relaunch failed.
_ = os.Remove(exe + ":Zone.Identifier")
applog.Printf("update: installed new exe, scheduling relaunch")
clearDownloadMark(exe)
if err := makeExecutable(exe); err != nil {
applog.Printf("update: could not restore the executable bit on %s: %v", filepath.Base(exe), err)
}
applog.Printf("update: installed new build, scheduling relaunch")
// THE NEW EXE STARTS ITSELF. No helper, no script.
//
@@ -222,12 +224,20 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
//
// The wait it existed for is not needed: --post-update already makes the new
// instance patient with the single-instance mutex (see acquireInstance), so it
// can start while this one is still shutting down and simply wait its turn.
cmd := exec.Command(exe, "--post-update")
// The wait it existed for still has to happen — it just happens on the other
// side now. The new instance is told OUR pid and waits for this process to
// end before taking the single-instance mutex.
//
// Waiting on the mutex alone was not enough, and that is the bug this line
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog),
// because it closes a remote logbook, a CAT session and sometimes a backup,
// while the new instance was only patient for twenty. On a station where
// that ran long, the new process gave up and exited — leaving the old one
// still running and no new window, which is precisely what the PowerShell
// helper never did: it waited for the pid, however long it took.
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
cmd.Dir = dir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
hideConsole(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err)
}
@@ -242,52 +252,6 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
return nil
}
// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
// this process is gone.
//
// The fallback for when the running image cannot be renamed at all. Once OpsLog
// has exited its exe is an ordinary file again, so the move that was refused a
// moment earlier succeeds — and the helper keeps trying for ten seconds, because
// an antivirus that was holding the file usually lets go a beat after the
// process dies rather than instantly.
//
// OpsLog is restarted either way. If the move failed, that starts the OLD build
// — the update simply has not applied — and the operator keeps a working logger
// instead of having it vanish mid-session, which for someone in a QSO is worse
// than an update that waits. Only a successful swap passes --post-update, so a
// failure leaves the .new file in place for the next attempt rather than having
// the cleanup delete the download.
// The LAST resort still needs a helper that outlives this process: nothing else
// can move a file over an image that is still running. It stays PowerShell —
// there is no smaller tool on a stock Windows that can wait for a pid and then
// move a file — but it is reached only when the rename above failed, which is
// rare, and never on the ordinary update path (see the relaunch there for why
// that matters to Defender).
func (a *App) scheduleDeferredSwap(exe, pending string) error {
// Clear the "downloaded from the internet" mark before it becomes the exe —
// SmartScreen silently blocks a programmatic launch of a marked file, and the
// mark follows the file across the move.
_ = os.Remove(pending + ":Zone.Identifier")
q := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; "+
"$ok=$false; "+
"for ($i=0; $i -lt 40; $i++) { "+
"try { Move-Item -LiteralPath '%s' -Destination '%s' -Force -ErrorAction Stop; $ok=$true; break } "+
"catch { Start-Sleep -Milliseconds 250 } }; "+
"if ($ok) { Start-Process -FilePath '%s' -ArgumentList '--post-update' } "+
"else { Start-Process -FilePath '%s' }",
os.Getpid(), q(pending), q(exe), q(exe), q(exe))
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule the update swap: %w", err)
}
applog.Printf("update: swap scheduled for after exit (%s → %s)", filepath.Base(pending), filepath.Base(exe))
return nil
}
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
func (a *App) downloadWithProgress(url, dest string) error {
client := &http.Client{Timeout: 10 * time.Minute}
+42
View File
@@ -0,0 +1,42 @@
//go:build linux
package main
import (
"fmt"
"os"
"path/filepath"
"hamlog/internal/applog"
)
// clearDownloadMark has nothing to clear on Linux: there is no
// mark-of-the-web, and no SmartScreen to refuse a programmatic launch.
func clearDownloadMark(path string) {}
// makeExecutable restores the executable bit. A binary downloaded over HTTP
// arrives 0644 — on Windows the extension decides and this is a no-op, but here
// a freshly installed OpsLog that nothing can exec is a dead station.
func makeExecutable(path string) error { return os.Chmod(path, 0o755) }
// scheduleDeferredSwap is the fallback for when the running binary could not be
// renamed out of the way — the path Windows needs a detached PowerShell helper
// for, because nothing there can move a file over a running image.
//
// On Linux it should never be reached. A rename only touches the directory
// entry, and the running process holds the inode, so replacing the binary of a
// live process is ordinary and the staging rename in DownloadAndApplyUpdate
// succeeds. If it did fail, the cause was the filesystem (read-only mount, no
// write permission on the directory, a full disk) and no helper would get past
// it either — so do the honest thing: try the move once more now, and say
// plainly what is wrong if it still refuses.
func (a *App) scheduleDeferredSwap(exe, pending string) error {
if err := os.Rename(pending, exe); err != nil {
return fmt.Errorf("install the new build: %w (the folder %s must be writable)", err, filepath.Dir(exe))
}
if err := makeExecutable(exe); err != nil {
return fmt.Errorf("make the new build executable: %w", err)
}
applog.Printf("update: installed %s over %s after the staging rename failed", filepath.Base(pending), filepath.Base(exe))
return nil
}
+69
View File
@@ -0,0 +1,69 @@
//go:build windows
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"hamlog/internal/applog"
)
// clearDownloadMark strips the NTFS "downloaded from the internet" stream.
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
// this?" — but since we launch the exe programmatically that prompt never
// shows, and the launch is silently blocked. This is exactly why the relaunch
// used to fail after an update.
func clearDownloadMark(path string) { _ = os.Remove(path + ":Zone.Identifier") }
// makeExecutable is a no-op on Windows, where the extension decides.
func makeExecutable(path string) error { return nil }
// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
// this process is gone.
//
// The fallback for when the running image cannot be renamed at all. Once OpsLog
// has exited its exe is an ordinary file again, so the move that was refused a
// moment earlier succeeds — and the helper keeps trying for ten seconds, because
// an antivirus that was holding the file usually lets go a beat after the
// process dies rather than instantly.
//
// OpsLog is restarted either way. If the move failed, that starts the OLD build
// — the update simply has not applied — and the operator keeps a working logger
// instead of having it vanish mid-session, which for someone in a QSO is worse
// than an update that waits. Only a successful swap passes --post-update, so a
// failure leaves the .new file in place for the next attempt rather than having
// the cleanup delete the download.
// The LAST resort still needs a helper that outlives this process: nothing else
// can move a file over an image that is still running. It stays PowerShell —
// there is no smaller tool on a stock Windows that can wait for a pid and then
// move a file — but it is reached only when the rename above failed, which is
// rare, and never on the ordinary update path (see the relaunch there for why
// that matters to Defender).
func (a *App) scheduleDeferredSwap(exe, pending string) error {
// Clear the "downloaded from the internet" mark before it becomes the exe —
// SmartScreen silently blocks a programmatic launch of a marked file, and the
// mark follows the file across the move.
_ = os.Remove(pending + ":Zone.Identifier")
q := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; "+
"$ok=$false; "+
"for ($i=0; $i -lt 40; $i++) { "+
"try { Move-Item -LiteralPath '%s' -Destination '%s' -Force -ErrorAction Stop; $ok=$true; break } "+
"catch { Start-Sleep -Milliseconds 250 } }; "+
"if ($ok) { Start-Process -FilePath '%s' -ArgumentList '--post-update' } "+
"else { Start-Process -FilePath '%s' }",
os.Getpid(), q(pending), q(exe), q(exe), q(exe))
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
hideConsole(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule the update swap: %w", err)
}
applog.Printf("update: swap scheduled for after exit (%s → %s)", filepath.Base(pending), filepath.Base(exe))
return nil
}
+35
View File
@@ -31,6 +31,15 @@ WSJT-X and MSHV normally broadcast to a **multicast group** (usually
give the group. A program sending to one address only needs unicast — leave it
unticked.
If you also run **GridTracker**, or anything else that listens to your decoder,
read [[Digital Modes and GridTracker]] first: two programs on one unicast port
work or not depending on which started first, which is the usual cause of
"the decodes arrived yesterday and not today".
A multicast group is an address between `224.0.0.0` and `239.255.255.255`.
`127.0.0.1` is not one — a row ticked *Multicast* with a loopback or LAN
address in the group box listens on unicast instead, and says so in the log.
> **Do not put an inbound row and an outbound row on the same port.** OpsLog
> then receives its own messages, and something it publishes can come back as a
> command. It says so in the log when it spots the arrangement:
@@ -45,10 +54,36 @@ unticked.
|---|---|---|
| **ADIF message** | a plain ADIF record | each QSO logged |
| **WSJT-X logged QSO** | the same ADIF wrapped in a WSJT-X datagram | each QSO logged |
| **Relay the WSJT-X stream** | every datagram received, byte for byte | as it arrives |
| **PstRotator frequency** | `<PST><FREQUENCY>` | frequency change |
| **N1MM RadioInfo** | N1MM's RadioInfo XML | frequency or mode change |
| **Custom message** | whatever you write | a trigger you pick |
### Relay the WSJT-X stream
WSJT-X, JTDX and MSHV each send to **one address**. Without a relay the choice
is between OpsLog and JTAlert / GridTracker, not both.
This row re-sends every datagram an inbound WSJT-X row receives, **byte for
byte**, to another program — so OpsLog stops being the reason you cannot run the
two together:
```
WSJT-X ──▶ OpsLog (inbound, 2237) ──relay──▶ GridTracker (2238)
```
Point it at the **other** program's port. Pointing it at one of OpsLog's own
listening ports would feed the stream back into itself for ever; the relay
refuses and says so in the log rather than flooding the network.
Nothing is added to the datagrams — no origin header. Some relays in the field
prepend one (`127.0.0.1:2237|`), which is exactly the kind of thing OpsLog has
had to write code to survive on the receiving side; passing it on downstream
would be repeating that mistake.
> If every program involved can do **multicast**, prefer that: each one gets its
> own copy and no relay is needed. See [[Digital Modes and GridTracker]].
### Which one for another logger?
If a logger says it accepts "WSJT-X UDP", it wants the **WSJT-X logged QSO**
+185
View File
@@ -0,0 +1,185 @@
# Digital modes: WSJT-X, GridTracker and OpsLog together
Running **WSJT-X** (or JTDX or MSHV) with **GridTracker** *and* OpsLog at the
same time is the normal digital station, and it is the setup that most often
half-works: the decodes arrive one day and not the next, with nothing changed.
There is one cause and one fix. The cause is **unicast**; the fix is
**multicast**.
---
## Unicast and multicast, in one minute
WSJT-X does not know about OpsLog. It sends a stream of UDP messages — every
decode, the DX call you are working, each QSO you log — to **one address and
port** you give it. Everything else is just listeners.
**Unicast** is a message addressed to one place: `127.0.0.1:2237`.
> One letter, one letterbox. If two programs are watching the same letterbox,
> the operating system decides which one gets the letter — and it decides on
> the order they started, not on what you want. That is why it works after one
> restart and not after the next.
**Multicast** is a message addressed to a **group**: `239.255.0.1:2237`.
> A radio broadcast. Every program that has tuned to the group gets its own
> copy. No competition, no order, no luck.
Multicast is what WSJT-X offers for exactly this reason, and it is what you
want as soon as **more than one program** listens to your decoder.
### What it looks like when it goes wrong
Two starts of the same station, an hour apart, nothing changed in any setting:
```
06:43 autostart: GridTracker2 — already_running
06:43 udp: [Decodium] listening on unicast :2237
… not one decode all morning, though WSJT-X was running and logging QSOs
10:30 udp: [Decodium] listening on unicast :2237
10:30 autostart: GridTracker2 — launched
10:30 udp: emit udp:dx_call "JK2TTP" (mode=FT4 freq=21140000)
… decodes all the way through
```
In the morning GridTracker was already holding port 2237 when OpsLog started.
In the second session OpsLog got there first. Same programs, same settings, and
the only difference is who started first.
---
## The setup that works
Pick one group and one port and give the **same pair** to every program.
`239.255.0.1` port `2237` is the usual choice.
### WSJT-X
**File → Settings → Reporting**, *UDP Server* box:
| Field | Value |
|---|---|
| UDP Server | `239.255.0.1` |
| UDP Server port number | `2237` |
| Outgoing interfaces | tick your **LAN/Wi-Fi adapter** *and* **loopback** |
| Accept UDP requests | ✔ ticked |
*Outgoing interfaces* matters and is easy to miss: with only the LAN adapter
ticked, programs on the same PC may never see the traffic. Tick loopback too.
*Accept UDP requests* is what lets OpsLog answer a station for you and clear
the DX call. Without it OpsLog still hears everything, but can only watch.
### JTDX
Same place, same values — JTDX keeps WSJT-X's Reporting tab.
### MSHV
**Options → Settings → Reporting** (WSJT-X protocol), same address and port.
### GridTracker
**Settings → WSJT-X / UDP**: address `239.255.0.1`, port `2237`, multicast
enabled. GridTracker joins the group like everyone else instead of owning the
port.
### OpsLog
**Settings → Connections → Add**:
| Field | Value |
|---|---|
| Direction | Inbound |
| Service | WSJT-X / JTDX / MSHV |
| Port | `2237` |
| Multicast | ✔ ticked |
| Group | `239.255.0.1` |
Save. The log should say:
```
udp: [WSJT-X] listening on multicast 239.255.0.1:2237 on 2 interface(s) (service=wsjt)
```
---
## The mistake to avoid: 127.0.0.1 in the group box
A **multicast group is an address between `224.0.0.0` and `239.255.255.255`**.
Nothing else can be joined.
`127.0.0.1` is loopback — an ordinary unicast address — and it is a very
understandable thing to type, because it is what every other field in every
other program wants. But a row ticked *Multicast* with `127.0.0.1` in the group
box cannot join anything, and used to fail with a Windows message naming
nothing you had typed:
```
udp: [Wsjtx] join 127.0.0.1 on Wi-Fi 2: setsockopt: the requested address is not
valid in its context
udp: start "Wsjtx" failed: couldn't join multicast 127.0.0.1 on any interface
```
OpsLog now listens on unicast instead and says so, so the row still works — but
if you meant multicast, put a real group in.
**Rule of thumb:** if the address starts with `127.` or `192.168.` or `10.`,
untick *Multicast*.
---
## If you have to stay on unicast
Sometimes you cannot use multicast — an old program, a locked-down network.
Then **only one program may listen on the port**, and the others are fed by it.
**OpsLog can be the one that feeds them.** Add an outbound *Relay the WSJT-X
stream* row (Settings → Connections) and it re-sends every datagram it receives,
byte for byte, to wherever you point it:
```
WSJT-X ──unicast 2237──▶ OpsLog ──relay──▶ GridTracker (2238)
```
Set GridTracker to listen on **2238** instead of 2237 and nothing competes for
anything. Point the relay at the other program's port, never at one of OpsLog's
own — that would feed the stream back into itself, and the relay refuses rather
than let it.
It works the other way round too, if you prefer GridTracker to own the port:
GridTracker forwards to a **different** port and OpsLog takes an inbound row
there. Either order is fine. The one arrangement that never works reliably is
two programs on one unicast port.
---
## Checking it
1. **Settings → Connections** — the row is enabled and its port matches.
2. **Help → Application log** — one of these two lines appears at startup:
- `listening on multicast 239.255.0.1:2237 on N interface(s)`
- `listening on unicast :2237` — fine only if nothing else listens there
3. Tune to a busy FT8 frequency. Within a minute the log fills with:
```
udp: emit udp:dx_call "F5ABC" (mode=FT8 freq=14074000)
```
4. Nothing at all? In order: is WSJT-X's UDP server address the same group; is
*Outgoing interfaces* including loopback; is another program holding the
port; is Windows Firewall blocking OpsLog.
> **Not the same thing:** `rigctld: sharing CAT on port 2237` in the log is
> OpsLog's **TCP** CAT sharing for Hamlib clients. TCP and UDP ports are
> separate — it does not conflict with the UDP listener, however alike the
> numbers look.
---
## Related
- [[Connections]] — every inbound and outbound row, and what each service does
- [[Logging Basics]] — what happens when a QSO arrives from WSJT-X
- [[Troubleshooting]] — where the log lives
+1
View File
@@ -29,6 +29,7 @@ Developed by **F4BPO**.
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
| Digital modes | [[Digital Modes and GridTracker]] · [[Connections]] |
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
| Confirmations | [[Awards]] · [[QSL Management]] · [[QSL Card Designer]] |
+22 -7
View File
@@ -32,13 +32,28 @@ one you pick.
### Types (Settings → Rotator)
| Type | Connection | Notes |
|---|---|---|
| **PstRotator** | UDP | Enable PstRotator's UDP listener (Setup → Communication → UDP). |
| **Rotator Genius** (4O3A) | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
| **microHAM ARCO / GS-232A** | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
| **Hy-Gain DCU-1** | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. Azimuth only. A DCU-1 is 4800 baud; others may differ — match the controller. |
| **SPID / AlfaSpid** | COM port | Native, so PstRotator is not needed in between. See below. |
Every rotator interface is configured here, once — including the az/el ones a
satellite pass needs. The satellite page does not configure a rotator; it picks
one of these.
| Type | Axes | Connection | Notes |
|---|---|---|---|
| **PstRotator** | Az, or Az + El | UDP | Enable PstRotator's UDP listener (Setup → Communication → UDP). Tick *This rotator has an elevation axis* if the mast behind PstRotator has one — PstRotator itself will forward elevation to a rotor that cannot use it. |
| **Rotator Genius** (4O3A) | Az | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
| **GS-232 azimuth** (microHAM ARCO, ERC) | Az | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
| **ERC-M by DF9GR** | Az + El | USB COM or LAN | The az/el interface for a **Yaesu G-5500** and its relatives. GS-232 emulation, 19200 baud out of the box. Pick this rather than the GS-232 azimuth entry: it is what tells OpsLog the mast has an elevation motor. |
| **Hy-Gain DCU-1** | Az | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. A DCU-1 is 4800 baud; others may differ — match the controller. |
| **SPID / AlfaSpid** | Az + El (Rot2Prog) | COM port | Native, so PstRotator is not needed in between. See below. |
| **EasyComm II** | Az + El | COM port or TCP | What SatPC32, Gpredict, Hamlib and K3NG firmware speak — the usual choice for a home-built az/el controller. |
Each interface carries an **Az** or **Az + El** badge beside its name, so you can
see at a glance which of your rotors can follow a satellite.
**Rotator range (360° / 450°)** appears for the interfaces OpsLog drives itself.
A 450° mast follows a pass straight through north instead of unwinding. It is
deliberately absent for PstRotator: 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 mid-pass.
### SPID / AlfaSpid
+63
View File
@@ -3,6 +3,69 @@
**Tools → QSL Manager.** Upload and download confirmations for the online QSL
services, and manage paper QSL.
---
## Confirmations — the status every new QSO starts with
**Settings → Confirmations.** This page does not send anything. It sets the
**status stamped on each new QSO**, per service, at the moment it is logged —
by hand, or automatically from WSJT-X / JTDX / MSHV.
Think of it as the starting position of each column in your log. Everything
else — the QSL Manager, the automatic uploads, the *Select required* button —
reads those columns and acts on what it finds.
### ⚠ Sent = **Yes** means the QSO will NEVER be uploaded
`Y` does not mean *please send it*. It means **already sent**.
A contact stamped `Y` is finished as far as that service is concerned: the
uploader skips it, the QSL Manager does not offer it, and nothing is ever sent
for it. Set **eQSL Sent** to `Y` here and every QSO you log from that moment on
will silently never reach eQSL.
There is no error, because nothing failed — OpsLog did exactly what the column
said. The only trace is one line in the application log:
```
extsvc: QSO 3807 not eligible for eqsl — EQSLSent already "Y"
(set Confirmations default to N to upload)
```
If a service has stopped receiving your contacts, this is the first place to
look.
### What each status means
| | Meaning | Effect on uploads |
|---|---|---|
| **R** — requested | Waiting to go out | Uploaded ✔ |
| **N** — no | Not sent / not received | Uploaded ✔ |
| **Q** — queued | Waiting in a batch | Uploaded ✔ |
| **Y** — yes | **Already sent / received** | **Skipped for ever** ✘ |
| **I** — ignore | Deliberately excluded | Skipped |
| *blank* | No status at all | Column stays empty |
### What OpsLog ships
One rule, for every service: the **sent** side starts at **R** (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 — it starts at **N**, because a
card is only *requested* once somebody asks for one.
You only need to change this if you work differently. The common reason is
gating: set a service to `N` and nothing goes out until you select the contacts
yourself in the QSL Manager.
### Two things worth knowing
- **It is per profile.** Your contest callsign can upload to a different set of
services from your home one.
- **It applies from now on.** Changing a default never touches contacts already
in the log — use **Bulk edit field** (right-click in Recent QSOs) for those.
## Services
| Service | Upload | Download confirmations |
+17 -1
View File
@@ -4,7 +4,23 @@ These files are the source for the **OpsLog GitHub Wiki**. GitHub stores a wiki
as its own git repository (`<repo>.wiki.git`), so publishing means pushing these
`.md` files into it.
## Automatic — via the release script (recommended)
## Just the wiki, between releases
**VS Code → *Tasks: Run Task**Publish wiki***, or:
```powershell
powershell -ExecutionPolicy Bypass -File .vscode\publish-wiki.ps1
```
No version bump, no build, no release — it clones the wiki repo, copies these
pages in, and pushes only if something changed (it lists what). Use it whenever
a page is corrected: documentation and releases move at different speeds, and a
fix on a Sunday should not have to wait for the next version.
The release script calls this same file at the end of a release, so there is one
implementation rather than two that drift.
## Automatic — via the release script
The release task (`.vscode/release.ps1`, VS Code → *Tasks: Run Task* → *Release
OpsLog*) **publishes the wiki automatically** at the end of every release: it
+1
View File
@@ -28,6 +28,7 @@
- [[Net Control]]
- [[Multi-Operator Live Status]]
- [[Connections]]
- [[Digital Modes and GridTracker]]
**QSL & Awards**
- [[Awards]]