feat(sat): PstRotator can point the antenna too

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.
This commit is contained in:
2026-09-07 17:11:23 +02:00
parent 9dfa6f7d39
commit 2283734210
8 changed files with 327 additions and 41 deletions
+26 -2
View File
@@ -37,7 +37,14 @@ const (
// The az/el rotator. Its own settings rather than the HF rotator's: a // 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 // satellite station's elevation rotator is a different machine on a
// different port, and an operator who has both must not have to choose. // 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" keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
keySatRotHost = "sat.rot_host" keySatRotHost = "sat.rot_host"
keySatRotPort = "sat.rot_port" keySatRotPort = "sat.rot_port"
@@ -68,6 +75,8 @@ type SatSettings struct {
// The az/el rotator. // The az/el rotator.
RotOn bool `json:"rot_on"` RotOn bool `json:"rot_on"`
RotType string `json:"rot_type"`
RotPstPort int `json:"rot_pst_port"`
RotTransport string `json:"rot_transport"` RotTransport string `json:"rot_transport"`
RotHost string `json:"rot_host"` RotHost string `json:"rot_host"`
RotPort int `json:"rot_port"` RotPort int `json:"rot_port"`
@@ -236,6 +245,7 @@ func (a *App) satSettings() SatSettings {
// from being a command a second. // from being a command a second.
out := SatSettings{ out := SatSettings{
MinEl: 10, WindowH: 24, AutoTLE: true, MinEl: 10, WindowH: 24, AutoTLE: true,
RotType: satRotEasycomm, RotPstPort: 12000,
RotTransport: "serial", RotPort: 4533, RotBaud: 9600, RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
RotMaxAz: 360, RotMinEl: 0, RotStep: 5, RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
} }
@@ -244,12 +254,18 @@ func (a *App) satSettings() SatSettings {
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM, keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
keySatRotOn, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM, keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark) keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
if err != nil { if err != nil {
return out return out
} }
out.RotOn = m[keySatRotOn] == "1" 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" { if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
out.RotTransport = tr out.RotTransport = tr
} }
@@ -321,6 +337,12 @@ func (a *App) SaveSatSettings(s SatSettings) error {
seen[strings.ToUpper(n)] = true seen[strings.ToUpper(n)] = true
favs = append(favs, n) 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" { if s.RotTransport != "tcp" {
s.RotTransport = "serial" s.RotTransport = "serial"
} }
@@ -344,6 +366,8 @@ func (a *App) SaveSatSettings(s SatSettings) error {
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)), keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
keySatAltM: strconv.Itoa(s.AltM), keySatAltM: strconv.Itoa(s.AltM),
keySatRotOn: boolStr(s.RotOn), keySatRotOn: boolStr(s.RotOn),
keySatRotType: s.RotType,
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
keySatRotTransport: s.RotTransport, keySatRotTransport: s.RotTransport,
keySatRotHost: strings.TrimSpace(s.RotHost), keySatRotHost: strings.TrimSpace(s.RotHost),
keySatRotPort: strconv.Itoa(s.RotPort), keySatRotPort: strconv.Itoa(s.RotPort),
+140
View File
@@ -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() {}
+26 -24
View File
@@ -29,7 +29,6 @@ import (
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/cat" "hamlog/internal/cat"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/rotator/easycomm"
"hamlog/internal/sat" "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 // 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. // opened once rather than on every command. nil when none is configured.
rot *easycomm.Client rot satRotator
rotStep float64 rotStep float64
rotMinE float64 rotMinE float64
rotPark bool rotPark bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64 rotEl float64
rotSent bool rotSent bool
rotReadAt time.Time // when the controller was last asked where it is
stop chan struct{} stop chan struct{}
done 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. // left alone, so it gets one command rather than a loop.
set := a.satSettings() set := a.satSettings()
if set.RotOn { if set.RotOn {
if set.RotTransport == "tcp" { r, rerr := newSatRotator(set)
t.rot = easycomm.New(set.RotHost, set.RotPort, set.RotMaxAz) if rerr != nil {
applog.Printf("sat: no rotator: %v", rerr)
t.status.Error = rerr.Error()
} else { } 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: // 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 { if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off") return "", fmt.Errorf("the satellite rotator is switched off")
} }
var c *easycomm.Client c, err := newSatRotator(set)
if set.RotTransport == "tcp" { if err != nil {
if strings.TrimSpace(set.RotHost) == "" { return "", err
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() defer c.Close()
az, el, live, err := c.Heading() az, el, live, err := c.Heading()
@@ -209,7 +203,7 @@ func (a *App) TestSatelliteRotator() (string, error) {
return "", err return "", err
} }
if !live { 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 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 { if t.rot == nil {
return 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() az, el, live, err := t.rot.Heading()
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
+4 -2
View File
@@ -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.", "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.", "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.", "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": [ "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.", "[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.", "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.", "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é.", "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."
] ]
}, },
{ {
+48 -11
View File
@@ -1848,7 +1848,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// Satellites: the observer and the az/el rotator. The rest of the satellite // 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 // settings (favourites, the pass window) are set in the tab itself, where
// they are used. // 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_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<any>({ 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(''); const [satTest, setSatTest] = useState('');
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined), // Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
@@ -4567,7 +4567,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{!!satCfg.rot_on && ( {!!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. */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.rotType')}</Label>
<Select value={satCfg.rot_type || 'easycomm'} onValueChange={(v) => set('rot_type', v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easycomm">{t('satset.rotEasycomm')}</SelectItem>
<SelectItem value="pstrotator">{t('satset.rotPst')}</SelectItem>
</SelectContent>
</Select>
</div>
{satCfg.rot_type === 'pstrotator' && (
<>
<div className="space-y-1">
<Label>{t('satset.rotHost')}</Label>
<Input className="font-mono" placeholder="127.0.0.1"
value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t('satset.rotPstPort')}</Label>
<Input className="font-mono" value={String(satCfg.rot_pst_port ?? 12000)}
onChange={(e) => set('rot_pst_port', num(e.target.value))} />
</div>
</>
)}
</div>
{satCfg.rot_type === 'pstrotator' && (
<p className="text-xs text-muted-foreground">{t('satset.rotPstHint')}</p>
)}
<div className={cn('grid grid-cols-3 gap-3', satCfg.rot_type === 'pstrotator' && 'hidden')}>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('satset.rotLink')}</Label> <Label>{t('satset.rotLink')}</Label>
<Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}> <Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
@@ -4618,16 +4649,22 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1"> {/* The rotator's range is ours to know only when we drive the
<Label>{t('satset.rotRange')}</Label> controller. PstRotator knows which machine is on the other
<Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}> end and does its own overlap; two programs each deciding to
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> go the long way round is how an antenna unwinds mid-pass. */}
<SelectContent> {satCfg.rot_type !== 'pstrotator' && (
<SelectItem value="360">360°</SelectItem> <div className="space-y-1">
<SelectItem value="450">450°</SelectItem> <Label>{t('satset.rotRange')}</Label>
</SelectContent> <Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}>
</Select> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
</div> <SelectContent>
<SelectItem value="360">360°</SelectItem>
<SelectItem value="450">450°</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('satset.rotMinEl')}</Label> <Label>{t('satset.rotMinEl')}</Label>
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)} <Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
+8 -2
View File
@@ -603,7 +603,10 @@ const en: Dict = {
'satset.autoTle': 'Fetch fresh elements at startup when they are more than three days old', 'satset.autoTle': 'Fetch fresh elements at startup when they are more than three days old',
'satset.rotor': 'Azimuth / elevation rotator', 'satset.rotor': 'Azimuth / elevation rotator',
'satset.rotEnable': 'Point a rotator at the satellite while tracking', 'satset.rotEnable': 'Point a rotator at the satellite while tracking',
'satset.rotHint': 'EasyComm II — what SatPC32, Gpredict and Hamlib speak, so any controller that works with those works here. This is a separate machine from your HF rotator: having both is normal.', 'satset.rotHint': 'Either OpsLog drives the controller itself over EasyComm II — what SatPC32, Gpredict and Hamlib speak — or it hands the bearing to PstRotator, which many stations already run. Use PstRotator if you have it: two programs on one cable is one too many. Whichever it is, this is a separate machine from your HF rotator, and having both is normal.',
'satset.rotType': 'Driven by', 'satset.rotEasycomm': 'OpsLog (EasyComm II)', 'satset.rotPst': 'PstRotator',
'satset.rotPstPort': 'UDP port',
'satset.rotPstHint': 'PstRotator handles azimuth and elevation and already knows your controller, so OpsLog sends it the bearing and lets it turn the mast. Its UDP port is in PstRotator under Setup ▸ TCP/UDP Server — the default is 12000. Nothing else here applies: the rotators range and its overlap are PstRotators business.',
'satset.rotLink': 'Connection', 'satset.rotSerial': 'Serial (COM)', 'satset.rotTcp': 'Network (TCP)', 'satset.rotLink': 'Connection', 'satset.rotSerial': 'Serial (COM)', 'satset.rotTcp': 'Network (TCP)',
'satset.rotHost': 'Address', 'satset.rotPort': 'Port', 'satset.rotCom': 'COM port', 'satset.rotBaud': 'Baud', 'satset.rotHost': 'Address', 'satset.rotPort': 'Port', 'satset.rotCom': 'COM port', 'satset.rotBaud': 'Baud',
'satset.rotRange': 'Rotator range', 'satset.rotMinEl': 'Start above (°)', 'satset.rotStep': 'Move by (°)', 'satset.rotRange': 'Rotator range', 'satset.rotMinEl': 'Start above (°)', 'satset.rotStep': 'Move by (°)',
@@ -1187,7 +1190,10 @@ const fr: Dict = {
'satset.autoTle': 'Récupérer des éléments frais au démarrage quand ils ont plus de trois jours', 'satset.autoTle': 'Récupérer des éléments frais au démarrage quand ils ont plus de trois jours',
'satset.rotor': 'Rotor azimut / élévation', 'satset.rotor': 'Rotor azimut / élévation',
'satset.rotEnable': 'Pointer un rotor vers le satellite pendant le suivi', 'satset.rotEnable': 'Pointer un rotor vers le satellite pendant le suivi',
'satset.rotHint': 'EasyComm II — le langage de SatPC32, Gpredict et Hamlib : tout contrôleur qui fonctionne avec eux fonctionne ici. Cest une machine distincte de votre rotor HF : avoir les deux est normal.', 'satset.rotHint': 'Soit OpsLog pilote le contrôleur lui-même en EasyComm II — le langage de SatPC32, Gpredict et Hamlib — soit il transmet le cap à PstRotator, que beaucoup de stations font déjà tourner. Utilisez PstRotator si vous lavez : deux programmes sur un même câble, cest un de trop. Dans les deux cas, cest une machine distincte du rotor HF, et avoir les deux est normal.',
'satset.rotType': 'Piloté par', 'satset.rotEasycomm': 'OpsLog (EasyComm II)', 'satset.rotPst': 'PstRotator',
'satset.rotPstPort': 'Port UDP',
'satset.rotPstHint': 'PstRotator gère azimut et élévation et connaît déjà votre contrôleur : OpsLog lui envoie le cap et le laisse tourner le pylône. Son port UDP se trouve dans PstRotator sous Setup ▸ TCP/UDP Server — 12000 par défaut. Rien dautre ici ne sapplique : la course du rotor et son recouvrement sont laffaire de PstRotator.',
'satset.rotLink': 'Connexion', 'satset.rotSerial': 'Série (COM)', 'satset.rotTcp': 'Réseau (TCP)', 'satset.rotLink': 'Connexion', 'satset.rotSerial': 'Série (COM)', 'satset.rotTcp': 'Réseau (TCP)',
'satset.rotHost': 'Adresse', 'satset.rotPort': 'Port', 'satset.rotCom': 'Port COM', 'satset.rotBaud': 'Bauds', 'satset.rotHost': 'Adresse', 'satset.rotPort': 'Port', 'satset.rotCom': 'Port COM', 'satset.rotBaud': 'Bauds',
'satset.rotRange': 'Course du rotor', 'satset.rotMinEl': 'Démarrer au-dessus de (°)', 'satset.rotStep': 'Déplacer par (°)', 'satset.rotRange': 'Course du rotor', 'satset.rotMinEl': 'Démarrer au-dessus de (°)', 'satset.rotStep': 'Déplacer par (°)',
+4
View File
@@ -4136,6 +4136,8 @@ export namespace main {
grid: string; grid: string;
alt_m: number; alt_m: number;
rot_on: boolean; rot_on: boolean;
rot_type: string;
rot_pst_port: number;
rot_transport: string; rot_transport: string;
rot_host: string; rot_host: string;
rot_port: number; rot_port: number;
@@ -4159,6 +4161,8 @@ export namespace main {
this.grid = source["grid"]; this.grid = source["grid"];
this.alt_m = source["alt_m"]; this.alt_m = source["alt_m"];
this.rot_on = source["rot_on"]; this.rot_on = source["rot_on"];
this.rot_type = source["rot_type"];
this.rot_pst_port = source["rot_pst_port"];
this.rot_transport = source["rot_transport"]; this.rot_transport = source["rot_transport"];
this.rot_host = source["rot_host"]; this.rot_host = source["rot_host"];
this.rot_port = source["rot_port"]; this.rot_port = source["rot_port"];
+71
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"net" "net"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -85,6 +86,76 @@ func (c *Client) Heading() (az int, raw string, err error) {
return a, raw, nil return a, raw, nil
} }
// Elevation queries PstRotator for the current elevation.
//
// Same shape as Heading, and the same port+1 listener — but a great many
// PstRotator setups drive an azimuth-only rotator and answer nothing at all,
// which is why the caller is expected to ask once and stop rather than wait a
// second and a half per poll for a reply that is never coming.
//
// The reply is matched on its LABEL and not on "the first number in it": AZ?
// and EL? both report on the same port, so taking the first integer of whatever
// arrives would happily read an azimuth as an elevation.
func (c *Client) Elevation() (el int, raw string, err error) {
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
if err != nil {
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
}
defer pc.Close()
if err := c.send("<PST>EL?</PST>"); 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",
// "<PST><ELEVATION>45</ELEVATION></PST>".
//
// 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 // parseAzimuth extracts the first integer found in a PstRotator reply
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises // ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
// it to [0,360). // it to [0,360).