Compare commits

...
4 Commits
Author SHA1 Message Date
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
12 changed files with 646 additions and 13 deletions
+137 -3
View File
@@ -77,6 +77,10 @@ type satTracker struct {
stop chan struct{} stop chan struct{}
done 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. // 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(), nominalDown: b.Transponders[transponder].Centre(),
stop: make(chan struct{}), stop: make(chan struct{}),
done: 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} 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() t.status.Error = err.Error()
} else { } else {
radio = "sat" radio = "sat"
a.applySatRadio(b.Transponders[transponder])
} }
} }
t.status.Radio = radio t.status.Radio = radio
@@ -258,6 +264,7 @@ func (a *App) satTrackLoop(t *satTracker) {
select { select {
case <-t.stop: case <-t.stop:
return return
case <-t.wake:
case <-tick.C: case <-tick.C:
} }
} }
@@ -359,11 +366,12 @@ func (a *App) satTrackStep(t *satTracker) {
return return
} }
mode := tp.Mode downMode, upMode := satSidebands(tp)
if lastDown != 0 { 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() t.mu.Lock()
if err == nil { if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0 t.lastDown, t.lastUp, t.fails = down, up, 0
@@ -634,3 +642,129 @@ func satBandLetter(hz int64) string {
} }
return "K" // 24 GHz and above 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) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
func (c *countingRotator) Close() {} 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)
}
}
}
+20
View File
@@ -1,4 +1,24 @@
[ [
{
"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", "version": "0.27.20",
"date": "", "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
}
+19 -1
View File
@@ -5,7 +5,7 @@ import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, Panel
import { import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -397,6 +397,24 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
} catch (e: any) { setErr(String(e?.message ?? e)); } } 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 ────────────────────────────────────────────────────────────────── // ── Map ──────────────────────────────────────────────────────────────────
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
+2
View File
@@ -1045,6 +1045,8 @@ export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>; export function RestartQSORecorder():Promise<void>;
export function RetargetSatelliteTracking(arg1:string,arg2:number):Promise<void>;
export function RetryOfflineSync():Promise<number>; export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>; export function RevealDataFolder():Promise<void>;
+4
View File
@@ -2022,6 +2022,10 @@ export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder'](); return window['go']['main']['App']['RestartQSORecorder']();
} }
export function RetargetSatelliteTracking(arg1, arg2) {
return window['go']['main']['App']['RetargetSatelliteTracking'](arg1, arg2);
}
export function RetryOfflineSync() { export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync'](); return window['go']['main']['App']['RetryOfflineSync']();
} }
+5
View File
@@ -584,6 +584,11 @@ type FlexController interface {
SetMute(bool) error SetMute(bool) error
SetRXAntenna(string) error SetRXAntenna(string) error
SetTXAntenna(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 SetActiveSlice(int) error // focus slice idx so commands target it
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and // 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. // keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
+78 -2
View File
@@ -161,8 +161,11 @@ func (f *Flex) satMode(idx int, mode string, freqHz int64) {
if mode == "" { if mode == "" {
return return
} }
// USB on both sides above 30 MHz, which is every satellite worth the name — // A bare "SSB" still means upper sideband above 30 MHz, which is every
// including the parts of a passband that would be an LSB band down on HF. // 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 { if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
mode = "USB" mode = "USB"
} }
@@ -200,3 +203,76 @@ func (f *Flex) SatReceiveHz() (int64, error) {
} }
return s.freqHz, nil 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
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.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
}
+37 -4
View File
@@ -271,9 +271,6 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
if err != nil { if err != nil {
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err) 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{ sv := &sgp4.StateVector{
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z, X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.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, Az: o.LookAngles.Azimuth,
El: o.LookAngles.Elevation, El: o.LookAngles.Elevation,
RangeKm: o.LookAngles.Range, RangeKm: o.LookAngles.Range,
RangeRate: o.LookAngles.RangeRate, RangeRate: e.rangeRate(loc, at.UTC()),
}, nil }, 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 // 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. // sphere, and a metre of flattening does not show at that scale.
const earthRadiusKm = 6371.0 const earthRadiusKm = 6371.0
+73
View File
@@ -4,6 +4,8 @@ import (
"math" "math"
"testing" "testing"
"time" "time"
"github.com/akhenakh/sgp4"
) )
// A real ISS element set, and the answers a second tracker agrees with. The // 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" 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 { func issElement(t *testing.T) Element {
t.Helper() t.Helper()
e, err := ParseElement(issName, issLine1, issLine2) e, err := ParseElement(issName, issLine1, issLine2)
@@ -170,3 +175,71 @@ func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
t.Error("an unknown satellite was tracked anyway") 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
}
+59 -2
View File
@@ -32,7 +32,9 @@ import (
const ( const (
cmdNull = 0x13 cmdNull = 0x13
cmdAdmin = 0x00 cmdAdmin = 0x00
adminReset = 0x01
adminOpen = 0x02 adminOpen = 0x02
adminClose = 0x03
adminEcho = 0x04 adminEcho = 0x04
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101 echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
bootDelay = 400 * time.Millisecond 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 { if attempt > 1 || slowBoot {
wait = resetDelay 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 err == nil {
if attempt > 1 { 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) 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 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. // The keyer may still be booting off the DTR line we just raised.
time.Sleep(boot) time.Sleep(boot)
drain(p) drain(p)
@@ -130,12 +139,60 @@ func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
} }
ver, ok := readByte(p, openTimeout) ver, ok := readByte(p, openTimeout)
traceHandshake("RX", nil, ver, ok) traceHandshake("RX", nil, ver, ok)
if !ok {
// 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 { if !ok {
return 0, errors.New("host open: the keyer echoed but did not return its firmware version") return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
} }
}
return int(ver), nil 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 // traceHandshake puts the opening exchange in the log, ALWAYS — unlike the
// running trace beside it, which is behind the diagnostic option. // running trace beside it, which is behind the diagnostic option.
// //