Compare commits

...
11 Commits
Author SHA1 Message Date
rouggy ce7b3686f5 chore: release v0.27.22 2026-09-10 00:32:03 +02:00
rouggyandClaude Opus 5 b0a973d390 fix(db): stop losing the database pointer, and never lose it silently
An operator spent three hours setting up, accepted the update, and reopened a
program that had forgotten everything. It is the second such report.

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

Three changes, in the order they defend:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Written against the SmartSDR slice API and UNTESTED on hardware.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 20:07:27 +02:00
21 changed files with 1166 additions and 46 deletions
+162 -5
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@@ -950,6 +951,7 @@ type App struct {
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupWarn string // a non-fatal warning worth putting in front of the operator at launch
startupErr string // captured for surfacing to the frontend
settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
@@ -1194,6 +1196,25 @@ func (a *App) startup(ctx context.Context) {
boot.DeletePending = ""
_ = writeBootstrap(dataDir, boot)
}
// About to create a database in a folder that already has one.
//
// This is the shape of every "everything I set up is gone" report: the
// pointer was lost or the default moved, OpsLog opened a NEW empty database
// beside the full one, and the operator was shown a program that had
// forgotten them. Nothing is changed here — guessing which file is theirs is
// how the wrong one gets picked — but it is said loudly, in the startup log
// and on screen, while the old file is still sitting there untouched.
if !fileExists(a.dbPath) {
if others := otherDatabasesIn(dataDir, a.dbPath); len(others) > 0 {
msg := fmt.Sprintf("OpsLog is starting a NEW, EMPTY settings database (%s) although this folder already holds %s. "+
"Nothing has been deleted. If your settings have gone, that other file is where they are: "+
"Settings ▸ Database ▸ open an existing database, and pick it.",
filepath.Base(a.dbPath), strings.Join(others, ", "))
bootLog("%s", msg)
fmt.Println("OpsLog:", msg)
a.startupWarn = msg
}
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
@@ -1741,6 +1762,10 @@ type StartupStatus struct {
OK bool `json:"ok"`
Err string `json:"err"`
DBPath string `json:"db_path"`
// Warn is not a failure and must not be swallowed: OpsLog started, and
// something about WHERE it started is worth an operator seeing before they
// conclude their configuration has been thrown away.
Warn string `json:"warn"`
}
// GetStartupStatus exposes whatever happened during startup so the UI
@@ -1771,6 +1796,7 @@ func (a *App) GetStartupStatus() StartupStatus {
OK: a.startupErr == "",
Err: a.startupErr,
DBPath: a.dbPath,
Warn: a.startupWarn,
}
}
@@ -2405,11 +2431,37 @@ func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool {
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
var c dbPointer
b, err := os.ReadFile(dbPointerPath(dataDir))
path := dbPointerPath(dataDir)
b, err := os.ReadFile(path)
if err != nil {
// No pointer at all: a first run, or a folder that never had one. The
// caller falls back to this folder's own default, which is correct.
return c
}
_ = json.Unmarshal(b, &c)
if uerr := json.Unmarshal(b, &c); uerr != nil || len(bytes.TrimSpace(b)) == 0 {
// A pointer that EXISTS and cannot be read is not the same thing as no
// pointer, and treating it as one is how an operator loses an evening:
// the database moves back to this folder's default, the default is
// empty, and the program opens having forgotten everything. Reported
// twice, both times just after an update.
//
// The previous contents are kept beside it for exactly this, so the
// answer is usually one file away.
bootLog("config.json is unreadable (%v, %d bytes) — trying the backup", uerr, len(b))
var prev dbPointer
if bk, berr := os.ReadFile(path + ".bak"); berr == nil && json.Unmarshal(bk, &prev) == nil {
bootLog("config.json restored from its backup (database %q)", prev.DBPath)
c = prev
// Put it back, so the next launch does not have to do this again.
_ = os.WriteFile(path, bk, 0o644)
} else {
// Nothing to restore from. Keep the broken file rather than
// overwriting it — it is evidence, and it may still be readable by
// hand.
_ = os.Rename(path, path+".broken")
bootLog("no usable backup — the broken config.json was kept as config.json.broken")
}
}
// Stored relative when it lives inside the app folder, so the pointer follows
// the folder from C:OpsLog to D:OpsLog or to a stick.
c.DBPath = resolvePath(dataDir, c.DBPath)
@@ -2417,11 +2469,51 @@ func readBootstrap(dataDir string) dbPointer {
return c
}
// writeBootstrap saves the pointer ATOMICALLY, and keeps the previous one.
//
// os.WriteFile truncates the file and then fills it, so a process that stops
// in between — a crash, a power cut, an update's watchdog force-exiting the old
// instance — leaves an empty or half-written config.json. That file is the only
// record of WHERE the database is, and losing it moved an operator's whole
// station back to an empty default. It has happened twice.
//
// A temporary file renamed into place cannot do that: on Windows and on Unix
// alike the rename is atomic within a volume, so config.json is either entirely
// the old contents or entirely the new. The previous contents are kept as a
// .bak because a pointer is a few dozen bytes and an evening of configuration
// is not.
func writeBootstrap(dataDir string, c dbPointer) error {
c.DBPath = portablePath(c.DBPath)
c.DeletePending = portablePath(c.DeletePending)
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
path := dbPointerPath(dataDir)
if old, rerr := os.ReadFile(path); rerr == nil && len(bytes.TrimSpace(old)) > 0 {
_ = os.WriteFile(path+".bak", old, 0o644)
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
// On disk before the rename, or the rename can publish an empty file.
if err := f.Sync(); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// readDBPointer returns the user-chosen DB path, or "" for the default.
@@ -17486,7 +17578,7 @@ func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error
func linkGoTo(l rotorLink, az, el int) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
return rotgeniusGoTo(l, az)
case "arco":
return arcoClient(l).GoTo(az)
case "erc":
@@ -22517,3 +22609,68 @@ func (a *App) IcomConsolePTT(on bool) error {
}
return a.cat.SetPTT(on)
}
// rotgeniusGoTo picks which way round to reach a bearing when the controller
// has an overlap to offer.
//
// THE GENIUS DECIDES, and it needs no setting from us. It reports the limits it
// is configured with on every heading query, and those are the truth about what
// is bolted to the tower: if the far side of an overlap is reachable it says so,
// and if it is not, asking anyway turns a working command into a rejected one.
// Its manual is unambiguous — "you will not be able to give it a target beyond
// the limits".
//
// In practice today that means the plain bearing, every time. A Rotator Genius
// is a 360° controller: its Limits fields say where the mechanical stop sits
// within one turn ("5 to 4" is a dead zone at four and a half degrees), not how
// far the mast can travel, and an operator with a 450° rotator gets 360° of it.
// The overlap branch stays because the decision is made from what the device
// reports rather than from an assumption about it — a controller that one day
// answers 450 will be driven through the overlap without a line changing here.
//
// Which of the two forms is right depends on where the antenna IS, so the
// heading is read first and the nearer one wins: a beam at 350° heading for 010°
// should cross north, not travel the other 340 degrees.
func rotgeniusGoTo(l rotorLink, az int) error {
c := rotgenius.New(l.Host, l.Port)
a := ((az % 360) + 360) % 360
st, _, err := c.Heading(l.Num)
if err != nil || !st.Connected || st.LimitCW <= 360 {
return c.GoTo(l.Num, a)
}
if alt := a + 360; alt <= st.LimitCW && absInt(alt-st.Azimuth) < absInt(a-st.Azimuth) {
applog.Printf("rotator: %d° is nearer as %d° from the antenna's %d° (Genius limit %d)",
a, alt, st.Azimuth, st.LimitCW)
return c.GoTo(l.Num, alt)
}
return c.GoTo(l.Num, a)
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}
// otherDatabasesIn lists the settings databases sitting in the data folder that
// are NOT the one about to be opened.
//
// Only the two names OpsLog itself ever uses, and only files with something in
// them: a stray .db from another program is not evidence, and a zero-byte file
// is not a lost configuration. The point is to recognise the one situation that
// matters — a full database next to a new empty one — and to say so before the
// operator concludes their evening is gone.
func otherDatabasesIn(dataDir, chosen string) []string {
var out []string
for _, name := range []string{"settings.db", "opslog.db"} {
p := filepath.Join(dataDir, name)
if p == chosen {
continue
}
if fi, err := os.Stat(p); err == nil && fi.Size() > 0 {
out = append(out, name)
}
}
return out
}
+152 -12
View File
@@ -77,6 +77,10 @@ type satTracker struct {
stop chan struct{}
done chan struct{}
// wake makes the loop take a step NOW instead of at the next tick. Changing
// satellite has to move the radio at once: a second of the old bird's
// frequencies is a second of the wrong pass.
wake chan struct{}
}
// SatTrackStatus is what the tracker is doing, for the panel.
@@ -129,6 +133,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
nominalDown: b.Transponders[transponder].Centre(),
stop: make(chan struct{}),
done: make(chan struct{}),
wake: make(chan struct{}, 1),
}
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
@@ -158,6 +163,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
t.status.Error = err.Error()
} else {
radio = "sat"
a.applySatRadio(b.Transponders[transponder])
}
}
t.status.Radio = radio
@@ -258,6 +264,7 @@ func (a *App) satTrackLoop(t *satTracker) {
select {
case <-t.stop:
return
case <-t.wake:
case <-tick.C:
}
}
@@ -331,22 +338,28 @@ func (a *App) satTrackStep(t *satTracker) {
down, up := sh.DownHz, sh.UpHz
t.mu.Lock()
t.status = SatTrackStatus{
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
NominalDown: nominal, NominalUp: nomUp,
DownHz: down, UpHz: up,
Az: pos.Az, El: pos.El, Visible: visible,
Radio: t.status.Radio, Error: t.status.Error,
}
// Rebuilt from the old one, not from nothing.
//
// The rotator fields are written by readRotator, which runs at most every
// three seconds — a controller query binds a socket and waits. Building a
// fresh status here dropped them on every OTHER tick, so the antenna
// readout appeared for one second in three and vanished again, which reads
// as a rotator that keeps disconnecting.
st := t.status
st.On, st.Name, st.Transponder, st.Mode = true, b.Name, tp.Label, tp.Mode
st.NominalDown, st.NominalUp = nominal, nomUp
st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
t.status = st
t.mu.Unlock()
t.pointRotator(pos, b.Geostationary)
t.readRotator()
t.mu.Lock()
st := t.status
out := t.status
t.mu.Unlock()
a.emitSatTrack(st)
a.emitSatTrack(out)
// Only send what has actually moved. The step is the smallest change worth a
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
@@ -359,11 +372,12 @@ func (a *App) satTrackStep(t *satTracker) {
return
}
mode := tp.Mode
downMode, upMode := satSidebands(tp)
if lastDown != 0 {
mode = "" // set once, at the start of the pass — see satMode/satSetMode
// Set once, at the start of the pass — see satMode/satSetMode.
downMode, upMode = "", ""
}
err := a.satTune(down, up, mode, mode)
err := a.satTune(down, up, downMode, upMode)
t.mu.Lock()
if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0
@@ -634,3 +648,129 @@ func satBandLetter(hz int64) string {
}
return "K" // 24 GHz and above
}
// applySatAntennas puts each satellite slice on the antenna configured for ITS
// band.
//
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
// only ever applied by the entry form on a band change — to the active slice.
// A pass never goes through that path: the tracker arms two slices itself, on
// two different bands, and both were left on whatever the radio last used. A
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
// nothing at all, having configured exactly the thing that was being ignored.
//
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
// a correction of ten kilohertz cannot change the band, and the nominal pair is
// what the operator's configuration is written against.
func (a *App) applySatRadio(tp sat.Transponder) {
if a.cat == nil || !a.cat.SatCapable() {
return
}
// The CTCSS tone first: an FM bird will not answer without it, and it is the
// one setting an operator cannot make from the front panel once a pass has
// started. Zero turns it off, which is what a linear bird needs.
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatTone(tp.CTCSS)
}); err != nil {
applog.Printf("sat: could not set the uplink tone: %v", err)
}
m, err := a.GetFlexBandAntennas()
if err != nil || len(m) == 0 {
return
}
// The downlink is received, so it takes that band's RX antenna; the uplink
// is transmitted, so it takes that band's TX antenna.
rxAnt := m[bandForHz(tp.DownLo)].RX
txAnt := m[bandForHz(tp.UpLo)].TX
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatAntennas(rxAnt, txAnt)
}); err != nil {
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
// the wrong antenna is still a pass.
applog.Printf("sat: could not set the satellite antennas: %v", err)
}
}
// satSidebands is which sideband to set on each side of a linear transponder.
//
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
// sideband comes back on upper. Setting USB at both ends — which is what
// happened until now — put the operator's own audio through the transponder
// upside down, which is unreadable at the far end and sounds like nothing much
// at ours.
//
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
// and FM down, and CW is CW whichever way round the passband runs.
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
return tp.Mode, tp.Mode
}
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
// on the AO-7 10 m downlink, which would be lower sideband on HF.
if tp.Inverting {
return "USB", "LSB"
}
return "USB", "USB"
}
// RetargetSatelliteTracking points the tracker at a different satellite without
// letting go of the radio.
//
// Two birds are often up at once, and an operator switching between them found
// the frequencies stayed on the first: the panel's selection is the DISPLAY's,
// while the tracker held its own name and went on following what it was started
// with. Stopping and starting worked, which is how it was discovered, and is
// also how a Flex loses and rebuilds both its slices for no reason.
//
// So the radio stays armed and the rotator stays open, and only what is being
// followed changes. Everything derived from the old satellite is cleared so the
// next step sets it afresh: the frequencies, the mode on both slices (set once
// per satellite, not per tick), the antennas and the tone — the new bird may be
// U/V where the old one was V/U, which swaps which slice is on which band.
func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
// Not tracking: this is simply a start.
return a.StartSatelliteTracking(name, transponder)
}
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok || len(b.Transponders) == 0 {
return fmt.Errorf("%s has no frequency plan to tune to", name)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
tp := b.Transponders[transponder]
t.mu.Lock()
t.name, t.tp = b.Name, transponder
t.nominalDown = tp.Centre()
// Zeroed so the next step tunes and sets the mode again rather than deciding
// nothing has changed.
t.lastDown, t.lastUp, t.fails = 0, 0, 0
// And so the antenna is commanded at once instead of waiting for the new
// satellite to drift a step away from where the old one happened to be.
t.rotSent = false
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
t.status.Error = ""
t.mu.Unlock()
if a.cat != nil && a.cat.SatCapable() {
a.applySatRadio(tp)
}
select {
case t.wake <- struct{}{}:
default: // a step is already pending; it will pick this up
}
applog.Printf("sat: now tracking %s (%s)", b.Name, tp.Label)
return nil
}
+29
View File
@@ -106,3 +106,32 @@ func (c *countingRotator) Point(az, el float64) error {
}
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
func (c *countingRotator) Close() {}
// Which sideband goes on each slice.
//
// An inverting transponder turns the passband over, so a signal transmitted on
// lower sideband comes back on upper. Setting USB at both ends put the
// operator's own audio through upside down — unreadable at the far end, and on
// FO-29, RS-44 and AO-73 that is every contact attempted.
func TestSatSidebands(t *testing.T) {
cases := []struct {
name string
tp sat.Transponder
wantDown, want string
}{
{"inverting linear: LSB up, USB down",
sat.Transponder{Mode: "SSB", Inverting: true}, "USB", "LSB"},
{"non-inverting linear: USB both ways",
sat.Transponder{Mode: "SSB"}, "USB", "USB"},
// A tone is transmitted and received in FM whichever way the passband
// runs, and CW is CW.
{"FM is FM both ways", sat.Transponder{Mode: "FM"}, "FM", "FM"},
{"CW ignores inversion", sat.Transponder{Mode: "CW", Inverting: true}, "CW", "CW"},
}
for _, c := range cases {
down, up := satSidebands(c.tp)
if down != c.wantDown || up != c.want {
t.Errorf("%s: got %s/%s, want %s/%s", c.name, down, up, c.wantDown, c.want)
}
}
}
+42
View File
@@ -1,4 +1,46 @@
[
{
"version": "0.27.22",
"date": "",
"en": [
"If OpsLog is about to create a new, empty settings database in a folder that already holds a full one, it says so — in the startup log and on screen — instead of opening quietly as though nothing were configured. Nothing is deleted and nothing is guessed: the message names the other file, which is where your settings still are.",
"config.json — the one file that records WHERE your database is — is now written atomically, and a copy of the previous one is kept beside it. It was written by truncating the file and then filling it, so a process that stopped in between (a crash, a power cut, an update closing the old instance) left it empty; OpsLog then read \"no database chosen\", opened a new empty one, and started having forgotten everything. An unreadable config.json is now restored from its backup, and one that cannot be restored is KEPT as config.json.broken rather than silently replaced.",
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
"Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controllers, not OpsLogs. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none.",
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed."
],
"fr": [
"Si OpsLog sapprête à créer une base de réglages neuve et vide dans un dossier qui en contient déjà une pleine, il le dit — dans le journal de démarrage et à l’écran — au lieu de souvrir sans bruit comme si rien n’était configuré. Rien nest supprimé et rien nest deviné : le message nomme lautre fichier, là où vos réglages sont toujours.",
"config.json — le seul fichier qui note OÙ se trouve votre base — est désormais écrit de façon atomique, avec une copie de la version précédente conservée à côté. Il était écrit en tronquant le fichier puis en le remplissant : un processus interrompu entre les deux (plantage, coupure de courant, mise à jour fermant lancienne instance) le laissait vide. OpsLog lisait alors « aucune base choisie », en ouvrait une neuve et vide, et démarrait en ayant tout oublié. Un config.json illisible est maintenant restauré depuis sa sauvegarde, et celui quon ne peut pas restaurer est CONSERVÉ sous le nom config.json.broken au lieu d’être remplacé en silence.",
"Laffichage de lantenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — lantenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
"Pendant le suivi, les deux fréquences et le cap de lantenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et lantenne, pas une colonne à lautre bout de la fenêtre — et cest la première chose quon masque pour avoir la carte en pleine largeur. La boussole tourne tant que lantenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
"Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° nen donne que 360, et cette limite est celle du contrôleur, pas dOpsLog. Lamplitude du rotor nest donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout.",
"FlexRadio, satellite : la tranche de montée est correctement armée. Créer une tranche est asynchrone — la radio annonce son numéro ensuite — et OpsLog continuait sans attendre : tout ce qui était destiné à la montée partait dans le vide. Elle n’était jamais accordée (elle restait sur le 435,100 de sa création), ne recevait ni sa bande latérale, ni son antenne, ni sa tonalité CTCSS, et ne devenait jamais l’émettrice — la radio émettait donc sur la tranche de DESCENTE. Larmement attend maintenant les deux tranches, adopte celle que la radio annonce sans réponse propre, et donne à une montée arrivée en retard tout ce qui lui était dû."
]
},
{
"version": "0.27.21",
"date": "",
"en": [
"The Doppler correction was wrong — by a factor of about 250, and in the wrong direction. The SGP4 library reports a range rate that is not one: the ISS closing at 5.5 km/s came back as +2036 km/s, which moved a 2 m downlink two megahertz instead of three kilohertz, and moved it the wrong way. OpsLog now measures the range rate from the range itself, which cannot disagree with physics. A 2 m downlink shifts about ±3.5 kHz across a pass and a 70 cm one about ±10 kHz, as they should.",
"FlexRadio, satellite: the per-band antennas you configured are now applied to the satellite slices. They were not — the entry form applied them on a band change, to the active slice, and a pass never goes through that path. The two slices are on two different bands, so each gets its own: the downlink takes the receive antenna for its band, the uplink the transmit antenna for its. On a station with transverters (XVTA on 2 m, XVTB on 70 cm) the downlink was left on whatever the radio last used, and heard nothing.",
"FlexRadio, satellite: on an inverting transponder the uplink is set to LSB and the downlink to USB, instead of USB at both ends. The passband is turned over, so audio transmitted on the wrong sideband comes back through it upside down — which is every attempted contact on FO-29, RS-44 and AO-73.",
"FlexRadio, satellite: the CTCSS tone is set on the uplink slice from the satellite's frequency plan. An FM bird does not answer without it, and it is the one setting an operator cannot reach from the front panel once a pass has started.",
"Changing satellite while tracking now moves the radio to the new one at once, and the antenna with it. The selection on the page was the display's; the tracker held its own and went on following whatever it was started with, so with two birds up at the same time the frequencies stayed on the first — the only way through was to stop tracking and start it again. The radio stays armed through the change, so a Flex no longer throws away and rebuilds both its slices for nothing.",
"WinKeyer: a keyer that will not connect is now woken up instead of given up on. An operator with a WinKey2 USB had to run K1EL's WKdemo and close it again before OpsLog could open the keyer at all — so the second attempt now does what closing WKdemo does: Host Close in case a session that ended badly left the keyer waiting for a host that went away, Admin Reset for a parser stuck part-way through a command, and a DTR pulse, which on a WKUSB or an Arduino clone is a power-on reset in all but name. A keyer that echoes but refuses to open is also closed and asked again, which is the same leftover-session case seen from the other side. A port known to need this gets it straight away next time."
],
"fr": [
"La correction Doppler était fausse — dun facteur denviron 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui nen est pas une : lISS se rapprochant à 5,5 km/s était rapportée à +2036 km/s, ce qui déplaçait une descente 2 m de deux mégahertz au lieu de trois kilohertz, et dans la mauvaise direction. OpsLog mesure désormais cette vitesse à partir de la distance elle-même, ce qui ne peut pas contredire la physique. Une descente 2 m se décale denviron ±3,5 kHz sur un passage et une 70 cm denviron ±10 kHz, comme il se doit.",
"FlexRadio, satellite : les antennes par bande que vous avez configurées sont désormais appliquées aux tranches satellite. Elles ne l’étaient pas — la fenêtre de saisie les appliquait au changement de bande, sur la tranche active, et un passage ne passe jamais par là. Les deux tranches sont sur deux bandes différentes, donc chacune reçoit la sienne : la descente prend lantenne de réception de sa bande, la montée lantenne d’émission de la sienne. Sur une station à transverters (XVTA en 2 m, XVTB en 70 cm), la descente restait sur ce que la radio utilisait en dernier, et nentendait rien.",
"FlexRadio, satellite : sur un transpondeur inverseur, la montée est mise en LSB et la descente en USB, au lieu dUSB des deux côtés. La bande passante est retournée : une audio émise sur la mauvaise bande latérale revient à lenvers — soit tous les QSO tentés sur FO-29, RS-44 et AO-73.",
"FlexRadio, satellite : la tonalité CTCSS est réglée sur la tranche de montée depuis le plan de fréquences du satellite. Un satellite FM ne répond pas sans elle, et cest le seul réglage quun OM ne peut pas atteindre en façade une fois le passage commencé.",
"Changer de satellite pendant le suivi déplace désormais la radio sur le nouveau immédiatement, et lantenne avec. La sélection de la page était celle de laffichage ; le tracker gardait la sienne et continuait de suivre celui avec lequel il avait démarré, donc avec deux satellites en passage simultané les fréquences restaient sur le premier — il fallait arrêter puis relancer le suivi. La radio reste armée pendant le changement : un Flex ne jette plus ses deux tranches pour les reconstruire inutilement.",
"WinKeyer : un keyer qui refuse de se connecter est désormais réveillé au lieu d’être abandonné. Un OM avec un WinKey2 USB devait lancer le WKdemo de K1EL puis le refermer avant quOpsLog puisse ouvrir le keyer — la seconde tentative fait donc maintenant ce que fait la fermeture de WKdemo : Host Close au cas où une session mal terminée aurait laissé le keyer à attendre un hôte disparu, Admin Reset pour un analyseur bloqué au milieu dune commande, et une impulsion sur DTR, qui sur un WKUSB ou un clone Arduino est une remise sous tension ou presque. Un keyer qui répond à l’écho mais refuse de souvrir est également refermé puis redemandé — le même cas de session résiduelle, vu de lautre côté. Un port connu pour en avoir besoin y a droit demblée la fois suivante."
]
},
{
"version": "0.27.20",
"date": "",
+182
View File
@@ -0,0 +1,182 @@
// Command satdiag answers "is this pass real, and is that Doppler right?" from
// a station's own cached elements, without launching OpsLog.
//
// go run ./cmd/satdiag <data dir> <locator> <satellite>
//
// It prints which element set the satellite resolved to and how old it is, the
// look angle now, the range rate BOTH as the propagator reports it and as the
// range actually changes, the Doppler each transponder would be given, and the
// next passes. It exists because a wrong Doppler and a wrong satellite look the
// same from the front — an operator saying "the frequency moves enormously" —
// and the two are told apart by these numbers in a second.
//
// It found the range rate the SGP4 library reports being wrong by a factor of
// 250 and of the wrong sign. Not part of the build.
package main
import (
"fmt"
"os"
"strings"
"time"
"hamlog/internal/sat"
)
func main() {
dir := os.Args[1]
grid := os.Args[2]
name := os.Args[3]
f := sat.NewFetcher(dir)
els, at, err := f.LoadCache()
if err != nil {
fmt.Println("cache:", err)
os.Exit(1)
}
store := sat.NewStore()
store.Replace(els, at)
fmt.Printf("elements: %d, fetched %s (%s ago)\n\n", len(els), at.Format(time.RFC3339), time.Since(at).Round(time.Minute))
birds, err := sat.LoadBirds(dir)
if err != nil {
fmt.Println("birds:", err)
}
b, ok := birds.Find(name)
if !ok {
fmt.Println("no frequency plan for", name)
os.Exit(1)
}
// The same resolution the app does.
var el sat.Element
found := false
if e, ok := store.GetNORAD(b.NORAD); ok {
el, found = e, true
fmt.Printf("elements found BY NORAD %d → %q\n", b.NORAD, e.Name)
} else if e, ok := store.Get(b.Name); ok {
el, found = e, true
fmt.Printf("elements found by name → %q (NORAD %d)\n", e.Name, e.NORAD)
} else {
for _, a := range b.Aliases {
if e, ok := store.Get(a); ok {
el, found = e, true
fmt.Printf("elements found by alias %q → %q (NORAD %d)\n", a, e.Name, e.NORAD)
break
}
}
}
if !found {
// Last resort, exactly as satElement does: scan every element name and
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
// that resolves perfectly well in the app look unresolvable here.
for _, n := range store.Names() {
if b.Matches(n) {
if e, ok := store.Get(n); ok {
el, found = e, true
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
break
}
}
}
}
if !found {
fmt.Println("NO ELEMENTS")
os.Exit(1)
}
fmt.Printf("epoch: %s (%s old)\n", el.Epoch.Format(time.RFC3339), time.Since(el.Epoch).Round(time.Hour))
fmt.Println("line1:", el.Line1)
lat, lon, okGrid := gridToLatLon(grid)
if !okGrid {
fmt.Println("bad locator:", grid)
os.Exit(1)
}
obs := sat.Observer{Lat: lat, Lon: lon}
fmt.Printf("observer: %s → %.4f, %.4f\n\n", grid, obs.Lat, obs.Lon)
now := time.Now().UTC()
p, err := el.Track(obs, now)
if err != nil {
fmt.Println("track:", err)
os.Exit(1)
}
fmt.Printf("NOW %s : az %.1f el %.1f range %.0f km\n", now.Format("15:04:05"), p.Az, p.El, p.RangeKm)
fmt.Printf(" range rate REPORTED by the library : %+10.3f km/s\n", p.RangeRate)
fmt.Printf(" range rate MEASURED (d range / dt) : %+10.3f km/s\n", numericRate(el, obs, now))
for _, tp := range b.Transponders {
sh := sat.Doppler(p, tp.DownLo, tp.UpLo)
fmt.Printf(" %-28s down %d → %d (%+d Hz) up %d → %d (%+d Hz)\n",
tp.Label, tp.DownLo, sh.DownHz, sh.DownHz-tp.DownLo, tp.UpLo, sh.UpHz, sh.UpHz-tp.UpLo)
}
fmt.Println("\nnext passes (min el 0):")
passes, err := store.Passes(el.Name, obs, now, now.Add(12*time.Hour), 0)
if err != nil {
fmt.Println("passes:", err)
}
for i, ps := range passes {
if i >= 8 {
break
}
fmt.Printf(" %s → %s max %.1f° az %.0f→%.0f\n",
ps.AOS.Format("15:04:05"), ps.LOS.Format("15:04:05"), ps.MaxEl, ps.AOSAz, ps.LOSAz)
}
// The extremes of the Doppler across the next pass, which is the honest
// answer to "does it move that much".
if len(passes) > 0 {
ps := passes[0]
var lo, hi int64
for tt := ps.AOS; tt.Before(ps.LOS); tt = tt.Add(10 * time.Second) {
q, err := el.Track(obs, tt)
if err != nil {
continue
}
d := sat.Doppler(q, b.Transponders[0].DownLo, 0).DownHz - b.Transponders[0].DownLo
if d < lo {
lo = d
}
if d > hi {
hi = d
}
}
fmt.Printf("\ndownlink Doppler across that pass: %+d Hz … %+d Hz (span %d Hz)\n", lo, hi, hi-lo)
}
}
// gridToLatLon is the six-character Maidenhead centre.
func gridToLatLon(g string) (float64, float64, bool) {
g = strings.ToUpper(strings.TrimSpace(g))
if len(g) < 4 {
return 0, 0, false
}
lon := float64(g[0]-'A')*20 - 180
lat := float64(g[1]-'A')*10 - 90
lon += float64(g[2]-'0') * 2
lat += float64(g[3]-'0') * 1
if len(g) >= 6 {
lon += float64(g[4]-'A') * (2.0 / 24)
lat += float64(g[5]-'A') * (1.0 / 24)
lon += (2.0 / 24) / 2
lat += (1.0 / 24) / 2
} else {
lon += 1
lat += 0.5
}
return lat, lon, true
}
// numericRate is the range rate measured rather than reported: the distance a
// second later minus the distance a second earlier, over two seconds. It cannot
// disagree with physics, so it is the reference the library's own figure is
// checked against.
func numericRate(el sat.Element, obs sat.Observer, at time.Time) float64 {
a, e1 := el.Track(obs, at.Add(-time.Second))
b, e2 := el.Track(obs, at.Add(time.Second))
if e1 != nil || e2 != nil {
return 0
}
return (b.RangeKm - a.RangeKm) / 2
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// config.json is the only record of WHERE the database is. Losing it moves an
// operator's whole station back to an empty default, and it has happened twice.
// These are the two ways it was lost.
// A half-written file must never be publishable. os.WriteFile truncates and
// then fills, so a process that stops in between leaves an empty pointer; the
// rename cannot.
func TestWriteBootstrapIsAtomicAndKeepsABackup(t *testing.T) {
dir := t.TempDir()
// Absolute and OUTSIDE the application folder, so portablePath stores them
// verbatim — the round trip is what is under test, not the re-rooting.
first := filepath.Join(t.TempDir(), "first", "one.db")
second := filepath.Join(t.TempDir(), "second", "two.db")
if err := writeBootstrap(dir, dbPointer{DBPath: first}); err != nil {
t.Fatal(err)
}
if err := writeBootstrap(dir, dbPointer{DBPath: second}); err != nil {
t.Fatal(err)
}
// No temporary left lying around to be mistaken for the real thing.
if _, err := os.Stat(dbPointerPath(dir) + ".tmp"); err == nil {
t.Error("the temporary file was left behind")
}
// The previous contents are still there.
var prev dbPointer
b, err := os.ReadFile(dbPointerPath(dir) + ".bak")
if err != nil {
t.Fatalf("no backup was kept: %v", err)
}
if err := json.Unmarshal(b, &prev); err != nil {
t.Fatalf("the backup does not parse: %v", err)
}
if prev.DBPath != first {
t.Errorf("the backup holds %q, want the previous pointer", prev.DBPath)
}
if got := readBootstrap(dir); got.DBPath != second {
t.Errorf("read back %q, want the current pointer", got.DBPath)
}
}
// A pointer that EXISTS and cannot be read is not the same thing as no pointer.
// Treating it as one is what opened an empty database and presented an operator
// with a program that had forgotten them.
func TestReadBootstrapRecoversFromABrokenPointer(t *testing.T) {
dir := t.TempDir()
mine := filepath.Join(t.TempDir(), "mine", "station.db")
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
t.Fatal(err)
}
// Two writes, so there is a backup of the good one to fall back to.
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
t.Fatal(err)
}
// Now truncate it, exactly as an interrupted write would.
if err := os.WriteFile(dbPointerPath(dir), nil, 0o644); err != nil {
t.Fatal(err)
}
got := readBootstrap(dir)
if got.DBPath != mine {
t.Fatalf("read %q — the database the operator chose was lost", got.DBPath)
}
// And it is put back, so the next launch does not have to recover again.
if again := readBootstrap(dir); again.DBPath != mine {
t.Errorf("the restored pointer did not stick: %q", again.DBPath)
}
}
// With nothing to restore from, the broken file is KEPT. It is evidence, and it
// may still be readable by hand.
func TestReadBootstrapKeepsAnUnrecoverablePointer(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dbPointerPath(dir), []byte("{oops"), 0o644); err != nil {
t.Fatal(err)
}
if got := readBootstrap(dir); got.DBPath != "" {
t.Errorf("invented a path out of a broken file: %q", got.DBPath)
}
if _, err := os.Stat(dbPointerPath(dir) + ".broken"); err != nil {
t.Errorf("the broken pointer was not kept: %v", err)
}
}
// The warning that turns a silent loss into a sentence: a new empty database
// about to be created in a folder that already holds a full one.
func TestOtherDatabasesInSpotsTheFullOneNextDoor(t *testing.T) {
dir := t.TempDir()
full := filepath.Join(dir, "opslog.db")
if err := os.WriteFile(full, []byte("not empty"), 0o644); err != nil {
t.Fatal(err)
}
chosen := filepath.Join(dir, "settings.db")
if got := otherDatabasesIn(dir, chosen); len(got) != 1 || got[0] != "opslog.db" {
t.Errorf("got %v, want the full database next door", got)
}
// A zero-byte file is not a lost configuration.
if err := os.WriteFile(full, nil, 0o644); err != nil {
t.Fatal(err)
}
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
t.Errorf("an empty file was reported as a database: %v", got)
}
// And the one being opened is never reported against itself.
if err := os.WriteFile(chosen, []byte("in use"), 0o644); err != nil {
t.Fatal(err)
}
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
t.Errorf("the chosen database was reported as another: %v", got)
}
}
+5
View File
@@ -3530,6 +3530,11 @@ export default function App() {
try {
const st = await GetStartupStatus();
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
// Started, but somewhere that deserves saying out loud — a new, empty
// settings database opened beside a full one. An operator who is not
// told this concludes their configuration was thrown away, when the
// file holding it is sitting right there.
if (st.warn) setError(st.warn);
// First launch (or a never-configured profile): collect the mandatory
// station identity before anything else.
try {
+83 -8
View File
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
@@ -74,11 +74,28 @@ const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
// Four decimals — a hundred hertz, which is what a linear transponder is
// actually tuned to.
//
// It used to be six, and the last two digits changed every tick: the Doppler
// moves about sixty hertz a second on 70 cm, so the display was a blur of
// numbers nobody could read and nobody needed. The RADIO still gets the whole
// figure — the correction is computed and sent to the hertz — this is only how
// much of it is worth putting in front of an operator. The shift beside it, in
// kilohertz, is where the fine movement shows.
const fmtHz = (hz: number) => {
if (!hz) return '—';
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
// Doppler correction moves the last three digits every second.
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
return (hz / 1e6).toFixed(4).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
};
// The Doppler shift, as an operator would say it: hertz while it is small
// enough to say in hertz, kilohertz once it is not. "+9741 Hz" is four digits
// of precision on a number that is only ever read as "about ten kilohertz".
const fmtShift = (hz: number) => {
const sign = hz > 0 ? '+' : '';
const a = Math.abs(hz);
if (a < 1000) return `${sign}${Math.round(a)} Hz`;
return `${sign}${(a / 1000).toFixed(1)} kHz`;
};
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
@@ -397,6 +414,24 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
} catch (e: any) { setErr(String(e?.message ?? e)); }
};
// Changing satellite WHILE tracking moves the radio to the new one at once.
//
// The selection here is the display's; the tracker held its own and went on
// following what it was started with, so two birds up at the same time meant
// switching between them and watching the frequencies stay on the first.
// Stopping and restarting worked, and is also how a Flex throws away and
// rebuilds both its slices for nothing.
//
// Guarded on tracking being on, so selecting a satellite with the radio idle
// stays what it has always been: a look, not a command.
const trackingOn = !!tracking?.on;
useEffect(() => {
if (!trackingOn || !sel) return;
RetargetSatelliteTracking(sel, tpIdx)
.then(async () => setTracking((await GetSatelliteTracking()) as any))
.catch((e: any) => setErr(String(e?.message ?? e)));
}, [sel, tpIdx, trackingOn]);
// ── Map ──────────────────────────────────────────────────────────────────
const divRef = useRef<HTMLDivElement>(null);
@@ -601,6 +636,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
// The pass, as a countdown and a bar. Both derived here from two timestamps,
// so they move every second without asking Go anything.
// Is the antenna still on its way? The rotator is asked where it is every
// three seconds and a mast takes tens of seconds to cross a pass, so a
// difference between where it is and where the satellite is means it is
// moving — which is exactly what a number alone cannot show, and the
// difference between "on its way" and "stuck" is the whole reason to look.
const antennaMoving = !!tracking?.rot_on && !!tracking.rot_live &&
Math.abs(((tracking.az - tracking.rot_az + 540) % 360) - 180) > 3;
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
@@ -669,6 +712,40 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{tracking?.on && tracking.radio === 'downlink-only' && (
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
)}
{/* What the station is actually doing, beside the button that started
it. During a pass an operator watches the radio and the antenna, not
a column on the far side of the window — and that column is the
first thing they hide to get the map full width. */}
{tracking?.on && (
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
<span className="flex items-center gap-1" title={t('sat.down')}>
<ArrowDown className="size-3 text-muted-foreground" />
<span className="font-medium">{fmtHz(tracking.down_hz)}</span>
</span>
{!!tracking.up_hz && (
<span className="flex items-center gap-1" title={t('sat.up')}>
<ArrowUp className="size-3 text-muted-foreground" />
<span className="font-medium">{fmtHz(tracking.up_hz)}</span>
</span>
)}
{tracking.rot_on && (
<span className={cn('flex items-center gap-1 border-l border-border pl-2.5',
antennaMoving && 'text-caution')} title={t('sat.antenna')}>
{/* The needle spins while the antenna is slewing. A rotator
takes tens of seconds to cross a pass, and the difference
between "on its way" and "stuck" is the whole reason to look
at it — a number alone cannot show movement. */}
<Compass className={cn('size-3', antennaMoving ? 'animate-spin' : 'text-muted-foreground')}
style={antennaMoving ? { animationDuration: '3s' } : undefined} />
<span className="font-medium">
{Math.round(tracking.rot_az)}°
{!tracking.rot_az_only && ` / ${Math.round(tracking.rot_el)}°`}
</span>
</span>
)}
</div>
)}
<div className="flex-1" />
{/* Elements are maintenance, so only their AGE is here — and only when
it has become a reason the panel might be wrong. */}
@@ -1013,9 +1090,7 @@ function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: n
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
{!!shift && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{shift > 0 ? '+' : ''}{Math.abs(Math.round(shift))} Hz
</span>
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
)}
</div>
);
@@ -5135,6 +5135,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// 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.
//
// NOT offered for a Rotator Genius. Its manual is plain — "you will
// not be able to give it a target beyond the limits" — and its
// Limits fields say where the mechanical stop sits within ONE turn
// ("5 to 4" is a dead zone at four and a half degrees), not how far
// the mast travels. Offering 450° there would be offering a setting
// that can only ever be refused by the box. Whether the overlap is
// used is decided from what the Genius itself reports, in
// rotgeniusGoTo, and needs no setting at all.
const ownsOverlap = isERC || isEasycomm;
return (
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
+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.20';
export const APP_VERSION = '0.27.22';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -1045,6 +1045,8 @@ export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
export function RetargetSatelliteTracking(arg1:string,arg2:number):Promise<void>;
export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>;
+4
View File
@@ -2022,6 +2022,10 @@ export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder']();
}
export function RetargetSatelliteTracking(arg1, arg2) {
return window['go']['main']['App']['RetargetSatelliteTracking'](arg1, arg2);
}
export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync']();
}
+2
View File
@@ -4578,6 +4578,7 @@ export namespace main {
ok: boolean;
err: string;
db_path: string;
warn: string;
static createFrom(source: any = {}) {
return new StartupStatus(source);
@@ -4588,6 +4589,7 @@ export namespace main {
this.ok = source["ok"];
this.err = source["err"];
this.db_path = source["db_path"];
this.warn = source["warn"];
}
}
export class StationDevice {
+5
View File
@@ -584,6 +584,11 @@ type FlexController interface {
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
// different bands and, with transverters, two different ports.
SatAntennas(rxAnt, txAnt string) error
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
SatTone(hz float64) error
SetActiveSlice(int) error // focus slice idx so commands target it
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
+36 -4
View File
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"
"hamlog/internal/applog"
)
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
@@ -69,10 +71,18 @@ 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
// What the uplink slice is owed, kept so it can be given to a slice that
// turns up LATE. Arming, the antennas, the tone and the mode all happen
// before the radio has necessarily reported the slice it was asked to
// create; without this they were applied to an index of -1 and never again.
satUpMode string
satRXAnt string
satTXAnt string
satTone float64
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)
@@ -1034,7 +1044,29 @@ func (f *Flex) handleStatus(payload string) {
s.filterHi = atoiDefault(val, s.filterHi)
}
}
// A satellite uplink slice that arrived without our hearing about it.
//
// satCreate correlates the "slice create" reply by sequence number, and when
// that correlation misses, the slice exists on the radio and OpsLog does not
// know its index. Everything then silently does nothing: the uplink is never
// tuned, never gets its mode, never gets its antenna or its CTCSS tone, and
// — worst — never becomes the transmitter, so the radio goes on transmitting
// on the DOWNLINK slice. Seen on the air: slice B sitting at the 435.100000
// it was created with, both slices in USB, and the red TX badge on the 2 m
// downlink.
//
// The status message needs no correlation. If satellite mode is armed, the
// uplink is still unknown, and a slice is in use that is not the downlink,
// that is the slice — the radio is telling us plainly.
adopt := -1
if f.satOn && f.satTX < 0 && idx != f.satRX && s.inUse {
adopt = idx
}
f.mu.Unlock()
if adopt >= 0 {
applog.Printf("flex: adopting slice %d as the satellite uplink from its status — the create reply never came back", adopt)
f.adoptSatSlice("tx", adopt)
}
}
// defInt returns v, or def when v is zero (so sliders show sane defaults before
+136 -2
View File
@@ -3,6 +3,7 @@ package cat
import (
"fmt"
"strings"
"time"
"hamlog/internal/applog"
)
@@ -65,10 +66,41 @@ func (f *Flex) SetSatellite(on bool) error {
} else {
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
}
// WAIT for the slices before saying the pair is armed.
//
// Creating a slice is asynchronous: the index comes back in a later reply.
// Returning before it arrives meant everything downstream ran against an
// uplink of -1 — no antenna, no CTCSS tone, no mode, never tuned, and never
// made the transmitter, so the radio went on transmitting on the DOWNLINK.
// Seen on the air, and it is the one failure here that can put a signal
// somewhere it must not go.
rxIdx, txIdx = f.awaitSatSlices(3 * time.Second)
if rxIdx < 0 || txIdx < 0 {
applog.Printf("flex: satellite armed but the radio did not report both slices (rx %d, tx %d) — "+
"the uplink will be picked up when it does", rxIdx, txIdx)
return nil
}
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
return nil
}
// awaitSatSlices waits for both slice indices to be known, and returns whatever
// it has when the time is up. Polled rather than signalled: the indices arrive
// on the reader goroutine by two different routes — the create reply and the
// slice status — and a poll is indifferent to which of them got there first.
func (f *Flex) awaitSatSlices(d time.Duration) (rx, tx int) {
deadline := time.Now().Add(d)
for {
f.mu.Lock()
rx, tx = f.satRX, f.satTX
f.mu.Unlock()
if (rx >= 0 && tx >= 0) || time.Now().After(deadline) {
return rx, tx
}
time.Sleep(50 * time.Millisecond)
}
}
func (f *Flex) satDisarm() error {
f.mu.Lock()
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
@@ -118,6 +150,24 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
f.mu.Unlock()
if role == "tx" {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
// Everything this slice was owed while nobody knew where it was. Set
// here rather than left to the next Doppler step, because the mode, the
// antenna and the tone are all sent ONCE — the step only re-sends
// frequencies.
f.mu.Lock()
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone
f.mu.Unlock()
if strings.TrimSpace(ant) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
}
if strings.TrimSpace(mode) != "" {
f.satMode(idx, mode, 0)
}
if tone > 0 {
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", idx, tone))
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", idx))
}
}
applog.Printf("flex: satellite %s slice is %d", role, idx)
}
@@ -146,6 +196,11 @@ func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
f.satMode(rx, downMode, downHz)
}
if strings.TrimSpace(upMode) != "" {
f.mu.Lock()
f.satUpMode = upMode
f.mu.Unlock()
}
if tx >= 0 && upHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
f.satMode(tx, upMode, upHz)
@@ -161,8 +216,11 @@ func (f *Flex) satMode(idx int, mode string, freqHz int64) {
if mode == "" {
return
}
// USB on both sides above 30 MHz, which is every satellite worth the name —
// including the parts of a passband that would be an LSB band down on HF.
// A bare "SSB" still means upper sideband above 30 MHz, which is every
// satellite worth the name — including the parts of a passband that would be
// an LSB band down on HF. An explicit USB or LSB from the caller is left
// alone: on an INVERTING transponder the two sides are different sidebands,
// and only the caller knows which way round this bird runs.
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
mode = "USB"
}
@@ -200,3 +258,79 @@ func (f *Flex) SatReceiveHz() (int64, error) {
}
return s.freqHz, nil
}
// SatAntennas selects the antenna each satellite slice uses.
//
// The two slices are on two different bands — a V/U bird receives on 70 cm and
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
// antenna setting. On a station with transverters they are not even the same
// port: XVTA for 2 m, XVTB for 70 cm, and a downlink slice left on the HF
// antenna hears nothing at all.
//
// Per SLICE, not through sendSlice, which addresses whichever slice is active.
// During a pass the active slice is the downlink, so the uplink's antenna would
// never have been set.
//
// Empty strings are left alone: an operator who has configured 2 m and not
// 70 cm should keep whatever the radio already had on the other side rather
// than have it cleared.
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
f.mu.Lock()
rx, tx := f.satRX, f.satTX
connected := f.conn != nil
// Remembered so a slice that is reported late still gets its antenna.
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
// The downlink slice is the one being listened to, so it takes the receive
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
}
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
// A transmit slice also has to HEAR its own band on some radios, and a
// transverter port is the only thing connected to it. Setting the
// receive antenna to match costs nothing when it is already right.
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
}
return nil
}
// SatTone sets the CTCSS tone the uplink slice transmits, in Hz. Zero turns it
// off.
//
// On the UPLINK slice, because that is the one that keys: a tone is something
// transmitted, and the repeater on the satellite will not open without it. This
// is the whole difference between an operator hearing a pass and hearing
// nothing on SO-50, AO-91, PO-101 and every other FM bird with a tone — and it
// is exactly the setting that cannot be made by hand mid-pass.
func (f *Flex) SatTone(hz float64) error {
f.mu.Lock()
tx := f.satTX
connected := f.conn != nil
f.satTone = hz
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if tx < 0 {
return nil // the slice has not come back yet; the next arming will set it
}
if hz <= 0 {
f.send(fmt.Sprintf("slice s %d fm_tone_mode=OFF", tx))
applog.Printf("flex: satellite uplink tone off")
return nil
}
// Value before mode: a radio that is told CTCSS_TX while still holding the
// previous tone transmits the previous tone for as long as it takes the
// second command to arrive.
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", tx, hz))
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", tx))
applog.Printf("flex: satellite uplink tone %.1f Hz on slice %d", hz, tx)
return nil
}
+27 -6
View File
@@ -33,10 +33,16 @@ const (
// Status is one rotator's live state parsed from a |h reply.
type Status struct {
Azimuth int // current heading in degrees (0..360)
Azimuth int // current heading in degrees (0..450 on an overlap rotator)
Connected bool // false when the sensor reports 999 (not connected)
Moving int // 0 not moving, 1 CW, 2 CCW
Target int // target azimuth when moving (else -1)
// The soft limits the Genius itself is configured with, as it reports them.
// Read rather than assumed: an operator with a 450° mast has told the
// Genius so, and that is the authority on how far it will go — OpsLog
// asking for 400° on a box configured for 360 is a command it will refuse.
LimitCW int
LimitCCW int
}
// Client is a stateless connector: each call opens a short-lived TCP connection,
@@ -128,15 +134,30 @@ func (c *Client) Read(rotator int) (Status, error) {
cur := atoiField(string(p[base : base+3]))
moving := atoiField(string(p[base+10 : base+11]))
target := atoiField(string(p[base+15 : base+18]))
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
st := Status{
Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1,
LimitCW: atoiField(string(p[base+3 : base+6])),
LimitCCW: atoiField(string(p[base+6 : base+9])),
}
if target != 999 {
st.Target = target
}
return st, nil
}
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
// accept, 'F' on reject.
// GoTo moves the rotator to az. The reply's status byte is 'K' on accept, 'F'
// on reject.
//
// The ceiling is 450 and not 360, which is the whole point: a rotator with an
// overlap can be asked for 010° as either 10 or 370, and only the second reaches
// it without unwinding the cable back through north. The command carries three
// digits, so the range was never the protocol's — it was ours, and it left an
// operator with a 450° mast clicking "clockwise" by hand every time a bearing
// crossed north.
//
// A Genius configured for a 360° rotator refuses a target beyond its own limit,
// which is the correct place for that decision: it knows what is bolted to the
// tower, and OpsLog does not.
func (c *Client) GoTo(rotator, az int) error {
if rotator != 1 && rotator != 2 {
rotator = 1
@@ -144,8 +165,8 @@ func (c *Client) GoTo(rotator, az int) error {
if az < 0 {
az = 0
}
if az > 360 {
az = 360
if az > 450 {
az = 450
}
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
if err != nil {
+37 -4
View File
@@ -271,9 +271,6 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
if err != nil {
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
}
// The state vector carries the position AND the velocity, which is what the
// look angle needs for the range rate — and the range rate is the whole of
// the Doppler shift.
sv := &sgp4.StateVector{
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
@@ -292,10 +289,46 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
Az: o.LookAngles.Azimuth,
El: o.LookAngles.Elevation,
RangeKm: o.LookAngles.Range,
RangeRate: o.LookAngles.RangeRate,
RangeRate: e.rangeRate(loc, at.UTC()),
}, nil
}
// rangeRate is how fast the satellite is closing or opening, in km/s.
//
// MEASURED, not taken from the propagator. The library reports a range rate
// that is wrong by a factor of some 250 AND has the wrong sign — the ISS at
// 5.5 km/s (closing) came back as +2036 km/s — which put the Doppler
// correction hundreds of kilohertz out and moved it the wrong way. The
// difference between two ranges a second apart cannot be wrong in either
// respect: it differentiates the very number the panel displays.
//
// Two extra propagations per call. SGP4 costs microseconds and this runs at
// most a few hundred times a second across every satellite on screen, so the
// price of being right here is not worth optimising away.
func (e Element) rangeRate(loc *sgp4.Location, at time.Time) float64 {
const dt = time.Second // ±1 s: far below any curvature in the range, far above float noise
before, ok1 := e.rangeAt(loc, at.Add(-dt))
after, ok2 := e.rangeAt(loc, at.Add(dt))
if !ok1 || !ok2 {
return 0
}
return (after - before) / (2 * dt.Seconds())
}
// rangeAt is the distance to the satellite at one instant, in km.
func (e Element) rangeAt(loc *sgp4.Location, at time.Time) (float64, bool) {
eci, err := e.tle.FindPositionAtTime(at.UTC())
if err != nil {
return 0, false
}
sv := &sgp4.StateVector{X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z}
o, err := sv.GetLookAngle(loc, at.UTC())
if err != nil {
return 0, false
}
return o.LookAngles.Range, true
}
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
// sphere, and a metre of flattening does not show at that scale.
const earthRadiusKm = 6371.0
+73
View File
@@ -4,6 +4,8 @@ import (
"math"
"testing"
"time"
"github.com/akhenakh/sgp4"
)
// A real ISS element set, and the answers a second tracker agrees with. The
@@ -16,6 +18,9 @@ const (
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
)
// testLoc is the same observer, in the form the internal range helper takes.
var testLoc = sgp4.Location{Latitude: 48.5, Longitude: 3.0}
func issElement(t *testing.T) Element {
t.Helper()
e, err := ParseElement(issName, issLine1, issLine2)
@@ -170,3 +175,71 @@ func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
t.Error("an unknown satellite was tracked anyway")
}
}
// The range rate is the whole of the Doppler shift, and it was wrong in both
// magnitude and sign — the propagator library reported +2036 km/s for an ISS
// that was closing at 5.5, which moved the correction hundreds of kilohertz the
// wrong way. These are the two things about it that cannot be argued with.
func TestRangeRateIsPhysical(t *testing.T) {
e := issElement(t)
obs := Observer{Lat: 48.5, Lon: 3.0}
// A day's worth, sampled across every geometry a pass goes through.
base := e.Epoch.Add(2 * time.Hour)
for i := 0; i < 240; i++ {
at := base.Add(time.Duration(i) * 6 * time.Minute)
p, err := e.Track(obs, at)
if err != nil {
t.Fatalf("track: %v", err)
}
// Nothing in low earth orbit closes faster than it flies, and it flies
// at about 7.7 km/s. A figure outside this is a units mistake.
if math.Abs(p.RangeRate) > 8 {
t.Fatalf("%s: range rate %.1f km/s — faster than orbital velocity", at.Format(time.RFC3339), p.RangeRate)
}
// And it must be the derivative of the range we display, sign included.
before, _ := e.rangeAt(&testLoc, at.Add(-2*time.Second))
after, _ := e.rangeAt(&testLoc, at.Add(2*time.Second))
want := (after - before) / 4
if math.Abs(p.RangeRate-want) > 0.05 {
t.Errorf("%s: range rate %.3f but the range moves at %.3f km/s",
at.Format(time.RFC3339), p.RangeRate, want)
}
}
}
// The Doppler that comes out of it, on the two bands satellites are worked on.
// A LEO gives about ±3.5 kHz on 2 m and ±10 kHz on 70 cm; ten times either is
// the bug this pins.
func TestDopplerStaysWithinTheTextbookRange(t *testing.T) {
e := issElement(t)
obs := Observer{Lat: 48.5, Lon: 3.0}
base := e.Epoch.Add(2 * time.Hour)
var maxVHF, maxUHF int64
for i := 0; i < 480; i++ {
p, err := e.Track(obs, base.Add(time.Duration(i)*3*time.Minute))
if err != nil {
continue
}
vhf := Doppler(p, 145_800_000, 0).DownHz - 145_800_000
uhf := Doppler(p, 437_800_000, 0).DownHz - 437_800_000
if a := abs64(vhf); a > maxVHF {
maxVHF = a
}
if a := abs64(uhf); a > maxUHF {
maxUHF = a
}
}
if maxVHF < 1_500 || maxVHF > 5_000 {
t.Errorf("2 m Doppler peaks at %d Hz, expected roughly 3.5 kHz", maxVHF)
}
if maxUHF < 5_000 || maxUHF > 14_000 {
t.Errorf("70 cm Doppler peaks at %d Hz, expected roughly 10 kHz", maxUHF)
}
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
+60 -3
View File
@@ -32,7 +32,9 @@ import (
const (
cmdNull = 0x13
cmdAdmin = 0x00
adminReset = 0x01
adminOpen = 0x02
adminClose = 0x03
adminEcho = 0x04
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
bootDelay = 400 * time.Millisecond
@@ -75,7 +77,11 @@ func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err er
if attempt > 1 || slowBoot {
wait = resetDelay
}
ver, err := hostOpenOnce(p, wait)
// A port already known to need the slow path gets the wake-up on the
// FIRST attempt too: it needed it last time, and making the operator
// wait through a failure to earn it again is a connect that takes twice
// as long for no new information.
ver, err := hostOpenOnce(p, wait, attempt > 1 || slowBoot)
if err == nil {
if attempt > 1 {
applog.Printf("winkeyer: answered on attempt %d — this keyer needs %s to boot (a K3NG or another Arduino keyer with auto-reset on); remembering that for this port", attempt, wait)
@@ -90,7 +96,10 @@ func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err er
return 0, false, lastErr
}
func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
func hostOpenOnce(p serial.Port, boot time.Duration, hard bool) (int, error) {
if hard {
recoverKeyer(p)
}
// The keyer may still be booting off the DTR line we just raised.
time.Sleep(boot)
drain(p)
@@ -131,11 +140,59 @@ func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
ver, ok := readByte(p, openTimeout)
traceHandshake("RX", nil, ver, ok)
if !ok {
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
// It answered the echo, so there IS a keyer on this port — it simply
// will not open. A keyer already IN host mode does exactly that: a
// previous session that ended badly never sent Host Close, and it has
// been waiting ever since for a host that went away. Close it and ask
// again.
applog.Printf("winkeyer: echoed but did not open — closing a host session left over from last time, and asking again")
if _, err := p.Write([]byte{cmdAdmin, adminClose}); err != nil {
return 0, fmt.Errorf("host close: %w", err)
}
time.Sleep(250 * time.Millisecond)
drain(p)
traceHandshake("TX", open, 0, false)
if _, err := p.Write(open); err != nil {
return 0, fmt.Errorf("host open: %w", err)
}
ver, ok = readByte(p, openTimeout)
traceHandshake("RX", nil, ver, ok)
if !ok {
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
}
}
return int(ver), nil
}
// recoverKeyer does to the keyer what running K1EL's WKdemo and closing it
// again does — which is the workaround an operator found for a WKUSB that
// OpsLog could not open until they had.
//
// Three things, in an order that survives each of them failing:
//
// - Host Close, in case the keyer is still in host mode from a session that
// ended without one: a crash, a cable pulled, a machine switched off.
// - Admin Reset, which returns it to its power-up state. A parser stuck
// part-way through a command whose parameters will never arrive cannot be
// talked out of it any other way.
// - A DTR pulse. That is what closing another program actually does to the
// line, and on the boxes that wire DTR to the processor's reset — a WKUSB,
// and every Arduino-based clone — it is a power-on reset in all but name.
//
// RTS is left alone throughout: on a serial WinKeyer it is the negative rail
// the RS-232 swing comes from, and driving it starves the chip.
func recoverKeyer(p serial.Port) {
applog.Printf("winkeyer: waking the keyer — host close, reset, then a DTR pulse")
_, _ = p.Write([]byte{cmdNull, cmdNull, cmdNull, cmdAdmin, adminClose})
time.Sleep(150 * time.Millisecond)
_, _ = p.Write([]byte{cmdAdmin, adminReset})
time.Sleep(150 * time.Millisecond)
_ = p.SetDTR(false)
time.Sleep(250 * time.Millisecond)
_ = p.SetDTR(true)
drain(p)
}
// traceHandshake puts the opening exchange in the log, ALWAYS — unlike the
// running trace beside it, which is behind the diagnostic option.
//
+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.20"
appVersion = "0.27.22"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.