From 22837342107235ff6c16ec2b333db5e508b7bc6a Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 7 Sep 2026 17:11:23 +0200 Subject: [PATCH] feat(sat): PstRotator can point the antenna too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It handles azimuth and elevation, and a great many stations already run it in front of a controller OpsLog has never heard of. For those, OpsLog talking to the controller itself would be a second program fighting PstRotator over the same cable — so it hands over the bearing instead, and lets PstRotator turn the mast. Both kinds sit behind one small interface, chosen in Settings. Neither is more correct than the other: the right one is whichever the station already has working. The 450° overlap is deliberately NOT applied on the PstRotator path. PstRotator knows which machine is on the other end and does its own; two programs each deciding to go the long way round is exactly how an antenna unwinds in the middle of a pass. Position queries are asked at most every three seconds rather than on every tick. A PstRotator query binds a socket and waits up to a second and a half, and many setups answer nothing at all — so one silence is enough and it stops asking, reporting the commanded position instead and saying that is what it is. --- app_sat.go | 28 ++++- app_sat_rotator.go | 140 ++++++++++++++++++++++ app_sat_track.go | 50 ++++---- changelog.json | 6 +- frontend/src/components/SettingsModal.tsx | 59 +++++++-- frontend/src/lib/i18n.tsx | 10 +- frontend/wailsjs/go/models.ts | 4 + internal/rotator/pst/pst.go | 71 +++++++++++ 8 files changed, 327 insertions(+), 41 deletions(-) create mode 100644 app_sat_rotator.go diff --git a/app_sat.go b/app_sat.go index 436175e..7296505 100644 --- a/app_sat.go +++ b/app_sat.go @@ -37,7 +37,14 @@ const ( // 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" + keySatRotOn = "sat.rot_enabled" + // Which program drives the mast: OpsLog itself over EasyComm, or PstRotator, + // which many stations already run in front of their controller. Its own port + // key because it is a different program on a different port from an EasyComm + // controller, and an operator who tries both must not lose the first setting + // to the second. + keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator" + keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port keySatRotTransport = "sat.rot_transport" // "serial" | "tcp" keySatRotHost = "sat.rot_host" keySatRotPort = "sat.rot_port" @@ -68,6 +75,8 @@ type SatSettings struct { // The az/el rotator. RotOn bool `json:"rot_on"` + RotType string `json:"rot_type"` + RotPstPort int `json:"rot_pst_port"` RotTransport string `json:"rot_transport"` RotHost string `json:"rot_host"` RotPort int `json:"rot_port"` @@ -236,6 +245,7 @@ func (a *App) satSettings() SatSettings { // from being a command a second. out := SatSettings{ MinEl: 10, WindowH: 24, AutoTLE: true, + RotType: satRotEasycomm, RotPstPort: 12000, RotTransport: "serial", RotPort: 4533, RotBaud: 9600, RotMaxAz: 360, RotMinEl: 0, RotStep: 5, } @@ -244,12 +254,18 @@ func (a *App) satSettings() SatSettings { } m, err := a.settings.GetMany(a.ctx, keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM, - keySatRotOn, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM, + keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM, keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark) if err != nil { return out } out.RotOn = m[keySatRotOn] == "1" + if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm { + out.RotType = ty + } + if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 { + out.RotPstPort = v + } if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" { out.RotTransport = tr } @@ -321,6 +337,12 @@ func (a *App) SaveSatSettings(s SatSettings) error { seen[strings.ToUpper(n)] = true favs = append(favs, n) } + if s.RotType != satRotPst { + s.RotType = satRotEasycomm + } + if s.RotPstPort <= 0 || s.RotPstPort > 65535 { + s.RotPstPort = 12000 + } if s.RotTransport != "tcp" { s.RotTransport = "serial" } @@ -344,6 +366,8 @@ func (a *App) SaveSatSettings(s SatSettings) error { keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)), keySatAltM: strconv.Itoa(s.AltM), keySatRotOn: boolStr(s.RotOn), + keySatRotType: s.RotType, + keySatRotPstPort: strconv.Itoa(s.RotPstPort), keySatRotTransport: s.RotTransport, keySatRotHost: strings.TrimSpace(s.RotHost), keySatRotPort: strconv.Itoa(s.RotPort), diff --git a/app_sat_rotator.go b/app_sat_rotator.go new file mode 100644 index 0000000..88c8e26 --- /dev/null +++ b/app_sat_rotator.go @@ -0,0 +1,140 @@ +package main + +// The two ways a satellite station points its antenna. +// +// Some operators drive their az/el rotator directly — EasyComm II, what +// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between +// them and a dozen different controllers and handles az AND el; for those, +// OpsLog talking to the controller itself would be a second program fighting +// PstRotator over the same cable. +// +// So both, behind one small interface, chosen in Settings. Neither is more +// "correct" than the other: the right one is whichever the station already has +// working. + +import ( + "fmt" + "math" + "strings" + "sync" + + "hamlog/internal/rotator/easycomm" + "hamlog/internal/rotator/pst" +) + +// satRotator is what the tracker needs of an antenna: point it, ask where it +// is, and let go of it at the end of the pass. +type satRotator interface { + Point(az, el float64) error + // Heading reports where the antenna is. live is false when the answer is + // the last commanded position rather than a reading — a stuck rotator must + // not be able to hide behind an order it never carried out. + Heading() (az, el float64, live bool, err error) + Close() +} + +// The rotator kinds, as stored. +const ( + satRotEasycomm = "easycomm" + satRotPst = "pstrotator" +) + +// newSatRotator builds the configured controller. +func newSatRotator(s SatSettings) (satRotator, error) { + switch s.RotType { + case satRotPst: + if strings.TrimSpace(s.RotHost) == "" && s.RotPort <= 0 { + return nil, fmt.Errorf("no address for PstRotator") + } + return &pstSatRotator{c: pst.New(s.RotHost, s.RotPstPort), maxAz: s.RotMaxAz}, nil + default: + if s.RotTransport == "tcp" { + if strings.TrimSpace(s.RotHost) == "" { + return nil, fmt.Errorf("no address for the rotator") + } + return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil + } + if strings.TrimSpace(s.RotCOM) == "" { + return nil, fmt.Errorf("no COM port for the rotator") + } + return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil + } +} + +// pstSatRotator points the antenna through PstRotator. +// +// PstRotator takes whole degrees and does its own overlap handling for a 450° +// rotator — it knows which controller is on the other end, and OpsLog does not. +// So the azimuth is sent plainly, and the 450° logic that EasyComm needs is +// deliberately NOT applied here: two programs each deciding to go the long way +// round is how an antenna ends up unwinding in the middle of a pass. +type pstSatRotator struct { + c *pst.Client + maxAz int + + mu sync.Mutex + // lastAz/lastEl are what was commanded, for the display when PstRotator + // does not answer a position query — which is the usual case for the many + // setups whose controller reports nothing back to it either. + lastAz, lastEl float64 + commanded bool + azSilent bool // the azimuth query went unanswered; stop asking + elSilent bool // likewise for elevation, and far more common +} + +func (p *pstSatRotator) Point(az, el float64) error { + a := math.Mod(az, 360) + if a < 0 { + a += 360 + } + if el < 0 { + el = 0 + } + if el > 180 { + el = 180 + } + if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil { + return err + } + p.mu.Lock() + p.lastAz, p.lastEl, p.commanded = a, el, true + p.mu.Unlock() + return nil +} + +func (p *pstSatRotator) Heading() (float64, float64, bool, error) { + p.mu.Lock() + azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded + p.mu.Unlock() + + az, el, live := la, le, false + if !azSilent { + if v, _, err := p.c.Heading(); err == nil { + az, live = float64(v), true + } else { + // One silence is enough. Each query binds a socket and waits a second + // and a half; repeating that every few seconds for a setup that will + // never answer is a stall per poll for nothing. + p.mu.Lock() + p.azSilent = true + p.mu.Unlock() + } + } + if !elSilent { + if v, _, err := p.c.Elevation(); err == nil { + el = float64(v) + } else { + p.mu.Lock() + p.elSilent = true + p.mu.Unlock() + } + } + if !live && !commanded { + return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position") + } + return az, el, live, nil +} + +// 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() {} diff --git a/app_sat_track.go b/app_sat_track.go index 9bb36f7..2cd2a3b 100644 --- a/app_sat_track.go +++ b/app_sat_track.go @@ -29,7 +29,6 @@ import ( "hamlog/internal/applog" "hamlog/internal/cat" "hamlog/internal/qso" - "hamlog/internal/rotator/easycomm" "hamlog/internal/sat" ) @@ -66,13 +65,14 @@ type satTracker struct { // 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 + rot satRotator + rotStep float64 + rotMinE float64 + rotPark bool + rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing + rotEl float64 + rotSent bool + rotReadAt time.Time // when the controller was last asked where it is stop chan struct{} done chan struct{} @@ -131,12 +131,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error { // 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) + r, rerr := newSatRotator(set) + if rerr != nil { + applog.Printf("sat: no rotator: %v", rerr) + t.status.Error = rerr.Error() } else { - t.rot = easycomm.NewSerial(set.RotCOM, set.RotBaud, set.RotMaxAz) + t.rot = r + t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark } - 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: @@ -191,17 +193,9 @@ func (a *App) TestSatelliteRotator() (string, error) { 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) + c, err := newSatRotator(set) + if err != nil { + return "", err } defer c.Close() az, el, live, err := c.Heading() @@ -209,7 +203,7 @@ func (a *App) TestSatelliteRotator() (string, error) { 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 "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil } return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil } @@ -441,6 +435,14 @@ func (t *satTracker) readRotator() { if t.rot == nil { return } + // Not on every tick. A PstRotator query binds a socket and waits up to a + // second and a half for an answer, and a held serial port still costs a + // round trip; three seconds is often enough to watch an antenna slew and + // rare enough not to sit in the way of the tuning. + if time.Since(t.rotReadAt) < 3*time.Second { + return + } + t.rotReadAt = time.Now() az, el, live, err := t.rot.Heading() t.mu.Lock() defer t.mu.Unlock() diff --git a/changelog.json b/changelog.json index 767929d..b6caafb 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,8 @@ "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.", "Settings → Satellites is where the satellite work is set up, and the tab is what you use during a pass. Choose the satellites you follow the way you choose awards — two columns, search, the ones with a frequency plan first — and the tab and the pass list show those and nothing else. The orbital elements move there too: age, fetch, and paste-your-own. None of it is something to be doing while a bird is going over.", "The satellite panel says what a pass actually is. A countdown to AOS, or to LOS once it is up, with a bar showing where in the pass you are; rise, peak and set with their compass directions; distance, altitude and footprint; and whether it is approaching or receding, which is why the frequencies move the way they do. The frequencies keep the corrected figure large and the Doppler shift beside it.", - "One satellite list, in one place. Settings → Lists → Satellites is gone: the birds it held by hand had nothing to do with the ones the tracker knew, and the same station kept two lists that drifted apart. The SAT_NAME box on the entry form now offers the satellites you follow — plus anything the old list still held, so nobody's typing is lost. Satellites also moved out of Hardware, where it never belonged, and sits with Operating." + "One satellite list, in one place. Settings → Lists → Satellites is gone: the birds it held by hand had nothing to do with the ones the tracker knew, and the same station kept two lists that drifted apart. The SAT_NAME box on the entry form now offers the satellites you follow — plus anything the old list still held, so nobody's typing is lost. Satellites also moved out of Hardware, where it never belonged, and sits with Operating.", + "PstRotator can point the antenna for satellites too. It handles azimuth and elevation and already knows your controller, so if you run it, choose it in Settings → Satellites and OpsLog sends it the bearing instead of taking the cable itself — two programs on one controller is one too many. Its overlap handling stays its own: a 450° rotator is PstRotator's business, not something two programs should each decide about mid-pass." ], "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.", @@ -16,7 +17,8 @@ "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.", "Réglages → Satellites, c'est là qu'on configure ; l'onglet, c'est ce qu'on utilise pendant un passage. On choisit les satellites suivis comme on choisit les diplômes — deux colonnes, recherche, ceux qui ont un plan de fréquences d'abord — et l'onglet comme la liste des passages n'affichent que ceux-là. Les éléments orbitaux y passent aussi : âge, récupération, et collage des siens. Rien de tout cela ne se fait pendant qu'un satellite passe.", "Le panneau satellite dit enfin ce qu'est un passage. Un compte à rebours jusqu'à l'AOS, ou jusqu'au LOS une fois qu'il est levé, avec une barre montrant où l'on en est ; lever, culmination et coucher avec leurs directions à la boussole ; distance, altitude et empreinte ; et s'il se rapproche ou s'éloigne, ce qui explique le sens du décalage. Les fréquences gardent la valeur corrigée en grand et le Doppler à côté.", - "Une seule liste de satellites, à un seul endroit. Réglages → Listes → Satellites disparaît : les satellites qu'on y saisissait à la main n'avaient aucun rapport avec ceux que connaissait le suivi, et une même station entretenait deux listes qui divergeaient. Le champ SAT_NAME de la saisie propose désormais les satellites que vous suivez — plus ce que l'ancienne liste contenait encore, pour ne rien perdre. Satellites quitte aussi Matériel, où il n'avait rien à faire, pour rejoindre Opération." + "Une seule liste de satellites, à un seul endroit. Réglages → Listes → Satellites disparaît : les satellites qu'on y saisissait à la main n'avaient aucun rapport avec ceux que connaissait le suivi, et une même station entretenait deux listes qui divergeaient. Le champ SAT_NAME de la saisie propose désormais les satellites que vous suivez — plus ce que l'ancienne liste contenait encore, pour ne rien perdre. Satellites quitte aussi Matériel, où il n'avait rien à faire, pour rejoindre Opération.", + "PstRotator peut aussi pointer l'antenne pour les satellites. Il gère azimut et élévation et connaît déjà votre contrôleur : si vous le faites tourner, choisissez-le dans Réglages → Satellites et OpsLog lui envoie le cap au lieu de prendre le câble lui-même — deux programmes sur un contrôleur, c'est un de trop. Le recouvrement reste son affaire : un rotor 450°, c'est à PstRotator d'en décider, pas à deux programmes en plein passage." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 6ea7e0e..fdaaef3 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1848,7 +1848,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({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_transport: 'serial', rot_host: '', rot_port: 4533, rot_com: '', rot_baud: 9600, rot_max_az: 360, rot_min_el: 0, rot_step: 5, rot_park: false }); + const [satCfg, setSatCfg] = useState({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_type: 'easycomm', rot_pst_port: 12000, rot_transport: 'serial', rot_host: '', rot_port: 4533, rot_com: '', rot_baud: 9600, rot_max_az: 360, 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), @@ -4567,7 +4567,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged {!!satCfg.rot_on && ( <> + {/* Who drives the mast. Not a detail: a station already running + PstRotator must NOT have OpsLog on the same cable as well. */}
+
+ + +
+ {satCfg.rot_type === 'pstrotator' && ( + <> +
+ + set('rot_host', e.target.value)} /> +
+
+ + set('rot_pst_port', num(e.target.value))} /> +
+ + )} +
+ {satCfg.rot_type === 'pstrotator' && ( +

{t('satset.rotPstHint')}

+ )} +
set('rot_max_az', parseInt(v, 10))}> - - - 360° - 450° - - -
+ {/* The rotator's range is ours to know only when we drive the + controller. PstRotator knows which machine is on the other + end and does its own overlap; two programs each deciding to + go the long way round is how an antenna unwinds mid-pass. */} + {satCfg.rot_type !== 'pstrotator' && ( +
+ + +
+ )}
EL?"); err != nil { + return 0, "", fmt.Errorf("query PstRotator: %w", err) + } + _ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond)) + buf := make([]byte, 512) + n, _, rerr := pc.ReadFrom(buf) + if rerr != nil { + return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr) + } + raw = string(buf[:n]) + v, ok := parseLabelled(raw, "EL", "AZ") + if !ok { + return 0, raw, fmt.Errorf("no elevation in reply %q", raw) + } + return v, raw, nil +} + +// parseLabelled reads the number attached to a label — "EL:45", "EL 45", +// "45". +// +// The number is the first one AFTER the label, and false is returned when the +// label is absent — which is how an answer to the other question gets refused +// rather than read as this one. +func parseLabelled(s, label, other string) (int, bool) { + up := strings.ToUpper(s) + i := strings.Index(up, label) + if i < 0 { + return 0, false + } + // A reply carrying BOTH labels is answering the other question first; only + // what follows our own label counts. + rest := up[i+len(label):] + if j := strings.Index(rest, other); j >= 0 { + rest = rest[:j] + } + j := 0 + for j < len(rest) && (rest[j] < '0' || rest[j] > '9') { + j++ + } + k := j + for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' { + k++ + } + if k == j { + return 0, false + } + n, err := strconv.Atoi(rest[j:k]) + if err != nil { + return 0, false + } + return n, true +} + // parseAzimuth extracts the first integer found in a PstRotator reply // ("AZ:123", "123", "123", …) and normalises // it to [0,360).