diff --git a/app_sat.go b/app_sat.go index 5b4be16..cc77e51 100644 --- a/app_sat.go +++ b/app_sat.go @@ -32,6 +32,20 @@ const ( keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale keySatGrid = "sat.grid" // locator override ("" = the station's own) keySatAltM = "sat.alt_m" // antenna height above sea level, metres + + // The az/el rotator. Its own settings rather than the HF rotator's: a + // satellite station's elevation rotator is a different machine on a + // different port, and an operator who has both must not have to choose. + keySatRotOn = "sat.rot_enabled" + keySatRotTransport = "sat.rot_transport" // "serial" | "tcp" + keySatRotHost = "sat.rot_host" + keySatRotPort = "sat.rot_port" + keySatRotCOM = "sat.rot_com" + keySatRotBaud = "sat.rot_baud" + keySatRotMaxAz = "sat.rot_max_az" // 360 or 450 + 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 ) // customTLEName holds elements the operator pasted in by hand. @@ -50,6 +64,18 @@ type SatSettings struct { AutoTLE bool `json:"auto_tle"` Grid string `json:"grid"` AltM int `json:"alt_m"` + + // The az/el rotator. + RotOn bool `json:"rot_on"` + RotTransport string `json:"rot_transport"` + RotHost string `json:"rot_host"` + RotPort int `json:"rot_port"` + RotCOM string `json:"rot_com"` + RotBaud int `json:"rot_baud"` + RotMaxAz int `json:"rot_max_az"` + 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. @@ -172,14 +198,47 @@ func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) { // ── Settings ──────────────────────────────────────────────────────────────── func (a *App) satSettings() SatSettings { - out := SatSettings{MinEl: 10, WindowH: 24, AutoTLE: true} + // The rotator defaults are the common case, not a blank form: EasyComm over + // a serial port at 9600, a 360° machine, and a five-degree step — which on a + // beam with any gain at all is well inside the beamwidth and keeps a pass + // from being a command a second. + out := SatSettings{ + MinEl: 10, WindowH: 24, AutoTLE: true, + RotTransport: "serial", RotPort: 4533, RotBaud: 9600, + RotMaxAz: 360, RotMinEl: 0, RotStep: 5, + } if a.settings == nil { return out } - m, err := a.settings.GetMany(a.ctx, keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM) + m, err := a.settings.GetMany(a.ctx, + keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM, + keySatRotOn, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM, + keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark) if err != nil { return out } + out.RotOn = m[keySatRotOn] == "1" + if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" { + out.RotTransport = tr + } + out.RotHost = strings.TrimSpace(m[keySatRotHost]) + if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 { + out.RotPort = v + } + out.RotCOM = strings.TrimSpace(m[keySatRotCOM]) + if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 { + out.RotBaud = v + } + if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 { + out.RotMaxAz = 450 + } + if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 { + out.RotMinEl = v + } + if v, err := strconv.Atoi(m[keySatRotStep]); err == nil && v >= 1 && v <= 30 { + out.RotStep = v + } + out.RotPark = m[keySatRotPark] == "1" for _, n := range strings.Split(m[keySatFavorites], ",") { if n = strings.TrimSpace(n); n != "" { out.Favorites = append(out.Favorites, n) @@ -230,13 +289,38 @@ func (a *App) SaveSatSettings(s SatSettings) error { seen[strings.ToUpper(n)] = true favs = append(favs, n) } + if s.RotTransport != "tcp" { + s.RotTransport = "serial" + } + if s.RotMaxAz != 450 { + s.RotMaxAz = 360 + } + if s.RotStep < 1 || s.RotStep > 30 { + s.RotStep = 5 + } + if s.RotPort <= 0 || s.RotPort > 65535 { + s.RotPort = 4533 + } + if s.RotBaud < 1200 || s.RotBaud > 115200 { + s.RotBaud = 9600 + } for k, v := range map[string]string{ - keySatFavorites: strings.Join(favs, ","), - keySatMinEl: strconv.Itoa(s.MinEl), - keySatWindowH: strconv.Itoa(s.WindowH), - keySatAutoTLE: boolStr(s.AutoTLE), - keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)), - keySatAltM: strconv.Itoa(s.AltM), + keySatFavorites: strings.Join(favs, ","), + keySatMinEl: strconv.Itoa(s.MinEl), + keySatWindowH: strconv.Itoa(s.WindowH), + keySatAutoTLE: boolStr(s.AutoTLE), + keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)), + keySatAltM: strconv.Itoa(s.AltM), + keySatRotOn: boolStr(s.RotOn), + keySatRotTransport: s.RotTransport, + keySatRotHost: strings.TrimSpace(s.RotHost), + keySatRotPort: strconv.Itoa(s.RotPort), + keySatRotCOM: strings.TrimSpace(s.RotCOM), + keySatRotBaud: strconv.Itoa(s.RotBaud), + keySatRotMaxAz: strconv.Itoa(s.RotMaxAz), + keySatRotMinEl: strconv.Itoa(s.RotMinEl), + keySatRotStep: strconv.Itoa(s.RotStep), + keySatRotPark: boolStr(s.RotPark), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err diff --git a/app_sat_track.go b/app_sat_track.go index db4f933..9bb36f7 100644 --- a/app_sat_track.go +++ b/app_sat_track.go @@ -29,6 +29,7 @@ import ( "hamlog/internal/applog" "hamlog/internal/cat" "hamlog/internal/qso" + "hamlog/internal/rotator/easycomm" "hamlog/internal/sat" ) @@ -63,6 +64,16 @@ type satTracker struct { status SatTrackStatus fails int + // The az/el rotator, built once at the start of the pass so a serial port is + // opened once rather than on every command. nil when none is configured. + rot *easycomm.Client + rotStep float64 + rotMinE float64 + rotPark bool + rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing + rotEl float64 + rotSent bool + stop chan struct{} done chan struct{} } @@ -82,6 +93,14 @@ type SatTrackStatus struct { Visible bool `json:"visible"` Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", "" Error string `json:"error"` + + // Where the antenna is. RotLive distinguishes a reading from the controller + // from the last position it was TOLD to go to — a stuck rotator must not be + // able to hide behind a command it never carried out. + RotOn bool `json:"rot_on"` + RotAz float64 `json:"rot_az"` + RotEl float64 `json:"rot_el"` + RotLive bool `json:"rot_live"` } // StartSatelliteTracking arms the radio and starts following the satellite. @@ -108,6 +127,18 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error { } t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode} + // The rotator, if there is one. A geostationary bird is pointed at once and + // left alone, so it gets one command rather than a loop. + set := a.satSettings() + if set.RotOn { + if set.RotTransport == "tcp" { + t.rot = easycomm.New(set.RotHost, set.RotPort, set.RotMaxAz) + } else { + t.rot = easycomm.NewSerial(set.RotCOM, set.RotBaud, set.RotMaxAz) + } + t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark + } + // Arm the radio for the pair. A rig that cannot hold one is NOT a failure: // it can still be tuned to the downlink, which is most of a receive-heavy // pass, and saying so beats refusing to track at all. @@ -150,6 +181,39 @@ func (a *App) StopSatelliteTracking() { a.emitSatTrack(SatTrackStatus{}) } +// TestSatelliteRotator opens the configured controller and asks it where it is. +// +// The one question worth asking before a pass: is this port the rotator, and +// does it talk back? A controller that accepts commands silently is a normal, +// working one — so that answer is a success with a caveat, not a failure. +func (a *App) TestSatelliteRotator() (string, error) { + set := a.satSettings() + if !set.RotOn { + return "", fmt.Errorf("the satellite rotator is switched off") + } + var c *easycomm.Client + if set.RotTransport == "tcp" { + if strings.TrimSpace(set.RotHost) == "" { + return "", fmt.Errorf("no address for the rotator") + } + c = easycomm.New(set.RotHost, set.RotPort, set.RotMaxAz) + } else { + if strings.TrimSpace(set.RotCOM) == "" { + return "", fmt.Errorf("no COM port for the rotator") + } + c = easycomm.NewSerial(set.RotCOM, set.RotBaud, set.RotMaxAz) + } + defer c.Close() + az, el, live, err := c.Heading() + if err != nil { + return "", err + } + if !live { + return "The controller accepted the command but does not report its position — normal for many EasyComm controllers. It will still be driven.", nil + } + return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil +} + // GetSatelliteTracking reports what the tracker is doing. func (a *App) GetSatelliteTracking() SatTrackStatus { a.satTrackMu.Lock() @@ -185,6 +249,7 @@ func (a *App) emitSatTrack(s SatTrackStatus) { func (a *App) satTrackLoop(t *satTracker) { defer close(t.done) + defer t.releaseRotator() tick := time.NewTicker(satTickEvery) defer tick.Stop() for { @@ -273,7 +338,14 @@ func (a *App) satTrackStep(t *satTracker) { Radio: t.status.Radio, Error: t.status.Error, } t.mu.Unlock() - a.emitSatTrack(t.status) + + t.pointRotator(pos, b.Geostationary) + t.readRotator() + + t.mu.Lock() + st := t.status + t.mu.Unlock() + a.emitSatTrack(st) // Only send what has actually moved. The step is the smallest change worth a // command: on SSB a listener hears twenty hertz, on an FM channel nothing @@ -322,6 +394,79 @@ func satNominalFromDial(heardHz int64, factor float64) int64 { return int64(math.Round(float64(heardHz) / (1 + factor))) } +// pointRotator keeps the antenna on the satellite. +// +// Below the configured elevation the rotator is left alone. Not because the +// numbers stop being right — they are right all the way round the orbit — but +// because a rotator that chases a satellite through the far side of the earth +// spends the whole night turning, and a mast is a mechanical thing with a +// finite number of turns in it. +func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) { + if t.rot == nil { + return + } + if !geostationary && pos.El < t.rotMinE { + return + } + // A step below the beamwidth is a command for nothing. Compared against what + // was last COMMANDED rather than where the rotator says it is: a rotator in + // motion is always somewhere between the two, and comparing against that + // would order a fresh move on every tick of a slew. + az, el := pos.Az, pos.El + if geostationary { + // A satellite that does not move needs pointing once. Its own az/el were + // not computed (there is nothing to compute), so leave the rotator where + // the operator put it. + if t.rotSent { + return + } + } + if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep { + return + } + if err := t.rot.Point(az, el); err != nil { + t.setError(err.Error()) + return + } + t.rotAz, t.rotEl, t.rotSent = az, el, true +} + +// readRotator asks the controller where it actually is, for the display. +// +// Separate from the pointing, and it runs on every tick rather than only when a +// command was sent: watching the antenna crawl towards the bearing is how an +// operator sees a rotator that is slow, stalled, or turning the wrong way. A +// controller that does not answer says so once and is not asked again. +func (t *satTracker) readRotator() { + if t.rot == nil { + return + } + az, el, live, err := t.rot.Heading() + t.mu.Lock() + defer t.mu.Unlock() + if err != nil { + t.status.RotOn = true + return + } + t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live +} + +// releaseRotator hands the mast back at the end of a pass. +func (t *satTracker) releaseRotator() { + if t.rot == nil { + return + } + if t.rotPark && t.rotSent { + // Elevation down first and azimuth to north: a dish or a pair of yagis + // left pointing at the sky is what a gale takes away. + if err := t.rot.Point(0, 0); err != nil { + applog.Printf("sat: could not park the rotator: %v", err) + } + } + t.rot.Close() + t.rot = nil +} + func (t *satTracker) setError(msg string) { t.mu.Lock() t.status.Error = msg diff --git a/changelog.json b/changelog.json index bcd1ed9..ec8f74e 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,13 @@ "date": "", "en": [ "[NEW] Satellites. A new tab (Tools → Satellites) tracks the amateur birds: a map with each satellite's footprint and the selected one's path over the ground, the next passes with their maximum elevation, and — for the satellite you are on — the azimuth, the elevation and the Doppler-corrected downlink and uplink. Orbital elements come from Celestrak (with a mirror behind it) and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. The shipped frequency list covers the FM and linear satellites and QO-100, and lives in a file you can correct yourself when a transponder is switched.", - "Doppler tracking drives the radio. Track puts the rig on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if they are missing, full duplex on) — and any other radio on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. QSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be." + "Doppler tracking drives the radio. Track puts the rig on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if they are missing, full duplex on) — and any other radio on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. QSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be.", + "The antenna follows too. An EasyComm II rotator — what SatPC32, Gpredict and Hamlib speak, so most az/el controllers — is pointed at the satellite while you track, over serial or over the network (Settings → Satellites). It is a separate machine from your HF rotator, so a station with both keeps both. A 450° rotator is used as one: a pass crossing north continues past 360 instead of unwinding through the whole scale with the antenna sweeping the ground. The panel shows where the antenna actually is beside where the satellite is — and says plainly when a controller only accepts commands without reporting back, which many do." ], "fr": [ "[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode.", - "Le suivi Doppler pilote la radio. « Suivre » met le poste sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quelle autre radio sur la descente seule, ce qu'elle annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Les QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait." + "Le suivi Doppler pilote la radio. « Suivre » met le poste sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quelle autre radio sur la descente seule, ce qu'elle annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Les QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.", + "L'antenne suit aussi. Un rotor EasyComm II — le langage de SatPC32, Gpredict et Hamlib, donc la plupart des contrôleurs az/él — est pointé vers le satellite pendant le suivi, en série ou en réseau (Réglages → Satellites). C'est une machine distincte du rotor HF : une station qui a les deux garde les deux. Un rotor 450° est utilisé comme tel : un passage qui traverse le nord continue au-delà de 360 au lieu de se dérouler sur toute la course, antenne balayant le sol. Le panneau montre où l'antenne se trouve réellement à côté de la position du satellite — et dit clairement quand un contrôleur se contente d'accepter les commandes sans répondre, ce que beaucoup font." ] }, { diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx index fac7e49..d7b8d2b 100644 --- a/frontend/src/components/SatellitePanel.tsx +++ b/frontend/src/components/SatellitePanel.tsx @@ -52,6 +52,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; }; const MAP_VIEW_SAT = 'opslog.satMapView'; @@ -445,6 +446,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { {tp?.inverting && {t('sat.inverting')}} {bird?.geostationary && {t('sat.geo')}} + + {/* Where the antenna is, beside where the satellite is. The two + differing is a rotator still slewing; the two differing for a + long time is a rotator that is stuck, and that is worth being + able to see without walking outside. */} + {tracking?.rot_on && ( +
{t('satset.gridHint')}
+ +{t('satset.rotHint')}
+ + {!!satCfg.rot_on && ( + <> +{t('satset.rotRangeHint')}
+ + + +