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:
@@ -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
|
||||
az, el, hasEl, raw, err := linkHeading(lr.Link)
|
||||
if err != nil {
|
||||
base.Raw = raw
|
||||
if base.Raw == "" {
|
||||
base.Raw = err.Error()
|
||||
}
|
||||
if !st.Connected {
|
||||
base.Raw = "sensor not connected (999)"
|
||||
return base
|
||||
}
|
||||
base.OK = true
|
||||
base.Azimuth = st.Azimuth
|
||||
base.Azimuth = int(math.Round(az))
|
||||
base.Elevation, base.HasElevation = int(math.Round(el)), hasEl
|
||||
base.Raw = raw
|
||||
return base
|
||||
case "arco":
|
||||
az, raw, herr := arcoClient(link).Heading()
|
||||
if herr != nil {
|
||||
base.Raw = herr.Error()
|
||||
return base
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
+17
-1
@@ -41,6 +41,8 @@ const (
|
||||
// this page's: describing one mast in two places is how a station ends up
|
||||
// working on HF and not on a pass.
|
||||
keySatRotID = "sat.rot_id"
|
||||
// Follow the azimuth and leave the elevation alone. See SatSettings.RotAzOnly.
|
||||
keySatRotAzOnly = "sat.rot_az_only"
|
||||
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
|
||||
keySatRotStep = "sat.rot_step" // degrees of change worth a command
|
||||
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
|
||||
@@ -88,6 +90,18 @@ type SatSettings struct {
|
||||
// is worth sending, and whether to park at the end.
|
||||
RotOn bool `json:"rot_on"`
|
||||
RotID string `json:"rot_id"`
|
||||
// RotAzOnly follows the satellite in azimuth and leaves the elevation
|
||||
// alone — which is how most stations that work satellites actually do it.
|
||||
//
|
||||
// A pass at the far edge of the footprint never climbs above ten or fifteen
|
||||
// degrees, and a beam on a plain azimuth rotator points straight through it:
|
||||
// the beamwidth covers the whole thing. Refusing to track for want of an
|
||||
// elevation motor turned the feature off for every operator who has a tower
|
||||
// and no az/el mast, which is nearly all of them.
|
||||
//
|
||||
// It also rescues an az/el station whose elevation motor has failed, and it
|
||||
// is why the rotor list stops being filtered when this is set.
|
||||
RotAzOnly bool `json:"rot_az_only"`
|
||||
RotMinEl int `json:"rot_min_el"`
|
||||
RotStep int `json:"rot_step"`
|
||||
RotPark bool `json:"rot_park"`
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 qu’en 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 @@
|
||||
"L’empreinte au sol n’est 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 l’atmosphè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 l’onglet satellite, le mode est une pastille colorée au lieu d’une note grise, et un satellite FM affiche sa tonalité CTCSS avec le même poids qu’une fréquence — un relais appelé sans sa tonalité ne répond pas, et l’OM entend un canal vide et en conclut que le satellite n’est pas passé. Quand il n’y a pas de tonalité, c’est écrit, plutôt qu’un blanc qui pourrait vouloir dire l’un ou l’autre. Le mode apparaît aussi dans la liste des transpondeurs et dans l’en-tête, donc il survit au masquage de la colonne de droite."
|
||||
"Sur l’onglet satellite, le mode est une pastille colorée au lieu d’une note grise, et un satellite FM affiche sa tonalité CTCSS avec le même poids qu’une fréquence — un relais appelé sans sa tonalité ne répond pas, et l’OM entend un canal vide et en conclut que le satellite n’est pas passé. Quand il n’y a pas de tonalité, c’est écrit, plutôt qu’un blanc qui pourrait vouloir dire l’un ou l’autre. Le mode apparaît aussi dans la liste des transpondeurs et dans l’en-tête, donc il survit au masquage de la colonne de droite.",
|
||||
"Nouvelle option : suivre l’azimut seulement. Une station avec un rotor ordinaire et sans moteur d’élévation peut désormais suivre un satellite — un passage en bord d’empreinte reste entre l’horizon et 15° environ sur toute sa durée, et une beam couvre ça avec son ouverture. Avec l’option activée, n’importe quel rotor de la liste peut être choisi. Ce qu’on perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire — d’où un réglage plutôt qu’un choix fait à votre place."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 beam’s 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 l’horizon', 'sat.tipNoPass': 'aucun passage dans la fenêtre de prévision',
|
||||
'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous l’horizon',
|
||||
@@ -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 l’antenne vers le satellite avec l’un des rotors déjà configurés. C’est 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 l’azimut seulement', 'satset.rotAzOnlyTag': 'azimut seul',
|
||||
'satset.rotAzOnlyHint': 'Pour une station avec un rotor d’azimut ordinaire et sans moteur d’élévation. Un passage en bord d’empreinte reste entre l’horizon et 15° environ sur toute sa durée, et l’ouverture d’une beam couvre ça — le cap seul suffit donc. Ce qu’on perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire. Avec cette option, n’importe 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 n’a d’axe 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.',
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user