Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dad00f8ad | ||
|
|
b8491f3038 | ||
|
|
5ddc5e38a9 | ||
|
|
8f643b5b67 | ||
|
|
37b798e9f5 | ||
|
|
3ac6f7e49c | ||
|
|
0add56fb2d |
@@ -17486,7 +17486,7 @@ func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error
|
|||||||
func linkGoTo(l rotorLink, az, el int) error {
|
func linkGoTo(l rotorLink, az, el int) error {
|
||||||
switch l.Type {
|
switch l.Type {
|
||||||
case "rotgenius":
|
case "rotgenius":
|
||||||
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
|
return rotgeniusGoTo(l, az)
|
||||||
case "arco":
|
case "arco":
|
||||||
return arcoClient(l).GoTo(az)
|
return arcoClient(l).GoTo(az)
|
||||||
case "erc":
|
case "erc":
|
||||||
@@ -22517,3 +22517,77 @@ func (a *App) IcomConsolePTT(on bool) error {
|
|||||||
}
|
}
|
||||||
return a.cat.SetPTT(on)
|
return a.cat.SetPTT(on)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rotgeniusGoTo picks which way round to reach a bearing on a mast with an
|
||||||
|
// overlap.
|
||||||
|
//
|
||||||
|
// A 450° rotator can be at 010° twice over: once at 10 and once at 370, and
|
||||||
|
// only the second reaches it without unwinding the cable back through north.
|
||||||
|
// OpsLog used to clamp every target to 360, so an operator with such a mast
|
||||||
|
// watched the Rotator Genius stop dead at 359 and had to press "clockwise" by
|
||||||
|
// hand to get through north.
|
||||||
|
//
|
||||||
|
// THE GENIUS DECIDES WHAT IS REACHABLE, not us and not a setting. It reports the
|
||||||
|
// limits it is configured with, and they are the truth about what is bolted to
|
||||||
|
// the tower: a box configured "5 to 4" — the factory 360° range — refuses a
|
||||||
|
// target of 370, and asking anyway turns a working command into a rejected one.
|
||||||
|
// So the overlap is used only when the Genius itself says it has one, and when
|
||||||
|
// it does not, the operator is told, because the setting to change is in the
|
||||||
|
// Genius and not here.
|
||||||
|
//
|
||||||
|
// Which of the two forms is right then depends on where the antenna IS, so the
|
||||||
|
// heading is read first and the nearer one wins. That is the point of an
|
||||||
|
// overlap: 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
|
||||||
|
if l.MaxAz <= 360 {
|
||||||
|
return c.GoTo(l.Num, a)
|
||||||
|
}
|
||||||
|
st, _, err := c.Heading(l.Num)
|
||||||
|
if err != nil || !st.Connected {
|
||||||
|
// No reading to compare against. The plain bearing is always reachable;
|
||||||
|
// the overlap is an optimisation, not a requirement.
|
||||||
|
return c.GoTo(l.Num, a)
|
||||||
|
}
|
||||||
|
if st.LimitCW <= 360 {
|
||||||
|
// OpsLog is set to 450 and the Genius is not. Said once per move rather
|
||||||
|
// than silently doing the wrong thing — the fix is in the Genius's own
|
||||||
|
// rotator configuration, and nothing here can reach past its limits.
|
||||||
|
rotgeniusRangeWarn(l, st)
|
||||||
|
return c.GoTo(l.Num, a)
|
||||||
|
}
|
||||||
|
target := a
|
||||||
|
if alt := a + 360; alt <= st.LimitCW && absInt(alt-st.Azimuth) < absInt(a-st.Azimuth) {
|
||||||
|
target = alt
|
||||||
|
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, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotgeniusRangeWarn says, at most once a minute, that the two ends disagree
|
||||||
|
// about the mast.
|
||||||
|
var rotgeniusWarnedAt sync.Map // host:port → time.Time
|
||||||
|
|
||||||
|
func rotgeniusRangeWarn(l rotorLink, st rotgenius.Status) {
|
||||||
|
key := fmt.Sprintf("%s:%d/%d", l.Host, l.Port, l.Num)
|
||||||
|
if v, ok := rotgeniusWarnedAt.Load(key); ok {
|
||||||
|
if t, _ := v.(time.Time); time.Since(t) < time.Minute {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rotgeniusWarnedAt.Store(key, time.Now())
|
||||||
|
applog.Printf("rotator: OpsLog is set to a 450° mast but the Rotator Genius is configured %d° to %d° — "+
|
||||||
|
"a range it will not go past, whatever is asked. Change the limits in the Genius's own Rotator "+
|
||||||
|
"Configuration; until then the antenna takes the long way round through north.",
|
||||||
|
st.LimitCCW, st.LimitCW)
|
||||||
|
}
|
||||||
|
|
||||||
|
func absInt(v int) int {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
+152
-12
@@ -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:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,22 +338,28 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
down, up := sh.DownHz, sh.UpHz
|
down, up := sh.DownHz, sh.UpHz
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.status = SatTrackStatus{
|
// Rebuilt from the old one, not from nothing.
|
||||||
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
//
|
||||||
NominalDown: nominal, NominalUp: nomUp,
|
// The rotator fields are written by readRotator, which runs at most every
|
||||||
DownHz: down, UpHz: up,
|
// three seconds — a controller query binds a socket and waits. Building a
|
||||||
Az: pos.Az, El: pos.El, Visible: visible,
|
// fresh status here dropped them on every OTHER tick, so the antenna
|
||||||
Radio: t.status.Radio, Error: t.status.Error,
|
// 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.mu.Unlock()
|
||||||
|
|
||||||
t.pointRotator(pos, b.Geostationary)
|
t.pointRotator(pos, b.Geostationary)
|
||||||
t.readRotator()
|
t.readRotator()
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
st := t.status
|
out := t.status
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
a.emitSatTrack(st)
|
a.emitSatTrack(out)
|
||||||
|
|
||||||
// Only send what has actually moved. The step is the smallest change worth a
|
// 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
|
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||||
@@ -359,11 +372,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 +648,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,40 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.22",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"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: a 450° mast can now be pointed through the overlap instead of the long way round. OpsLog clamped every target to 360°, so a bearing just past north meant the rotator stopped at 359 and the operator pressed \"clockwise\" by hand to get through it. A rotator range now appears for the Rotator Genius like the other backends it applies to, and the nearer of the two ways to a bearing is taken — 010° reached as 370° when the antenna is already at 350°. The Genius's own limits are read and they win: a box configured for 360° refuses a target beyond it, so OpsLog does not ask, and says in the log that the range has to be changed in the Genius's own Rotator Configuration."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"L’affichage de l’antenne 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 — l’antenne 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 l’antenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et l’antenne, pas une colonne à l’autre bout de la fenêtre — et c’est la première chose qu’on masque pour avoir la carte en pleine largeur. La boussole tourne tant que l’antenne 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 d’environ soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et l’affichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne s’agit 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 : un pylône 450° peut désormais être pointé à travers le recouvrement au lieu de faire le tour. OpsLog écrêtait toute consigne à 360°, donc un cap juste après le nord arrêtait le rotor à 359 et il fallait cliquer « clockwise » à la main pour le franchir. L’amplitude du rotor apparaît maintenant pour le Rotator Genius comme pour les autres pilotes concernés, et le plus court des deux chemins est pris — 010° atteint comme 370° quand l’antenne est déjà à 350°. Les limites propres au Genius sont lues et priment : un boîtier configuré en 360° refuse une consigne au-delà, donc OpsLog ne la lui envoie pas et écrit dans le journal que l’amplitude est à changer dans la configuration du Genius lui-même."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 — d’un facteur d’environ 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui n’en est pas une : l’ISS 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 d’environ ±3,5 kHz sur un passage et une 70 cm d’environ ±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 l’antenne de réception de sa bande, la montée l’antenne 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 n’entendait rien.",
|
||||||
|
"FlexRadio, satellite : sur un transpondeur inverseur, la montée est mise en LSB et la descente en USB, au lieu d’USB des deux côtés. La bande passante est retournée : une audio émise sur la mauvaise bande latérale revient à l’envers — 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 c’est le seul réglage qu’un 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 l’antenne avec. La sélection de la page était celle de l’affichage ; 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 qu’OpsLog 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 d’une 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 s’ouvrir est également refermé puis redemandé — le même cas de session résiduelle, vu de l’autre côté. Un port connu pour en avoir besoin y a droit d’emblée la fois suivante."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.20",
|
"version": "0.27.20",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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 {
|
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';
|
||||||
@@ -74,11 +74,28 @@ const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
|||||||
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||||
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
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) => {
|
const fmtHz = (hz: number) => {
|
||||||
if (!hz) return '—';
|
if (!hz) return '—';
|
||||||
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
|
return (hz / 1e6).toFixed(4).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
||||||
// Doppler correction moves the last three digits every second.
|
};
|
||||||
return (hz / 1e6).toFixed(6).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 fmtDeg = (d: number) => `${d.toFixed(1)}°`;
|
||||||
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
|
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)); }
|
} 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);
|
||||||
@@ -601,6 +636,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
|
|
||||||
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
||||||
// so they move every second without asking Go anything.
|
// 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 aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
|
||||||
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
||||||
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
|
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' && (
|
{tracking?.on && tracking.radio === 'downlink-only' && (
|
||||||
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
<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" />
|
<div className="flex-1" />
|
||||||
{/* Elements are maintenance, so only their AGE is here — and only when
|
{/* Elements are maintenance, so only their AGE is here — and only when
|
||||||
it has become a reason the panel might be wrong. */}
|
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="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
||||||
{!!shift && (
|
{!!shift && (
|
||||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
|
||||||
{shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5135,7 +5135,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// controller. PstRotator knows which machine is on the other end and
|
// controller. PstRotator knows which machine is on the other end and
|
||||||
// does its own overlap; two programs each deciding to go the long
|
// does its own overlap; two programs each deciding to go the long
|
||||||
// way round is how an antenna unwinds mid-pass.
|
// way round is how an antenna unwinds mid-pass.
|
||||||
const ownsOverlap = isERC || isEasycomm;
|
//
|
||||||
|
// A Rotator Genius is in between: it has its OWN limits, and they
|
||||||
|
// win — this setting only tells OpsLog it may ask for the far side
|
||||||
|
// of the overlap at all. If the Genius is configured 360°, it says
|
||||||
|
// so in the log rather than sending commands the box refuses.
|
||||||
|
const ownsOverlap = isERC || isEasycomm || isRG;
|
||||||
return (
|
return (
|
||||||
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// 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.21';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,10 +33,16 @@ const (
|
|||||||
|
|
||||||
// Status is one rotator's live state parsed from a |h reply.
|
// Status is one rotator's live state parsed from a |h reply.
|
||||||
type Status struct {
|
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)
|
Connected bool // false when the sensor reports 999 (not connected)
|
||||||
Moving int // 0 not moving, 1 CW, 2 CCW
|
Moving int // 0 not moving, 1 CW, 2 CCW
|
||||||
Target int // target azimuth when moving (else -1)
|
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,
|
// 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]))
|
cur := atoiField(string(p[base : base+3]))
|
||||||
moving := atoiField(string(p[base+10 : base+11]))
|
moving := atoiField(string(p[base+10 : base+11]))
|
||||||
target := atoiField(string(p[base+15 : base+18]))
|
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 {
|
if target != 999 {
|
||||||
st.Target = target
|
st.Target = target
|
||||||
}
|
}
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
// GoTo moves the rotator to az. The reply's status byte is 'K' on accept, 'F'
|
||||||
// accept, 'F' on reject.
|
// 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 {
|
func (c *Client) GoTo(rotator, az int) error {
|
||||||
if rotator != 1 && rotator != 2 {
|
if rotator != 1 && rotator != 2 {
|
||||||
rotator = 1
|
rotator = 1
|
||||||
@@ -144,8 +165,8 @@ func (c *Client) GoTo(rotator, az int) error {
|
|||||||
if az < 0 {
|
if az < 0 {
|
||||||
az = 0
|
az = 0
|
||||||
}
|
}
|
||||||
if az > 360 {
|
if az > 450 {
|
||||||
az = 360
|
az = 450
|
||||||
}
|
}
|
||||||
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+37
-4
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -131,11 +140,59 @@ 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 {
|
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
|
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.
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.27.20"
|
appVersion = "0.27.21"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user