feat(sat): point the antenna — EasyComm II az/el rotator
EasyComm is what satellite rotator controllers agreed on, so a box that works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its own settings rather than the HF rotator's: an az/el pair is a different machine on a different port, and an operator who has both must not have to choose. A great many EasyComm controllers — the Arduino trackers above all — accept commands and never say a word back. That is legal and common, so a silent controller is not treated as a broken one: it is still driven, and the last commanded position is reported in its place, marked as commanded rather than read. A stuck rotator must not be able to hide behind an order it never carried out, which is why the panel shows the antenna's position beside the satellite's. The 450° overlap is the reason a satellite rotator is worth having, so it is used: a pass crossing north continues past 360 instead of unwinding three quarters of a turn with the antenna sweeping the ground. Below the configured elevation the mast is left alone — the numbers are right all the way round the orbit, but a rotator that chases a satellite through the far side of the earth spends the night turning, and a mast has a finite number of turns in it.
This commit is contained in:
+86
-2
@@ -32,6 +32,20 @@ const (
|
|||||||
keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale
|
keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale
|
||||||
keySatGrid = "sat.grid" // locator override ("" = the station's own)
|
keySatGrid = "sat.grid" // locator override ("" = the station's own)
|
||||||
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
|
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.
|
// customTLEName holds elements the operator pasted in by hand.
|
||||||
@@ -50,6 +64,18 @@ type SatSettings struct {
|
|||||||
AutoTLE bool `json:"auto_tle"`
|
AutoTLE bool `json:"auto_tle"`
|
||||||
Grid string `json:"grid"`
|
Grid string `json:"grid"`
|
||||||
AltM int `json:"alt_m"`
|
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.
|
// 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 ────────────────────────────────────────────────────────────────
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (a *App) satSettings() SatSettings {
|
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 {
|
if a.settings == nil {
|
||||||
return out
|
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 {
|
if err != nil {
|
||||||
return out
|
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], ",") {
|
for _, n := range strings.Split(m[keySatFavorites], ",") {
|
||||||
if n = strings.TrimSpace(n); n != "" {
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
out.Favorites = append(out.Favorites, n)
|
out.Favorites = append(out.Favorites, n)
|
||||||
@@ -230,6 +289,21 @@ 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.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{
|
for k, v := range map[string]string{
|
||||||
keySatFavorites: strings.Join(favs, ","),
|
keySatFavorites: strings.Join(favs, ","),
|
||||||
keySatMinEl: strconv.Itoa(s.MinEl),
|
keySatMinEl: strconv.Itoa(s.MinEl),
|
||||||
@@ -237,6 +311,16 @@ func (a *App) SaveSatSettings(s SatSettings) error {
|
|||||||
keySatAutoTLE: boolStr(s.AutoTLE),
|
keySatAutoTLE: boolStr(s.AutoTLE),
|
||||||
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),
|
||||||
|
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 {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+146
-1
@@ -29,6 +29,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,6 +64,16 @@ type satTracker struct {
|
|||||||
status SatTrackStatus
|
status SatTrackStatus
|
||||||
fails int
|
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{}
|
stop chan struct{}
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
@@ -82,6 +93,14 @@ type SatTrackStatus struct {
|
|||||||
Visible bool `json:"visible"`
|
Visible bool `json:"visible"`
|
||||||
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||||
Error string `json:"error"`
|
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.
|
// 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}
|
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:
|
// 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
|
// 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.
|
// pass, and saying so beats refusing to track at all.
|
||||||
@@ -150,6 +181,39 @@ func (a *App) StopSatelliteTracking() {
|
|||||||
a.emitSatTrack(SatTrackStatus{})
|
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.
|
// GetSatelliteTracking reports what the tracker is doing.
|
||||||
func (a *App) GetSatelliteTracking() SatTrackStatus {
|
func (a *App) GetSatelliteTracking() SatTrackStatus {
|
||||||
a.satTrackMu.Lock()
|
a.satTrackMu.Lock()
|
||||||
@@ -185,6 +249,7 @@ func (a *App) emitSatTrack(s SatTrackStatus) {
|
|||||||
|
|
||||||
func (a *App) satTrackLoop(t *satTracker) {
|
func (a *App) satTrackLoop(t *satTracker) {
|
||||||
defer close(t.done)
|
defer close(t.done)
|
||||||
|
defer t.releaseRotator()
|
||||||
tick := time.NewTicker(satTickEvery)
|
tick := time.NewTicker(satTickEvery)
|
||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -273,7 +338,14 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
Radio: t.status.Radio, Error: t.status.Error,
|
Radio: t.status.Radio, Error: t.status.Error,
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
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
|
// 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
|
// 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)))
|
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) {
|
func (t *satTracker) setError(msg string) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.status.Error = msg
|
t.status.Error = msg
|
||||||
|
|||||||
+4
-2
@@ -4,11 +4,13 @@
|
|||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"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.",
|
"[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": [
|
"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.",
|
||||||
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type Track = {
|
|||||||
az: number; el: number; visible: boolean;
|
az: number; el: number; visible: boolean;
|
||||||
radio: string; // "sat" | "downlink-only" | ""
|
radio: string; // "sat" | "downlink-only" | ""
|
||||||
error: string;
|
error: string;
|
||||||
|
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAP_VIEW_SAT = 'opslog.satMapView';
|
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||||
@@ -445,6 +446,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
||||||
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
||||||
|
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
|
||||||
|
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetPSUSettings, SavePSUSettings,
|
GetPSUSettings, SavePSUSettings,
|
||||||
|
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
@@ -228,6 +229,7 @@ type SectionId =
|
|||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'tunergenius'
|
| 'tunergenius'
|
||||||
| 'psu'
|
| 'psu'
|
||||||
|
| 'satellite'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
| 'relayauto'
|
| 'relayauto'
|
||||||
@@ -294,6 +296,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
const hardware: TreeNode[] = [
|
const hardware: TreeNode[] = [
|
||||||
{ kind: 'item', label: t('sec.cat'), id: 'cat' },
|
{ kind: 'item', label: t('sec.cat'), id: 'cat' },
|
||||||
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
|
{ kind: 'item', label: t('sec.rotator'), id: 'rotator' },
|
||||||
|
{ kind: 'item', label: t('sec.satellite'), id: 'satellite' },
|
||||||
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
||||||
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
||||||
@@ -1679,6 +1682,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
|
||||||
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
||||||
|
// Satellites: the observer and the az/el rotator. The rest of the satellite
|
||||||
|
// settings (favourites, the pass window) are set in the tab itself, where
|
||||||
|
// they are used.
|
||||||
|
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_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),
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
@@ -2286,6 +2294,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
|
try { setSatCfg(await GetSatSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
@@ -2330,6 +2339,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
|
try { setSatCfg(await GetSatSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
@@ -2531,6 +2541,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
await SavePSUSettings(psuCfg as any);
|
await SavePSUSettings(psuCfg as any);
|
||||||
|
await SaveSatSettings(satCfg as any);
|
||||||
await SaveAmplifiers(amps as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
@@ -4365,6 +4376,165 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Satellites: where the antenna is, and the machine that points it.
|
||||||
|
//
|
||||||
|
// The observer belongs here rather than in the tab because it is a property
|
||||||
|
// of the station, and the rotator because it is a second machine on a second
|
||||||
|
// port — a station with an HF rotator and an az/el pair must be able to have
|
||||||
|
// both, and choosing between them in one panel would be the wrong question.
|
||||||
|
function SatellitePanelSettings() {
|
||||||
|
const ports = wkPorts;
|
||||||
|
const setPorts = setWkPorts;
|
||||||
|
const set = (k: string, v: any) => setSatCfg((s: any) => ({ ...s, [k]: v }));
|
||||||
|
const num = (v: string) => parseInt(v.replace(/[^0-9-]/g, ''), 10) || 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title={t('sec.satellite')} hint={t('satset.hint')} />
|
||||||
|
<div className="space-y-5 max-w-xl">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.grid')}</Label>
|
||||||
|
<Input className="font-mono" placeholder={t('satset.gridPlaceholder')}
|
||||||
|
value={satCfg.grid ?? ''} onChange={(e) => set('grid', e.target.value.toUpperCase())} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.altM')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.alt_m ?? 0)}
|
||||||
|
onChange={(e) => set('alt_m', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.minEl')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.min_el ?? 10)}
|
||||||
|
onChange={(e) => set('min_el', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.gridHint')}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.windowH')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.window_h ?? 24)}
|
||||||
|
onChange={(e) => set('window_h', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-end gap-2 text-sm cursor-pointer pb-2">
|
||||||
|
<Checkbox checked={!!satCfg.auto_tle} onCheckedChange={(c) => set('auto_tle', !!c)} />
|
||||||
|
{t('satset.autoTle')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{t('satset.rotor')}</h4>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!satCfg.rot_on} onCheckedChange={(c) => set('rot_on', !!c)} />
|
||||||
|
{t('satset.rotEnable')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.rotHint')}</p>
|
||||||
|
|
||||||
|
{!!satCfg.rot_on && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotLink')}</Label>
|
||||||
|
<Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="serial">{t('satset.rotSerial')}</SelectItem>
|
||||||
|
<SelectItem value="tcp">{t('satset.rotTcp')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{satCfg.rot_transport === 'tcp' ? (
|
||||||
|
<>
|
||||||
|
<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.rotPort')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.rot_port ?? 4533)}
|
||||||
|
onChange={(e) => set('rot_port', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotCom')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
↻
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotBaud')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.rot_baud ?? 9600)}
|
||||||
|
onChange={(e) => set('rot_baud', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotRange')}</Label>
|
||||||
|
<Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="360">360°</SelectItem>
|
||||||
|
<SelectItem value="450">450°</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotMinEl')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
|
||||||
|
onChange={(e) => set('rot_min_el', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotStep')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.rot_step ?? 5)}
|
||||||
|
onChange={(e) => set('rot_step', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.rotRangeHint')}</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!satCfg.rot_park} onCheckedChange={(c) => set('rot_park', !!c)} />
|
||||||
|
{t('satset.rotPark')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={async () => {
|
||||||
|
setSatTest(t('satset.rotTesting'));
|
||||||
|
try {
|
||||||
|
// Saved first: the test opens the port from the STORED
|
||||||
|
// settings, and testing what is on screen rather than what
|
||||||
|
// is stored is the classic way to prove a COM port that is
|
||||||
|
// not the one about to be used.
|
||||||
|
await SaveSatSettings(satCfg as any);
|
||||||
|
setSatTest(String(await TestSatelliteRotator()));
|
||||||
|
} catch (e: any) { setSatTest(String(e?.message ?? e)); }
|
||||||
|
}}>
|
||||||
|
{t('satset.rotTest')}
|
||||||
|
</Button>
|
||||||
|
{satTest && <span className="text-xs text-muted-foreground">{satTest}</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
// presents it as brand + model.
|
// presents it as brand + model.
|
||||||
@@ -8370,6 +8540,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
tunergenius: TunerGeniusPanelSettings,
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
psu: PSUPanelSettings,
|
psu: PSUPanelSettings,
|
||||||
|
satellite: SatellitePanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => (
|
flex: () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -587,6 +587,22 @@ const en: Dict = {
|
|||||||
'sat.trackingFull': 'Tracking both ends of the pass. Tune the receiver freely; the transmitter follows.',
|
'sat.trackingFull': 'Tracking both ends of the pass. Tune the receiver freely; the transmitter follows.',
|
||||||
'sat.trackingDown': 'Tracking the downlink only — this radio has one receiver.',
|
'sat.trackingDown': 'Tracking the downlink only — this radio has one receiver.',
|
||||||
'sat.downlinkOnly': 'downlink only', 'sat.nominal': 'nominal',
|
'sat.downlinkOnly': 'downlink only', 'sat.nominal': 'nominal',
|
||||||
|
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
|
||||||
|
'sec.satellite': 'Satellites',
|
||||||
|
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
|
||||||
|
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'your station’s',
|
||||||
|
'satset.gridHint': 'Leave the locator empty to use your station’s own. Altitude is the antenna above sea level — it changes the horizon, and so the start and end of a low pass.',
|
||||||
|
'satset.altM': 'Altitude (m)', 'satset.minEl': 'Lowest pass (°)', 'satset.windowH': 'Predict ahead (hours)',
|
||||||
|
'satset.autoTle': 'Fetch fresh elements at startup when they are more than three days old',
|
||||||
|
'satset.rotor': 'Azimuth / elevation rotator',
|
||||||
|
'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.rotLink': 'Connection', 'satset.rotSerial': 'Serial (COM)', 'satset.rotTcp': 'Network (TCP)',
|
||||||
|
'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.rotRangeHint': 'A 450° rotator follows a pass straight through north instead of unwinding, so it is used that way when it can be. “Start above” leaves the mast alone until the satellite is worth pointing at; “move by” is the smallest change worth a command — keep it inside your beamwidth.',
|
||||||
|
'satset.rotPark': 'Park at north, elevation zero, when tracking stops',
|
||||||
|
'satset.rotTest': 'Test the rotator', 'satset.rotTesting': 'asking the controller…',
|
||||||
};
|
};
|
||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
@@ -1137,6 +1153,22 @@ const fr: Dict = {
|
|||||||
'sat.trackingFull': 'Les deux bouts du passage sont suivis. Accordez le récepteur librement, l’émetteur suit.',
|
'sat.trackingFull': 'Les deux bouts du passage sont suivis. Accordez le récepteur librement, l’émetteur suit.',
|
||||||
'sat.trackingDown': 'Seule la descente est suivie — cette radio n’a qu’un récepteur.',
|
'sat.trackingDown': 'Seule la descente est suivie — cette radio n’a qu’un récepteur.',
|
||||||
'sat.downlinkOnly': 'descente seule', 'sat.nominal': 'nominal',
|
'sat.downlinkOnly': 'descente seule', 'sat.nominal': 'nominal',
|
||||||
|
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
|
||||||
|
'sec.satellite': 'Satellites',
|
||||||
|
'satset.hint': 'Où se trouve l’antenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans l’onglet Satellites.',
|
||||||
|
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'celui de la station',
|
||||||
|
'satset.gridHint': 'Laissez le locator vide pour utiliser celui de votre station. L’altitude est celle de l’antenne au-dessus du niveau de la mer — elle change l’horizon, donc le début et la fin d’un passage rasant.',
|
||||||
|
'satset.altM': 'Altitude (m)', 'satset.minEl': 'Passage minimal (°)', 'satset.windowH': 'Prévoir sur (heures)',
|
||||||
|
'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.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. C’est une machine distincte de votre rotor HF : avoir les deux est normal.',
|
||||||
|
'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.rotRange': 'Course du rotor', 'satset.rotMinEl': 'Démarrer au-dessus de (°)', 'satset.rotStep': 'Déplacer par (°)',
|
||||||
|
'satset.rotRangeHint': 'Un rotor 450° suit un passage à travers le nord sans se dérouler ; il est donc utilisé ainsi quand il le peut. « Démarrer au-dessus de » laisse le pylône tranquille tant que le satellite ne mérite pas d’être pointé ; « déplacer par » est le plus petit écart qui vaut une commande — gardez-le à l’intérieur de votre ouverture de faisceau.',
|
||||||
|
'satset.rotPark': 'Ranger au nord, élévation zéro, à l’arrêt du suivi',
|
||||||
|
'satset.rotTest': 'Tester le rotor', 'satset.rotTesting': 'interrogation du contrôleur…',
|
||||||
};
|
};
|
||||||
|
|
||||||
const dicts: Record<Lang, Dict> = { en, fr };
|
const dicts: Record<Lang, Dict> = { en, fr };
|
||||||
|
|||||||
Vendored
+2
@@ -1429,6 +1429,8 @@ export function TestQRZUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TestSatelliteRotator():Promise<string>;
|
||||||
|
|
||||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|||||||
@@ -2790,6 +2790,10 @@ export function TestRotatorDevice(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestSatelliteRotator() {
|
||||||
|
return window['go']['main']['App']['TestSatelliteRotator']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestStationDevice(arg1) {
|
export function TestStationDevice(arg1) {
|
||||||
return window['go']['main']['App']['TestStationDevice'](arg1);
|
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4082,6 +4082,16 @@ export namespace main {
|
|||||||
auto_tle: boolean;
|
auto_tle: boolean;
|
||||||
grid: string;
|
grid: string;
|
||||||
alt_m: number;
|
alt_m: number;
|
||||||
|
rot_on: boolean;
|
||||||
|
rot_transport: string;
|
||||||
|
rot_host: string;
|
||||||
|
rot_port: number;
|
||||||
|
rot_com: string;
|
||||||
|
rot_baud: number;
|
||||||
|
rot_max_az: number;
|
||||||
|
rot_min_el: number;
|
||||||
|
rot_step: number;
|
||||||
|
rot_park: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SatSettings(source);
|
return new SatSettings(source);
|
||||||
@@ -4095,6 +4105,16 @@ export namespace main {
|
|||||||
this.auto_tle = source["auto_tle"];
|
this.auto_tle = source["auto_tle"];
|
||||||
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_transport = source["rot_transport"];
|
||||||
|
this.rot_host = source["rot_host"];
|
||||||
|
this.rot_port = source["rot_port"];
|
||||||
|
this.rot_com = source["rot_com"];
|
||||||
|
this.rot_baud = source["rot_baud"];
|
||||||
|
this.rot_max_az = source["rot_max_az"];
|
||||||
|
this.rot_min_el = source["rot_min_el"];
|
||||||
|
this.rot_step = source["rot_step"];
|
||||||
|
this.rot_park = source["rot_park"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SatTLEInfo {
|
export class SatTLEInfo {
|
||||||
@@ -4150,6 +4170,10 @@ export namespace main {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
radio: string;
|
radio: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
rot_on: boolean;
|
||||||
|
rot_az: number;
|
||||||
|
rot_el: number;
|
||||||
|
rot_live: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SatTrackStatus(source);
|
return new SatTrackStatus(source);
|
||||||
@@ -4170,6 +4194,10 @@ export namespace main {
|
|||||||
this.visible = source["visible"];
|
this.visible = source["visible"];
|
||||||
this.radio = source["radio"];
|
this.radio = source["radio"];
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
|
this.rot_on = source["rot_on"];
|
||||||
|
this.rot_az = source["rot_az"];
|
||||||
|
this.rot_el = source["rot_el"];
|
||||||
|
this.rot_live = source["rot_live"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// Package easycomm drives azimuth/elevation rotator controllers that speak
|
||||||
|
// EasyComm II, over a raw TCP socket or a serial port.
|
||||||
|
//
|
||||||
|
// EasyComm is what satellite rotator controllers agreed on: SatPC32, Gpredict
|
||||||
|
// and Hamlib all speak it, so a controller that works with any of those works
|
||||||
|
// here. The dialect matters less than it looks — every command is a two-letter
|
||||||
|
// name with a number stuck to it, on one line, and a controller that does not
|
||||||
|
// recognise one ignores it.
|
||||||
|
//
|
||||||
|
// The subset used:
|
||||||
|
//
|
||||||
|
// AZ123.4 EL45.0<LF> point there
|
||||||
|
// AZ EL<LF> ask where it is — the reply is the same shape
|
||||||
|
// SA SE<LF> stop both axes
|
||||||
|
//
|
||||||
|
// Not every controller ANSWERS. A great many EasyComm boxes — the Arduino
|
||||||
|
// trackers above all — accept commands and never say a word back, which is
|
||||||
|
// perfectly legal in EasyComm I and common in II. So a silent controller is not
|
||||||
|
// treated as a broken one: the last commanded position is reported instead, and
|
||||||
|
// the rotator keeps being driven. Refusing to work with a write-only controller
|
||||||
|
// would rule out half the satellite stations in the hobby.
|
||||||
|
package easycomm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dialTimeout = 3 * time.Second
|
||||||
|
ioTimeout = 1500 * time.Millisecond
|
||||||
|
// replyWait is how long a query waits before deciding the controller is one
|
||||||
|
// of the silent ones. Short: this runs once a second inside a pass, and a
|
||||||
|
// controller that is going to answer answers in milliseconds.
|
||||||
|
replyWait = 400 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is one rotator controller. Exactly one of (Host, Port) or ComPort is
|
||||||
|
// used.
|
||||||
|
type Client struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
ComPort string
|
||||||
|
Baud int
|
||||||
|
// MaxAz is how far the rotator turns: 360 or 450. A 450° rotator can follow
|
||||||
|
// a pass straight through north without unwinding, which is the difference
|
||||||
|
// between hearing the whole of an overhead pass and losing the middle of it.
|
||||||
|
MaxAz int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// lastAz/lastEl are what was last commanded — the answer for a controller
|
||||||
|
// that does not talk back.
|
||||||
|
lastAz, lastEl float64
|
||||||
|
commanded bool
|
||||||
|
// silent latches once a query has gone unanswered. Without it, a write-only
|
||||||
|
// controller costs a 400 ms wait on every single poll of a pass.
|
||||||
|
silent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a TCP client. There is no standard port; 4533 is Hamlib's rotctld
|
||||||
|
// convention and the usual default in the controllers' own setup screens.
|
||||||
|
func New(host string, port int, maxAz int) *Client {
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = 4533
|
||||||
|
}
|
||||||
|
return &Client{Host: host, Port: port, MaxAz: normMaxAz(maxAz)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerial builds a serial client.
|
||||||
|
func NewSerial(comPort string, baud int, maxAz int) *Client {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600
|
||||||
|
}
|
||||||
|
return &Client{ComPort: comPort, Baud: baud, MaxAz: normMaxAz(maxAz)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normMaxAz(v int) int {
|
||||||
|
if v == 450 {
|
||||||
|
return 450
|
||||||
|
}
|
||||||
|
return 360
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point commands the rotator to an azimuth and elevation.
|
||||||
|
//
|
||||||
|
// The azimuth is given in the rotator's own terms: on a 450° machine an
|
||||||
|
// azimuth past 360 is a real, reachable position, and asking for 010 when the
|
||||||
|
// rotator is sitting at 370 would send it the long way round through the whole
|
||||||
|
// scale — three quarters of a turn, in the middle of a pass, with the antenna
|
||||||
|
// pointing at the ground for most of it.
|
||||||
|
func (c *Client) Point(az, el float64) error {
|
||||||
|
az = c.wrapAz(az)
|
||||||
|
el = clamp(el, 0, 180)
|
||||||
|
if err := c.send(fmt.Sprintf("AZ%.1f EL%.1f", az, el), false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastAz, c.lastEl, c.commanded = az, el, true
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts both axes.
|
||||||
|
func (c *Client) Stop() error { return c.send("SA SE", false) }
|
||||||
|
|
||||||
|
// Heading is where the rotator says it is.
|
||||||
|
//
|
||||||
|
// live is false when the answer is the last commanded position rather than a
|
||||||
|
// reading — the caller shows that differently, because "where I told it to go"
|
||||||
|
// and "where it is" are not the same claim and a stuck rotator must not be able
|
||||||
|
// to hide behind the first.
|
||||||
|
func (c *Client) Heading() (az, el float64, live bool, err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
silent, la, le, commanded := c.silent, c.lastAz, c.lastEl, c.commanded
|
||||||
|
c.mu.Unlock()
|
||||||
|
if silent {
|
||||||
|
if !commanded {
|
||||||
|
return 0, 0, false, fmt.Errorf("easycomm: the controller does not report its position")
|
||||||
|
}
|
||||||
|
return la, le, false, nil
|
||||||
|
}
|
||||||
|
line, err := c.query("AZ EL")
|
||||||
|
if err != nil {
|
||||||
|
// One silence is enough: a controller either answers or it does not, and
|
||||||
|
// this runs every second for the length of a pass.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.silent = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
if commanded {
|
||||||
|
return la, le, false, nil
|
||||||
|
}
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
a, e, ok := parseHeading(line)
|
||||||
|
if !ok {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.silent = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
if commanded {
|
||||||
|
return la, le, false, nil
|
||||||
|
}
|
||||||
|
return 0, 0, false, fmt.Errorf("easycomm: could not read %q", line)
|
||||||
|
}
|
||||||
|
return a, e, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapAz brings an azimuth into what this rotator can reach.
|
||||||
|
//
|
||||||
|
// On a 360° machine that is a plain modulo. On a 450° one the extra 90° is an
|
||||||
|
// OVERLAP — 370 and 10 are the same direction — and which of the two to use is
|
||||||
|
// decided by whichever is nearer where the rotator already is, so a pass
|
||||||
|
// crossing north continues instead of unwinding.
|
||||||
|
func (c *Client) wrapAz(az float64) float64 {
|
||||||
|
az = math.Mod(az, 360)
|
||||||
|
if az < 0 {
|
||||||
|
az += 360
|
||||||
|
}
|
||||||
|
if c.MaxAz != 450 {
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
cur, known := c.lastAz, c.commanded
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !known {
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
alt := az + 360
|
||||||
|
if alt > 450 {
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
if math.Abs(alt-cur) < math.Abs(az-cur) {
|
||||||
|
return alt
|
||||||
|
}
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transport ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type heldPort struct {
|
||||||
|
p serial.Port
|
||||||
|
openedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
portsMu sync.Mutex
|
||||||
|
openPorts = map[string]*heldPort{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootSettle: an Arduino-based controller resets when its serial port is
|
||||||
|
// opened, and its bootloader then holds the processor for a second or more. A
|
||||||
|
// command sent into that window is simply lost — which is how a controller that
|
||||||
|
// answers a terminal perfectly reports nothing here.
|
||||||
|
const bootSettle = 2 * time.Second
|
||||||
|
|
||||||
|
func acquire(com string, baud int) (*heldPort, error) {
|
||||||
|
portsMu.Lock()
|
||||||
|
defer portsMu.Unlock()
|
||||||
|
if h, ok := openPorts[com]; ok && h.p != nil {
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600
|
||||||
|
}
|
||||||
|
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
|
||||||
|
}
|
||||||
|
_ = sp.SetReadTimeout(150 * time.Millisecond)
|
||||||
|
h := &heldPort{p: sp, openedAt: time.Now()}
|
||||||
|
openPorts[com] = h
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drop(com string) {
|
||||||
|
portsMu.Lock()
|
||||||
|
defer portsMu.Unlock()
|
||||||
|
if h, ok := openPorts[com]; ok {
|
||||||
|
if h.p != nil {
|
||||||
|
_ = h.p.Close()
|
||||||
|
}
|
||||||
|
delete(openPorts, com)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the serial port. TCP dials per command and holds nothing.
|
||||||
|
func (c *Client) Close() {
|
||||||
|
if c.ComPort != "" {
|
||||||
|
drop(c.ComPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(cmd string, wantReply bool) error {
|
||||||
|
_, err := c.exchange(cmd, wantReply)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) query(cmd string) (string, error) { return c.exchange(cmd, true) }
|
||||||
|
|
||||||
|
func (c *Client) exchange(cmd string, wantReply bool) (string, error) {
|
||||||
|
var conn io.ReadWriteCloser
|
||||||
|
if c.ComPort != "" {
|
||||||
|
h, err := acquire(c.ComPort, c.Baud)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
conn = h.p
|
||||||
|
drain(h.p)
|
||||||
|
} else {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connect rotator %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
defer nc.Close()
|
||||||
|
conn = nc
|
||||||
|
}
|
||||||
|
// LF, not CR: EasyComm's own documents use a line feed, and the controllers
|
||||||
|
// that want CR accept either. The reverse is not true of every Arduino
|
||||||
|
// sketch out there.
|
||||||
|
if _, err := conn.Write([]byte(cmd + "\n")); err != nil {
|
||||||
|
if c.ComPort != "" {
|
||||||
|
drop(c.ComPort)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
if !wantReply {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
var sb strings.Builder
|
||||||
|
deadline := time.Now().Add(replyWait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if strings.ContainsAny(sb.String(), "\r\n") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(sb.String())
|
||||||
|
if line == "" {
|
||||||
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drain(sp serial.Port) {
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
n, err := sp.Read(buf)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeading reads a controller's answer.
|
||||||
|
//
|
||||||
|
// The shapes in the wild differ more than the specification suggests —
|
||||||
|
// "AZ123.4 EL45.0", "AZ=123.4 EL=45.0", "+123.4+045.0", lower case, tabs — so
|
||||||
|
// this looks for the two labels and takes the number attached to each rather
|
||||||
|
// than trying to match a whole line.
|
||||||
|
func parseHeading(line string) (az, el float64, ok bool) {
|
||||||
|
up := strings.ToUpper(line)
|
||||||
|
az, aok := numberAfter(up, "AZ")
|
||||||
|
el, eok := numberAfter(up, "EL")
|
||||||
|
if !aok {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
// Elevation missing is not a broken reply: an azimuth-only controller
|
||||||
|
// answering an AZ EL query says what it has.
|
||||||
|
if !eok {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
return az, el, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberAfter(s, label string) (float64, bool) {
|
||||||
|
i := strings.Index(s, label)
|
||||||
|
if i < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
rest := strings.TrimLeft(s[i+len(label):], " \t=:")
|
||||||
|
end := 0
|
||||||
|
for end < len(rest) {
|
||||||
|
ch := rest[end]
|
||||||
|
if (ch >= '0' && ch <= '9') || ch == '.' || ((ch == '-' || ch == '+') && end == 0) {
|
||||||
|
end++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if end == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSuffix(rest[:end], "."), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, lo, hi float64) float64 {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package easycomm
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Every one of these is a shape a real controller has been seen to answer with.
|
||||||
|
// The point of the parser is that none of them is special-cased.
|
||||||
|
func TestParseHeading(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
line string
|
||||||
|
az, el float64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"AZ123.4 EL45.0", 123.4, 45, true},
|
||||||
|
{"AZ=123.4 EL=45.0", 123.4, 45, true},
|
||||||
|
{"az 123.4 el 45.0", 123.4, 45, true},
|
||||||
|
{"AZ123.4\tEL45.0\r\n", 123.4, 45, true},
|
||||||
|
{"AZ012.0 EL000.0", 12, 0, true},
|
||||||
|
{"AZ370.5 EL05.5", 370.5, 5.5, true},
|
||||||
|
{"AZ123.4", 123.4, 0, true}, // azimuth-only controller
|
||||||
|
{"RPRT 0", 0, 0, false},
|
||||||
|
{"", 0, 0, false},
|
||||||
|
} {
|
||||||
|
az, el, ok := parseHeading(tc.line)
|
||||||
|
if ok != tc.ok {
|
||||||
|
t.Errorf("%q: ok=%v, wanted %v", tc.line, ok, tc.ok)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok && (az != tc.az || el != tc.el) {
|
||||||
|
t.Errorf("%q: got %.1f/%.1f, wanted %.1f/%.1f", tc.line, az, el, tc.az, tc.el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 450° overlap is the whole reason a satellite rotator is worth having: a
|
||||||
|
// pass crossing north must continue past 360 instead of unwinding through the
|
||||||
|
// entire scale with the antenna sweeping the ground.
|
||||||
|
func TestWrapAz450(t *testing.T) {
|
||||||
|
c := &Client{MaxAz: 450}
|
||||||
|
// Nothing commanded yet: no history to be near, so the plain bearing.
|
||||||
|
if got := c.wrapAz(10); got != 10 {
|
||||||
|
t.Errorf("first move: got %.1f, wanted 10", got)
|
||||||
|
}
|
||||||
|
c.lastAz, c.commanded = 350, true
|
||||||
|
// Crossing north: 370 is 20° away, 10 is 340° away.
|
||||||
|
if got := c.wrapAz(10); got != 370 {
|
||||||
|
t.Errorf("crossing north from 350: got %.1f, wanted 370", got)
|
||||||
|
}
|
||||||
|
// Coming back down the same way, the overlap stays the near answer.
|
||||||
|
c.lastAz = 370
|
||||||
|
if got := c.wrapAz(350); got != 350 {
|
||||||
|
t.Errorf("back from 370: got %.1f, wanted 350", got)
|
||||||
|
}
|
||||||
|
// Beyond the rotator's reach there is no overlap to use.
|
||||||
|
c.lastAz = 440
|
||||||
|
if got := c.wrapAz(100); got != 100 {
|
||||||
|
t.Errorf("past the end of the scale: got %.1f, wanted 100", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapAz360(t *testing.T) {
|
||||||
|
c := &Client{MaxAz: 360}
|
||||||
|
c.lastAz, c.commanded = 350, true
|
||||||
|
if got := c.wrapAz(10); got != 10 {
|
||||||
|
t.Errorf("a 360 rotator has no overlap: got %.1f, wanted 10", got)
|
||||||
|
}
|
||||||
|
if got := c.wrapAz(-10); got != 350 {
|
||||||
|
t.Errorf("negative bearing: got %.1f, wanted 350", got)
|
||||||
|
}
|
||||||
|
if got := c.wrapAz(725); got != 5 {
|
||||||
|
t.Errorf("two turns and five degrees: got %.1f, wanted 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller that never answers must not be treated as a broken one: the last
|
||||||
|
// commanded position is reported, marked as not live.
|
||||||
|
func TestSilentControllerReportsCommanded(t *testing.T) {
|
||||||
|
c := &Client{MaxAz: 360, silent: true}
|
||||||
|
if _, _, _, err := c.Heading(); err == nil {
|
||||||
|
t.Error("a silent controller with nothing commanded should say it cannot report")
|
||||||
|
}
|
||||||
|
c.lastAz, c.lastEl, c.commanded = 120, 30, true
|
||||||
|
az, el, live, err := c.Heading()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("after a command: %v", err)
|
||||||
|
}
|
||||||
|
if live {
|
||||||
|
t.Error("a commanded position must not be reported as a live reading")
|
||||||
|
}
|
||||||
|
if az != 120 || el != 30 {
|
||||||
|
t.Errorf("got %.1f/%.1f, wanted 120/30", az, el)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user