feat(sat): follow the azimuth only

A satellite tracker that insists on an elevation motor is a tracker switched off
for nearly everybody. A pass at the edge of the footprint — which is most of
them — never climbs above ten or fifteen degrees for its whole length, and a
yagi's beamwidth swallows that: the bearing alone is enough, and it is how most
stations that work satellites are actually built. The same switch rescues an
az/el station whose elevation motor has failed.

So it is an option, not a silent fallback, because it does cost something: a
bird straight overhead is a moving azimuth and a bearing that means nothing, and
whether to accept that is the operator's call. With it on, any rotor in the list
can be chosen — the PstRotator, the Rotator Genius, the ARCO, the tower already
turned for HF.

That works because the per-backend command dispatch moved out of the three
RotatorGoTo/Stop/Heading methods into linkGoTo/linkStop/linkHeading, so the
satellite tracker drives any of the seven backends through the same code the
compass uses instead of a second implementation of each. GetRotatorHeading loses
sixty lines of near-duplicate switch in the process, and a rotor with no
elevation axis now says so (HasElevation) rather than reporting a zero that
looks like a real bearing.

One trap, with a test on it: the step check compared both axes, so with the
elevation never commanded its difference stayed above the step for the whole
pass and every tick ordered the antenna to the bearing it was already on. A mast
has a finite number of turns in it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 11:35:23 +02:00
co-authored by Claude Opus 5
parent 8f17416eca
commit b15055ba4a
10 changed files with 337 additions and 160 deletions
+131 -131
View File
@@ -17412,6 +17412,126 @@ func (a *App) activeRotor() (lr logicalRotor, rotors []logicalRotor, idx int, ok
return rotors[idx], rotors, idx, true
}
// linkHeading asks one rotor where it is.
//
// The per-backend switch lives here, once, so the compass and the satellite
// tracker read a rotor the same way. hasEl says whether the elevation returned
// means anything: an azimuth controller answers the azimuth question perfectly
// well and has nothing to say about the other axis, and reporting a zero there
// would draw an antenna lying on the horizon.
//
// raw is the controller's own reply, kept for the log — it is what tells a
// baffled operator whether the port is silent or answering something we did not
// expect.
func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error) {
switch l.Type {
case "rotgenius":
st, r, herr := rotgenius.New(l.Host, l.Port).Heading(l.Num)
if herr != nil {
return 0, 0, false, "", herr
}
if !st.Connected {
return 0, 0, false, "sensor not connected (999)", fmt.Errorf("sensor not connected")
}
return float64(st.Azimuth), 0, false, r, nil
case "arco":
v, r, herr := arcoClient(l).Heading()
return float64(v), 0, false, r, herr
case "erc":
aa, ee, r, herr := ercClient(l).Position()
return float64(aa), float64(ee), herr == nil, r, herr
case "easycomm":
aa, ee, live, herr := easycommClient(l).Heading()
if herr != nil {
return 0, 0, false, "", herr
}
r := fmt.Sprintf("AZ %.0f° EL %.0f°", aa, ee)
if !live {
// The controller answered nothing and this is the last COMMANDED
// position. Say so: a stuck rotator must not be able to hide behind
// an order it never carried out.
r += " (commanded)"
}
return aa, ee, true, r, nil
case "spid":
aa, ee, herr := spidClient(l).Heading()
if herr != nil {
return 0, 0, false, "", herr
}
if l.HasElevation {
return float64(aa), float64(ee), true, fmt.Sprintf("AZ %d° EL %d°", aa, ee), nil
}
return float64(aa), 0, false, fmt.Sprintf("%d°", aa), nil
case "dcu1":
v, r, herr := dcu1Client(l).Heading()
return float64(v), 0, false, r, herr
default:
v, r, herr := pst.New(l.Host, l.Port).Heading()
if herr != nil {
// PstRotator's own text is more useful than the transport error.
return 0, 0, false, r, herr
}
if l.HasElevation {
if e, _, eerr := pst.New(l.Host, l.Port).Elevation(); eerr == nil {
return float64(v), float64(e), true, r, nil
}
}
return float64(v), 0, false, r, nil
}
}
// linkGoTo points one rotor. An elevation below zero is the callers' "no
// opinion" — a spot click, a compass drag — and leaves the elevation axis where
// it is rather than swinging a dish to the horizon.
func linkGoTo(l rotorLink, az, el int) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
case "arco":
return arcoClient(l).GoTo(az)
case "erc":
if el < 0 {
return ercClient(l).GoTo(az)
}
return ercClient(l).GoToAzEl(az, el)
case "easycomm":
if el < 0 {
if _, cur, _, err := easycommClient(l).Heading(); err == nil {
el = int(math.Round(cur))
} else {
el = 0
}
}
return easycommClient(l).Point(float64(az), float64(el))
case "spid":
return spidClient(l).GoTo(az, el)
case "dcu1":
return dcu1Client(l).GoTo(az)
default:
return pst.New(l.Host, l.Port).GoTo(az, l.HasElevation, el)
}
}
// linkStop interrupts one rotor.
func linkStop(l rotorLink) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).Stop()
case "arco":
return arcoClient(l).Stop()
case "erc":
return ercClient(l).Stop()
case "easycomm":
return easycommClient(l).Stop()
case "spid":
return spidClient(l).Stop()
case "dcu1":
return dcu1Client(l).Stop()
default:
return pst.New(l.Host, l.Port).Stop()
}
}
// GetRotatorHeading queries the active rotor for its azimuth. Returns
// Enabled=false when no rotator is configured. Polled by the status bar.
func (a *App) GetRotatorHeading() RotatorHeading {
@@ -17424,94 +17544,19 @@ func (a *App) GetRotatorHeading() RotatorHeading {
names[i] = r.Name
}
base := RotatorHeading{Enabled: true, Rotors: names, Active: idx, Motorized: lr.Motorized}
link := lr.Link
switch link.Type {
case "rotgenius":
st, raw, herr := rotgenius.New(link.Host, link.Port).Heading(link.Num)
if herr != nil {
base.Raw = herr.Error()
return base
}
if !st.Connected {
base.Raw = "sensor not connected (999)"
return base
}
base.OK = true
base.Azimuth = st.Azimuth
az, el, hasEl, raw, err := linkHeading(lr.Link)
if err != nil {
base.Raw = raw
return base
case "arco":
az, raw, herr := arcoClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
if base.Raw == "" {
base.Raw = err.Error()
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
case "erc":
az, el, raw, herr := ercClient(link).Position()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth, base.Elevation, base.HasElevation = az, el, true
base.Raw = raw
return base
case "easycomm":
az, el, live, herr := easycommClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth, base.Elevation, base.HasElevation = int(math.Round(az)), int(math.Round(el)), true
if live {
base.Raw = fmt.Sprintf("AZ %.0f° EL %.0f°", az, el)
} else {
// The controller answered nothing and this is the last COMMANDED
// position. Say so: a stuck rotator must not be able to hide behind
// an order it never carried out.
base.Raw = fmt.Sprintf("AZ %.0f° EL %.0f° (commanded)", az, el)
}
return base
case "spid":
az, el, herr := spidClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth = az
base.Raw = fmt.Sprintf("%d°", az)
if link.HasElevation {
base.Elevation, base.HasElevation = el, true
base.Raw = fmt.Sprintf("AZ %d° EL %d°", az, el)
}
return base
case "dcu1":
az, raw, herr := dcu1Client(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
default:
az, raw, herr := pst.New(link.Host, link.Port).Heading()
if herr != nil {
base.Raw = raw
return base
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
}
base.OK = true
base.Azimuth = int(math.Round(az))
base.Elevation, base.HasElevation = int(math.Round(el)), hasEl
base.Raw = raw
return base
}
// RotatorGoTo points the active rotor at the given azimuth (and optional
@@ -17535,36 +17580,7 @@ func (a *App) RotatorGoToPath(az int, el int, path string) error {
if !ok {
return fmt.Errorf("no rotator configured")
}
link := lr.Link
switch link.Type {
case "rotgenius":
return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
case "arco":
return arcoClient(link).GoTo(az)
case "erc":
// An elevation of -1 is the callers' "no opinion" (a spot click, a
// compass drag). Leaving the elevation where it is beats swinging the
// dish to the horizon because somebody clicked a DX spot.
if el < 0 {
return ercClient(link).GoTo(az)
}
return ercClient(link).GoToAzEl(az, el)
case "easycomm":
if el < 0 {
if _, cur, _, err := easycommClient(link).Heading(); err == nil {
el = int(math.Round(cur))
} else {
el = 0
}
}
return easycommClient(link).Point(float64(az), float64(el))
case "spid":
return spidClient(link).GoTo(az, el)
case "dcu1":
return dcu1Client(link).GoTo(az)
default:
return pst.New(link.Host, link.Port).GoTo(az, link.HasElevation, el)
}
return linkGoTo(lr.Link, az, el)
}
// RotatorStop interrupts any in-progress rotation of the active rotor.
@@ -17573,23 +17589,7 @@ func (a *App) RotatorStop() error {
if !ok {
return fmt.Errorf("no rotator configured")
}
link := lr.Link
switch link.Type {
case "rotgenius":
return rotgenius.New(link.Host, link.Port).Stop()
case "arco":
return arcoClient(link).Stop()
case "erc":
return ercClient(link).Stop()
case "easycomm":
return easycommClient(link).Stop()
case "spid":
return spidClient(link).Stop()
case "dcu1":
return dcu1Client(link).Stop()
default:
return pst.New(link.Host, link.Port).Stop()
}
return linkStop(lr.Link)
}
// RotorPreset is one quick-turn button on the rotor widget: a short label and
+26 -10
View File
@@ -40,10 +40,12 @@ const (
// flattenRotors gives it. How to reach it is that list's business, not
// this page's: describing one mast in two places is how a station ends up
// working on HF and not on a pass.
keySatRotID = "sat.rot_id"
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
keySatRotStep = "sat.rot_step" // degrees of change worth a command
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
keySatRotID = "sat.rot_id"
// Follow the azimuth and leave the elevation alone. See SatSettings.RotAzOnly.
keySatRotAzOnly = "sat.rot_az_only"
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
keySatRotStep = "sat.rot_step" // degrees of change worth a command
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
// The satellite page used to configure its own EasyComm or PstRotator link.
// These keys are read once by migrateSatRotator, which turns what they hold
@@ -86,11 +88,23 @@ type SatSettings struct {
// business and means nothing to a rotor turned by hand: below which
// elevation not to bother, how far the antenna must be off before a command
// is worth sending, and whether to park at the end.
RotOn bool `json:"rot_on"`
RotID string `json:"rot_id"`
RotMinEl int `json:"rot_min_el"`
RotStep int `json:"rot_step"`
RotPark bool `json:"rot_park"`
RotOn bool `json:"rot_on"`
RotID string `json:"rot_id"`
// RotAzOnly follows the satellite in azimuth and leaves the elevation
// alone — which is how most stations that work satellites actually do it.
//
// A pass at the far edge of the footprint never climbs above ten or fifteen
// degrees, and a beam on a plain azimuth rotator points straight through it:
// the beamwidth covers the whole thing. Refusing to track for want of an
// elevation motor turned the feature off for every operator who has a tower
// and no az/el mast, which is nearly all of them.
//
// It also rescues an az/el station whose elevation motor has failed, and it
// is why the rotor list stops being filtered when this is set.
RotAzOnly bool `json:"rot_az_only"`
RotMinEl int `json:"rot_min_el"`
RotStep int `json:"rot_step"`
RotPark bool `json:"rot_park"`
}
// SatTransponder is one path through a satellite, as the UI needs it.
@@ -260,12 +274,13 @@ func (a *App) satSettings() SatSettings {
}
m, err := a.settings.GetMany(a.ctx,
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
keySatRotOn, keySatRotID, keySatRotMinEl, keySatRotStep, keySatRotPark)
keySatRotOn, keySatRotID, keySatRotAzOnly, keySatRotMinEl, keySatRotStep, keySatRotPark)
if err != nil {
return out
}
out.RotOn = m[keySatRotOn] == "1"
out.RotID = strings.TrimSpace(m[keySatRotID])
out.RotAzOnly = m[keySatRotAzOnly] == "1"
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
out.RotMinEl = v
}
@@ -335,6 +350,7 @@ func (a *App) SaveSatSettings(s SatSettings) error {
keySatAltM: strconv.Itoa(s.AltM),
keySatRotOn: boolStr(s.RotOn),
keySatRotID: strings.TrimSpace(s.RotID),
keySatRotAzOnly: boolStr(s.RotAzOnly),
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
keySatRotStep: strconv.Itoa(s.RotStep),
keySatRotPark: boolStr(s.RotPark),
+56 -5
View File
@@ -55,12 +55,18 @@ func (a *App) newSatRotator(s SatSettings) (satRotator, error) {
// rather than failing to connect to an address nobody can see any more.
return nil, fmt.Errorf("the rotator chosen for satellite tracking no longer exists in Settings ▸ Rotator")
}
// Azimuth only: any rotor will do, including the tower the operator already
// turns for HF. See SatSettings.RotAzOnly for why this is the common case
// rather than a fallback.
if s.RotAzOnly {
return &azOnlySatRotator{link: lr.Link}, nil
}
if !lr.HasEl {
name := strings.TrimSpace(lr.Name)
if name == "" {
name = "this rotator"
}
return nil, fmt.Errorf("%s has no elevation axis — a satellite pass needs one", name)
return nil, fmt.Errorf("%s has no elevation axis — tick \"follow the azimuth only\" in Settings ▸ Satellite, or pick an az/el rotator", name)
}
l := lr.Link
switch l.Type {
@@ -90,10 +96,11 @@ type SatelliteRotorChoice struct {
// ListSatelliteRotors returns every configured rotor, elevation-capable or not.
//
// Not filtered to the az/el ones, deliberately. An operator who owns exactly one
// rotator and does not see it in this list concludes OpsLog cannot find it; shown
// with "azimuth only" beside it, they learn the actual thing — that the tracker
// needs an elevation axis and this mast has none.
// Never filtered. Which of them can be USED depends on the azimuth-only switch,
// and that is a question for the panel: with it off an azimuth rotor is shown
// greyed and says why, with it on every rotor is fair game. Hiding them
// outright would only teach an operator with one mast that OpsLog cannot find
// it.
func (a *App) ListSatelliteRotors() ([]SatelliteRotorChoice, error) {
devs, err := a.GetRotators()
if err != nil {
@@ -260,3 +267,47 @@ func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
// Close: nothing to release. Every PstRotator command is one datagram, and the
// socket lives for the length of a single write.
func (p *pstSatRotator) Close() {}
// azOnlySatRotator follows the satellite in azimuth and never touches the
// elevation axis, whatever the rotor happens to have.
//
// It works because of the geometry, not in spite of it: a pass at the far edge
// of the footprint stays between the horizon and about fifteen degrees for its
// whole length, and a yagi's beamwidth swallows that. What it costs is the high
// passes — a bird straight overhead is a moving azimuth and a useless bearing —
// and that is the operator's trade to make, which is why it is a switch and not
// a silent fallback.
//
// It drives whichever rotor was chosen through the same per-backend dispatch the
// compass uses, so a PstRotator, a Rotator Genius, an ARCO, a DCU-1, a SPID and
// the az/el ones all work here without a second implementation of each.
type azOnlySatRotator struct{ link rotorLink }
// Point sends the azimuth alone. The elevation is passed as -1, the callers'
// "no opinion", so a rotor that HAS an elevation axis is left where it is rather
// than being driven to the horizon.
func (r *azOnlySatRotator) Point(az, _ float64) error {
a := math.Mod(az, 360)
if a < 0 {
a += 360
}
return linkGoTo(r.link, int(math.Round(a)), -1)
}
// Heading reports the azimuth. The elevation comes back as whatever the
// controller said, which for an azimuth rotor is zero — the panel is told
// separately not to draw it (SatTrackStatus.RotAzOnly), because zero is a real
// bearing and not the absence of one.
//
// live stays true when the AZIMUTH was genuinely read: it means "this is a
// reading and not the last command", and that answer is honest whatever the
// other axis does or does not do.
func (r *azOnlySatRotator) Heading() (float64, float64, bool, error) {
az, el, _, _, err := linkHeading(r.link)
if err != nil {
return 0, 0, false, err
}
return az, el, true, nil
}
func (r *azOnlySatRotator) Close() {}
+15 -1
View File
@@ -69,6 +69,7 @@ type satTracker struct {
rotStep float64
rotMinE float64
rotPark bool
rotAzOnly bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
@@ -101,6 +102,10 @@ type SatTrackStatus struct {
RotAz float64 `json:"rot_az"`
RotEl float64 `json:"rot_el"`
RotLive bool `json:"rot_live"`
// RotAzOnly: the elevation is not being driven and RotEl means nothing.
// Sent so the panel can leave it out rather than draw an antenna lying on
// the horizon, which is what an undriven zero looks like.
RotAzOnly bool `json:"rot_az_only"`
}
// StartSatelliteTracking arms the radio and starts following the satellite.
@@ -138,6 +143,8 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
} else {
t.rot = r
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
t.rotAzOnly = set.RotAzOnly
t.status.RotAzOnly = set.RotAzOnly
}
}
@@ -415,7 +422,14 @@ func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
return
}
}
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
// In azimuth-only mode the elevation is never commanded, so comparing it
// would find a difference on every tick and send a command for nothing —
// the antenna ordered to the same bearing once a second for the whole pass.
moved := math.Abs(az-t.rotAz) >= t.rotStep
if !t.rotAzOnly {
moved = moved || math.Abs(el-t.rotEl) >= t.rotStep
}
if t.rotSent && !moved {
return
}
if err := t.rot.Point(az, el); err != nil {
+61
View File
@@ -45,3 +45,64 @@ func TestSatModeLetters(t *testing.T) {
}
}
}
// Azimuth-only tracking must not command the rotor once a second.
//
// The step check used to compare BOTH axes, so with the elevation never
// commanded its difference stayed above the step for the whole pass and every
// tick sent the antenna to the bearing it was already on. A rotator is a
// mechanical thing with a finite number of turns in it.
func TestPointRotatorAzOnlyIgnoresElevation(t *testing.T) {
rec := &countingRotator{}
tr := &satTracker{rot: rec, rotStep: 5, rotAzOnly: true}
// The satellite climbs while the bearing barely moves — a pass going
// overhead from the side, which is the shape that provoked this.
for _, p := range []sat.Position{
{Az: 100, El: 5},
{Az: 101, El: 20},
{Az: 102, El: 45},
{Az: 103, El: 70},
} {
tr.pointRotator(p, false)
}
if rec.n != 1 {
t.Errorf("azimuth-only sent %d commands for 3° of bearing, want 1", rec.n)
}
if rec.lastAz != 100 {
t.Errorf("commanded azimuth %v, want the first one", rec.lastAz)
}
// And it still follows the azimuth when the azimuth actually moves.
tr.pointRotator(sat.Position{Az: 130, El: 70}, false)
if rec.n != 2 {
t.Errorf("a 30° swing was not followed: %d commands", rec.n)
}
}
// With an elevation axis, a climb is still followed.
func TestPointRotatorFollowsElevationWhenItCan(t *testing.T) {
rec := &countingRotator{}
tr := &satTracker{rot: rec, rotStep: 5}
tr.pointRotator(sat.Position{Az: 100, El: 5}, false)
tr.pointRotator(sat.Position{Az: 101, El: 40}, false)
if rec.n != 2 {
t.Errorf("a 35° climb was not followed: %d commands", rec.n)
}
if rec.lastEl != 40 {
t.Errorf("commanded elevation %v, want 40", rec.lastEl)
}
}
type countingRotator struct {
n int
lastAz, lastEl float64
}
func (c *countingRotator) Point(az, el float64) error {
c.n++
c.lastAz, c.lastEl = az, el
return nil
}
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
func (c *countingRotator) Close() {}
+4 -2
View File
@@ -12,7 +12,8 @@
"The satellite footprint is drawn for the selected bird only. A footprint is thousands of kilometres across, and a dozen of them overlapped into a wash of circles that hid the coastline, the ground track and the satellites themselves.",
"The frequency plan goes from 25 satellites to 44, cut from Celestrak, PE0SAT and the SatNOGS transponder database instead of typed by hand — the nine Tevel-2 satellites, the Chinese space station, AO-27, AO-123, RS-44 and twenty more. Twelve that had re-entered are gone, first-generation Tevel among them. Your own file is merged rather than replaced: satellites you have never seen are added, and any frequency you corrected stands.",
"A satellite is now found by its catalog number rather than by its name. \"RADFXSAT (FOX-1B)\" and \"AO-91\" are the same bird, and so are \"TIANYAN 01\" and \"TO-108\" — the second pair never met before, so TO-108 tracked nothing.",
"On the satellite tab, the mode is a coloured badge instead of a grey footnote, and an FM bird shows its CTCSS tone with the same weight as a frequency — a repeater called without its tone does not answer, and the operator hears an empty channel and concludes the satellite is not up. When there is no tone it says so, rather than leaving a blank that could mean either. The mode also appears in the transponder list and in the header, so it survives hiding the readout column."
"On the satellite tab, the mode is a coloured badge instead of a grey footnote, and an FM bird shows its CTCSS tone with the same weight as a frequency — a repeater called without its tone does not answer, and the operator hears an empty channel and concludes the satellite is not up. When there is no tone it says so, rather than leaving a blank that could mean either. The mode also appears in the transponder list and in the header, so it survives hiding the readout column.",
"New option: follow the azimuth only. A station with an ordinary rotator and no elevation motor can now track a satellite — a pass at the edge of the footprint stays between the horizon and about 15° for its whole length, and a beam covers that with its beamwidth. With it on, any rotator in the list can be chosen. What you give up is the high passes, where a satellite overhead has a bearing that means nothing, which is why it is a switch and not something OpsLog decides for you."
],
"fr": [
"Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait quen choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.",
@@ -24,7 +25,8 @@
"Lempreinte au sol nest tracée que pour le satellite sélectionné. Une empreinte fait des milliers de kilomètres, et une douzaine se superposaient en un lavis de cercles qui masquait le trait de côte, la trace au sol et les satellites eux-mêmes.",
"Le plan de fréquences passe de 25 à 44 satellites, généré depuis Celestrak, PE0SAT et la base de transpondeurs SatNOGS au lieu d’être saisi à la main — les neuf Tevel-2, la station spatiale chinoise, AO-27, AO-123, RS-44 et vingt autres. Douze rentrés dans latmosphère ont été retirés, dont les Tevel de première génération. Votre fichier est fusionné et non remplacé : les satellites inconnus sont ajoutés, et vos corrections de fréquence restent.",
"Un satellite est désormais trouvé par son numéro de catalogue plutôt que par son nom. « RADFXSAT (FOX-1B) » et « AO-91 » sont le même oiseau, tout comme « TIANYAN 01 » et « TO-108 » — ces deux-là ne se rencontraient jamais, donc TO-108 ne suivait rien.",
"Sur longlet satellite, le mode est une pastille colorée au lieu dune note grise, et un satellite FM affiche sa tonalité CTCSS avec le même poids quune fréquence — un relais appelé sans sa tonalité ne répond pas, et lOM entend un canal vide et en conclut que le satellite nest pas passé. Quand il ny a pas de tonalité, cest écrit, plutôt quun blanc qui pourrait vouloir dire lun ou lautre. Le mode apparaît aussi dans la liste des transpondeurs et dans len-tête, donc il survit au masquage de la colonne de droite."
"Sur longlet satellite, le mode est une pastille colorée au lieu dune note grise, et un satellite FM affiche sa tonalité CTCSS avec le même poids quune fréquence — un relais appelé sans sa tonalité ne répond pas, et lOM entend un canal vide et en conclut que le satellite nest pas passé. Quand il ny a pas de tonalité, cest écrit, plutôt quun blanc qui pourrait vouloir dire lun ou lautre. Le mode apparaît aussi dans la liste des transpondeurs et dans len-tête, donc il survit au masquage de la colonne de droite.",
"Nouvelle option : suivre lazimut seulement. Une station avec un rotor ordinaire et sans moteur d’élévation peut désormais suivre un satellite — un passage en bord dempreinte reste entre lhorizon et 15° environ sur toute sa durée, et une beam couvre ça avec son ouverture. Avec loption activée, nimporte quel rotor de la liste peut être choisi. Ce quon perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire — doù un réglage plutôt quun choix fait à votre place."
]
},
{
+10 -2
View File
@@ -60,7 +60,7 @@ type Track = {
az: number; el: number; visible: boolean;
radio: string; // "sat" | "downlink-only" | ""
error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
};
const MAP_VIEW_SAT = 'opslog.satMapView';
@@ -811,7 +811,15 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
+22 -7
View File
@@ -1931,7 +1931,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// Satellites: the observer and the az/el rotator. The rest of the satellite
// settings (favourites, the pass window) are set in the tab itself, where
// they are used.
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_id: '', rot_min_el: 0, rot_step: 5, rot_park: false });
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_id: '', rot_az_only: false, rot_min_el: 0, rot_step: 5, rot_park: false });
const [satTest, setSatTest] = useState('');
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
@@ -4701,13 +4701,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('satset.rotPickNone')} /></SelectTrigger>
<SelectContent>
{satRotors.length === 0 && <SelectItem value="_" disabled>{t('satset.rotNoneConfigured')}</SelectItem>}
{/* The azimuth-only rotors are LISTED, and disabled. An
{/* The azimuth-only rotors are always LISTED. Without
the switch below they are greyed and say why an
operator who owns one rotator and does not see it
concludes OpsLog cannot find it; shown greyed with
"azimuth only" beside it, they learn the real thing. */}
concludes OpsLog cannot find it, where "azimuth
only" beside it teaches the real thing. With the
switch on, every rotor is fair game. */}
{satRotors.map((r: any) => (
<SelectItem key={r.key} value={r.key} disabled={!r.has_el}>
{(r.name || r.type) + (r.has_el ? '' : `${t('satset.rotAzOnly')}`)}
<SelectItem key={r.key} value={r.key} disabled={!r.has_el && !satCfg.rot_az_only}>
{(r.name || r.type) + (r.has_el ? '' : `${t('satset.rotAzOnlyTag')}`)}
</SelectItem>
))}
</SelectContent>
@@ -4718,11 +4720,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('satset.rotPickHint')}</p>
{satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
{!satCfg.rot_az_only && satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
<p className="text-xs text-[var(--warning)]">{t('satset.rotNoElAtAll')}</p>
)}
</div>
{/* Azimuth only. Not a fallback it is how most stations that
work satellites are actually built, and refusing to track
without an elevation motor turned the feature off for every
operator with a tower and no az/el mast. */}
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!satCfg.rot_az_only}
onCheckedChange={(c) => set('rot_az_only', !!c)} />
<span>
{t('satset.rotAzOnly')}
<span className="block text-xs text-muted-foreground">{t('satset.rotAzOnlyHint')}</span>
</span>
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.rotMinEl')}</Label>
+8 -2
View File
@@ -605,6 +605,7 @@ const en: Dict = {
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
'sat.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed',
'sat.rotAzOnly': 'azimuth only',
'sat.tipAos': 'Rises', 'sat.tipLos': 'Sets', 'sat.tipMaxEl': 'Peak',
'sat.tipBelow': 'below the horizon', 'sat.tipNoPass': 'no pass in the prediction window',
'sat.approaching': 'approaching', 'sat.receding': 'receding', 'sat.below': 'below the horizon',
@@ -628,7 +629,9 @@ const en: Dict = {
'satset.rotor': 'Azimuth / elevation rotator',
'satset.rotEnable': 'Point a rotator at the satellite while tracking',
'satset.rotHint': 'Point the antenna at the satellite through one of the rotators you have already configured. It is usually a separate machine from your HF rotator, and having both is normal — an az/el mast follows the pass while the beam stays where it was.',
'satset.rotPick': 'Rotator', 'satset.rotPickNone': '— choose a rotator —', 'satset.rotAzOnly': 'azimuth only',
'satset.rotPick': 'Rotator', 'satset.rotPickNone': '— choose a rotator —',
'satset.rotAzOnly': 'Follow the azimuth only', 'satset.rotAzOnlyTag': 'azimuth only',
'satset.rotAzOnlyHint': 'For a station with an ordinary azimuth rotator and no elevation motor. A pass at the edge of the footprint stays between the horizon and about 15° for its whole length, and a beams beamwidth covers that — so the bearing alone works. What you give up is the high passes, where a satellite overhead has a bearing that means nothing. With this on, any rotator can be chosen above.',
'satset.rotNoneConfigured': 'No rotator configured yet',
'satset.rotPickHint': 'One of the rotators from Settings ▸ Rotator. Add or change an interface there — a mast is described once, and this page only says which one follows the satellite.',
'satset.rotNoElAtAll': 'None of your rotators has an elevation axis. Add an az/el interface in Settings ▸ Rotator — ERC-M, EasyComm, SPID Rot2Prog, or PstRotator with elevation ticked.',
@@ -1220,6 +1223,7 @@ const fr: Dict = {
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
'sat.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise',
'sat.rotAzOnly': 'azimut seul',
'sat.tipAos': 'Lever', 'sat.tipLos': 'Coucher', 'sat.tipMaxEl': 'Culmination',
'sat.tipBelow': 'sous lhorizon', 'sat.tipNoPass': 'aucun passage dans la fenêtre de prévision',
'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous lhorizon',
@@ -1243,7 +1247,9 @@ const fr: Dict = {
'satset.rotor': 'Rotor azimut / élévation',
'satset.rotEnable': 'Pointer un rotor vers le satellite pendant le suivi',
'satset.rotHint': 'Pointe lantenne vers le satellite avec lun des rotors déjà configurés. Cest en général une machine distincte du rotor HF, et avoir les deux est normal — le pylône azimut/élévation suit le passage pendant que la beam reste où elle était.',
'satset.rotPick': 'Rotor', 'satset.rotPickNone': '— choisir un rotor —', 'satset.rotAzOnly': 'azimut seul',
'satset.rotPick': 'Rotor', 'satset.rotPickNone': '— choisir un rotor —',
'satset.rotAzOnly': 'Suivre lazimut seulement', 'satset.rotAzOnlyTag': 'azimut seul',
'satset.rotAzOnlyHint': 'Pour une station avec un rotor dazimut ordinaire et sans moteur d’élévation. Un passage en bord dempreinte reste entre lhorizon et 15° environ sur toute sa durée, et louverture dune beam couvre ça — le cap seul suffit donc. Ce quon perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire. Avec cette option, nimporte quel rotor peut être choisi ci-dessus.',
'satset.rotNoneConfigured': 'Aucun rotor configuré pour le moment',
'satset.rotPickHint': 'Un des rotors de Réglages ▸ Rotator. Ajoutez ou modifiez une interface là-bas — un pylône se décrit une seule fois, et cette page dit seulement lequel suit le satellite.',
'satset.rotNoElAtAll': 'Aucun de vos rotors na daxe d’élévation. Ajoutez une interface azimut/élévation dans Réglages ▸ Rotator — ERC-M, EasyComm, SPID Rot2Prog, ou PstRotator avec l’élévation cochée.',
+4
View File
@@ -4171,6 +4171,7 @@ export namespace main {
alt_m: number;
rot_on: boolean;
rot_id: string;
rot_az_only: boolean;
rot_min_el: number;
rot_step: number;
rot_park: boolean;
@@ -4189,6 +4190,7 @@ export namespace main {
this.alt_m = source["alt_m"];
this.rot_on = source["rot_on"];
this.rot_id = source["rot_id"];
this.rot_az_only = source["rot_az_only"];
this.rot_min_el = source["rot_min_el"];
this.rot_step = source["rot_step"];
this.rot_park = source["rot_park"];
@@ -4286,6 +4288,7 @@ export namespace main {
rot_az: number;
rot_el: number;
rot_live: boolean;
rot_az_only: boolean;
static createFrom(source: any = {}) {
return new SatTrackStatus(source);
@@ -4310,6 +4313,7 @@ export namespace main {
this.rot_az = source["rot_az"];
this.rot_el = source["rot_el"];
this.rot_live = source["rot_live"];
this.rot_az_only = source["rot_az_only"];
}
}