diff --git a/app.go b/app.go index 0d9a02c..1033aaa 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/app_sat.go b/app_sat.go index 47879ae..c8b03e8 100644 --- a/app_sat.go +++ b/app_sat.go @@ -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), diff --git a/app_sat_rotator.go b/app_sat_rotator.go index 4f5b175..cda0e03 100644 --- a/app_sat_rotator.go +++ b/app_sat_rotator.go @@ -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() {} diff --git a/app_sat_track.go b/app_sat_track.go index 1e641e7..d601b2b 100644 --- a/app_sat_track.go +++ b/app_sat_track.go @@ -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 { diff --git a/app_sat_track_test.go b/app_sat_track_test.go index d254554..0e27414 100644 --- a/app_sat_track_test.go +++ b/app_sat_track_test.go @@ -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() {} diff --git a/changelog.json b/changelog.json index 911aa12..25a9257 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx index 78e985c..3af759c 100644 --- a/frontend/src/components/SatellitePanel.tsx +++ b/frontend/src/components/SatellitePanel.tsx @@ -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 && (
{t('satset.rotPickHint')}
- {satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && ( + {!satCfg.rot_az_only && satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && ({t('satset.rotNoElAtAll')}
)} + {/* 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. */} + +