feat(rotator): one list of rotator interfaces, and ERC-M
The satellite page configured its own EasyComm or PstRotator link while five other backends were configured in the rotator list. An operator with one az/el mast therefore described it twice, and could describe it differently the second time — a station that works on HF and not on a pass, for no reason visible anywhere on screen. Now every interface lives in Settings ▸ Rotator, once, and the satellite page stores only a KEY into that list plus the tracking policy that is genuinely its own (minimum elevation, step, park). The key and not the index: deleting the first rotor must not silently point the tracker at a different mast. migrateSatRotator() turns an existing satellite link into a real entry in the list, selects it, and clears the old keys so it cannot run twice. Which rotors have an elevation axis is now a question with one answer, in Go: rotatorTypes plus rotorHasElevation, exposed to the panel by GetRotatorTypes. The dropdown, the labels, each backend's default port and default baud all come from there, so TypeScript no longer keeps a second copy of the same knowledge to drift out of step. Three cases do not follow from the type alone and are treated as such: PstRotator forwards elevation to a mast that may not have any, so the operator says; a SPID's dialect decides (Rot1Prog has no elevation in its reply format); and an ARCO and an ERC-M speak the same GS-232 while only one of them lifts. Each interface carries an Az / Az+El badge beside it. The satellite rotor dropdown LISTS the azimuth-only ones, disabled, rather than hiding them: an operator who owns one rotator and does not see it concludes OpsLog cannot find it, where a greyed row saying "azimuth only" teaches the actual thing. ERC-M by DF9GR is new — the az/el interface for a Yaesu G-5500. It emulates GS-232, so internal/rotator/gs232 grew the elevation half: W for a two-axis move, C2 to read both, falling back to C+B for the firmware that answers C2 with the azimuth alone. That fallback is the point of the parser tests: reading such a reply as "elevation zero" would put the antenna on the horizon, which is the one wrong answer that looks plausible. EasyComm II is promoted to an ordinary rotator interface, so it can also turn the antenna from the compass and from a spot click. The ERC-M is UNTESTED on hardware. Its Test button reads BOTH axes rather than just the azimuth, so a controller wired for azimuth alone says so there instead of during a pass. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -63,6 +63,7 @@ import (
|
|||||||
"hamlog/internal/relaydev"
|
"hamlog/internal/relaydev"
|
||||||
"hamlog/internal/rigctld"
|
"hamlog/internal/rigctld"
|
||||||
"hamlog/internal/rotator/dcu1"
|
"hamlog/internal/rotator/dcu1"
|
||||||
|
"hamlog/internal/rotator/easycomm"
|
||||||
"hamlog/internal/rotator/gs232"
|
"hamlog/internal/rotator/gs232"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/rotator/spid"
|
"hamlog/internal/rotator/spid"
|
||||||
@@ -16957,9 +16958,11 @@ const keyRotatorsList = "rotators.json"
|
|||||||
type RotatorDevice struct {
|
type RotatorDevice struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (GS-232A)
|
// Type is the backend. See rotatorTypes for the list and what each one can
|
||||||
|
// do; normRotorType clamps anything unknown to "pst".
|
||||||
|
Type string `json:"type"`
|
||||||
Host string `json:"host"` // default 127.0.0.1
|
Host string `json:"host"` // default 127.0.0.1
|
||||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco)
|
Port int `json:"port"` // per-backend default, see rotatorDefaultPort
|
||||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||||
RotatorNum int `json:"rotator_num"` // Rotator Genius internal index (1/2) when not Dual
|
RotatorNum int `json:"rotator_num"` // Rotator Genius internal index (1/2) when not Dual
|
||||||
Dual bool `json:"dual"` // Rotator Genius: drive both ports → two logical rotors
|
Dual bool `json:"dual"` // Rotator Genius: drive both ports → two logical rotors
|
||||||
@@ -16973,36 +16976,125 @@ type RotatorDevice struct {
|
|||||||
// azimuth + elevation) or "rot1prog" (the older azimuth-only controller).
|
// azimuth + elevation) or "rot1prog" (the older azimuth-only controller).
|
||||||
// They differ in reply length and baud rate, so guessing is not an option.
|
// They differ in reply length and baud rate, so guessing is not an option.
|
||||||
SpidModel string `json:"spid_model,omitempty"`
|
SpidModel string `json:"spid_model,omitempty"`
|
||||||
|
// MaxAz is the azimuth range of the mast: 360 or 450. It matters only when
|
||||||
|
// OpsLog drives the controller itself — an overlap rotator reached at 350°
|
||||||
|
// through 10° unwinds the cable, and the choice between going the short way
|
||||||
|
// and the long way is ours to make. Through PstRotator it is deliberately
|
||||||
|
// ignored: PstRotator knows which controller is on the other end and does
|
||||||
|
// its own overlap, and two programs each deciding to go the long way round
|
||||||
|
// is how an antenna unwinds in the middle of a satellite pass.
|
||||||
|
MaxAz int `json:"max_az,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotatorTypes is the one place that says what each backend is and what it can
|
||||||
|
// do. The settings panel renders its dropdown from this — labels, the "Az + El"
|
||||||
|
// badge, which transports to offer — instead of keeping a second, drifting copy
|
||||||
|
// of the same knowledge in TypeScript.
|
||||||
|
//
|
||||||
|
// Elevation here means the backend has an elevation AXIS, which is what the
|
||||||
|
// satellite tracker needs. It is not the same question as whether a given
|
||||||
|
// station's mast has an elevation motor: a PstRotator setup answers "it
|
||||||
|
// depends", which is why pst carries the per-device HasElevation switch and is
|
||||||
|
// the only type whose capability is decided by rotorHasElevation rather than by
|
||||||
|
// this table.
|
||||||
|
var rotatorTypes = []RotatorTypeInfo{
|
||||||
|
{ID: "pst", Label: "PstRotator (UDP)", Elevation: false, Optional: true, Network: true, DefaultPort: 12000},
|
||||||
|
{ID: "rotgenius", Label: "Rotator Genius (4O3A, native)", Network: true, DefaultPort: 9006},
|
||||||
|
{ID: "arco", Label: "GS-232 azimuth controller (microHAM ARCO, ERC)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 9600},
|
||||||
|
{ID: "erc", Label: "ERC-M by DF9GR (Yaesu G-5500 az/el)", Elevation: true, Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 19200},
|
||||||
|
{ID: "dcu1", Label: "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 4800},
|
||||||
|
{ID: "spid", Label: "SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)", Elevation: true, Serial: true, DefaultBaud: 600},
|
||||||
|
{ID: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotatorTypeInfo describes one rotator backend to the settings panel.
|
||||||
|
type RotatorTypeInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
// Elevation: this backend drives an elevation axis, so a satellite pass can
|
||||||
|
// be followed with it.
|
||||||
|
Elevation bool `json:"elevation"`
|
||||||
|
// Optional: the elevation axis depends on the station rather than on the
|
||||||
|
// backend, and the operator says so per device (PstRotator).
|
||||||
|
Optional bool `json:"elevation_optional"`
|
||||||
|
Serial bool `json:"serial"`
|
||||||
|
Network bool `json:"network"`
|
||||||
|
DefaultPort int `json:"default_port"`
|
||||||
|
DefaultBaud int `json:"default_baud"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRotatorTypes lists the rotator backends for the settings panel.
|
||||||
|
func (a *App) GetRotatorTypes() []RotatorTypeInfo {
|
||||||
|
out := make([]RotatorTypeInfo, len(rotatorTypes))
|
||||||
|
copy(out, rotatorTypes)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotorTypeInfo looks a backend up, falling back to PstRotator like
|
||||||
|
// normRotorType does.
|
||||||
|
func rotorTypeInfo(typ string) RotatorTypeInfo {
|
||||||
|
typ = normRotorType(typ)
|
||||||
|
for _, t := range rotatorTypes {
|
||||||
|
if t.ID == typ {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rotatorTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotorHasElevation reports whether this configured rotor can be pointed in
|
||||||
|
// elevation — the question the satellite tracker asks before offering a rotor.
|
||||||
|
func rotorHasElevation(d RotatorDevice) bool {
|
||||||
|
t := rotorTypeInfo(d.Type)
|
||||||
|
if t.Optional {
|
||||||
|
// PstRotator: the elevation is the station's, not the protocol's.
|
||||||
|
return d.HasElevation
|
||||||
|
}
|
||||||
|
if t.ID == "spid" {
|
||||||
|
// Rot1Prog is the azimuth-only controller. Offering it for a satellite
|
||||||
|
// pass would mean sending elevation commands into a reply format that
|
||||||
|
// has no room for them.
|
||||||
|
return d.SpidModel != "rot1prog"
|
||||||
|
}
|
||||||
|
return t.Elevation
|
||||||
}
|
}
|
||||||
|
|
||||||
// logicalRotor is one addressable rotor. Flattening the device list expands a
|
// logicalRotor is one addressable rotor. Flattening the device list expands a
|
||||||
// Dual Rotator Genius into two.
|
// Dual Rotator Genius into two.
|
||||||
type logicalRotor struct {
|
type logicalRotor struct {
|
||||||
|
// Key addresses this rotor from elsewhere in the app — the satellite
|
||||||
|
// tracker stores one. It is the device id, with "#2" for the second port of
|
||||||
|
// a Dual Rotator Genius, and NOT the list index: an operator who deletes the
|
||||||
|
// first rotor must not silently have the satellite follow a different mast.
|
||||||
|
Key string
|
||||||
Name string
|
Name string
|
||||||
Motorized bool
|
Motorized bool
|
||||||
|
HasEl bool
|
||||||
Link rotorLink
|
Link rotorLink
|
||||||
}
|
}
|
||||||
|
|
||||||
// normRotorType clamps a rotor type to a known backend.
|
// normRotorType clamps a rotor type to a known backend.
|
||||||
func normRotorType(t string) string {
|
func normRotorType(t string) string {
|
||||||
if t == "rotgenius" || t == "arco" || t == "dcu1" || t == "spid" {
|
for _, k := range rotatorTypes {
|
||||||
|
if k.ID == t {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return "pst"
|
return "pst"
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotatorDefaultPort is each backend's default network port.
|
// rotatorDefaultPort is each backend's default network port.
|
||||||
|
//
|
||||||
|
// Two of them are placeholders rather than standards, and the difference
|
||||||
|
// matters when a link fails: 4001 for a GS-232 or DCU-1 controller is whatever
|
||||||
|
// the operator typed into its own LAN menu (or into the serial-over-IP bridge),
|
||||||
|
// so a refused connection there means "that is not the number you set", not
|
||||||
|
// "the controller is off".
|
||||||
func rotatorDefaultPort(typ string) int {
|
func rotatorDefaultPort(typ string) int {
|
||||||
switch typ {
|
if p := rotorTypeInfo(typ).DefaultPort; p > 0 {
|
||||||
case "rotgenius":
|
return p
|
||||||
return 9006 // 4O3A native default
|
|
||||||
case "arco":
|
|
||||||
return 4001 // placeholder — the real number is set in ARCO's LAN menu
|
|
||||||
case "dcu1":
|
|
||||||
return 4001 // only used with a serial-over-IP bridge; DCU-1 has no standard
|
|
||||||
default:
|
|
||||||
return 12000 // PstRotator UDP
|
|
||||||
}
|
}
|
||||||
|
return 12000 // PstRotator UDP
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceLink builds the connection params for a device's rotor. sub selects the
|
// deviceLink builds the connection params for a device's rotor. sub selects the
|
||||||
@@ -17010,8 +17102,11 @@ func rotatorDefaultPort(typ string) int {
|
|||||||
func deviceLink(d RotatorDevice, sub int) rotorLink {
|
func deviceLink(d RotatorDevice, sub int) rotorLink {
|
||||||
l := rotorLink{
|
l := rotorLink{
|
||||||
Type: normRotorType(d.Type), Host: d.Host, Port: d.Port,
|
Type: normRotorType(d.Type), Host: d.Host, Port: d.Port,
|
||||||
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: d.HasElevation,
|
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: rotorHasElevation(d),
|
||||||
SpidModel: d.SpidModel,
|
SpidModel: d.SpidModel, MaxAz: d.MaxAz,
|
||||||
|
}
|
||||||
|
if l.MaxAz != 450 {
|
||||||
|
l.MaxAz = 360
|
||||||
}
|
}
|
||||||
if l.Host == "" {
|
if l.Host == "" {
|
||||||
l.Host = "127.0.0.1"
|
l.Host = "127.0.0.1"
|
||||||
@@ -17050,18 +17145,39 @@ func deviceLink(d RotatorDevice, sub int) rotorLink {
|
|||||||
func flattenRotors(devs []RotatorDevice) []logicalRotor {
|
func flattenRotors(devs []RotatorDevice) []logicalRotor {
|
||||||
var out []logicalRotor
|
var out []logicalRotor
|
||||||
for _, d := range devs {
|
for _, d := range devs {
|
||||||
|
el := rotorHasElevation(d)
|
||||||
if normRotorType(d.Type) == "rotgenius" && d.Dual {
|
if normRotorType(d.Type) == "rotgenius" && d.Dual {
|
||||||
out = append(out,
|
out = append(out,
|
||||||
logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)},
|
logicalRotor{Key: d.ID, Name: d.Name, Motorized: d.Motorized, HasEl: el, Link: deviceLink(d, 1)},
|
||||||
logicalRotor{Name: d.Name2, Motorized: d.Motorized2, Link: deviceLink(d, 2)},
|
logicalRotor{Key: d.ID + "#2", Name: d.Name2, Motorized: d.Motorized2, HasEl: el, Link: deviceLink(d, 2)},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)})
|
out = append(out, logicalRotor{Key: d.ID, Name: d.Name, Motorized: d.Motorized, HasEl: el, Link: deviceLink(d, 1)})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rotorByKey finds a logical rotor by the key flattenRotors gave it. Used by
|
||||||
|
// anything that stores a choice of rotor rather than driving the active one —
|
||||||
|
// the satellite tracker, so far.
|
||||||
|
func (a *App) rotorByKey(key string) (logicalRotor, bool) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return logicalRotor{}, false
|
||||||
|
}
|
||||||
|
devs, err := a.GetRotators()
|
||||||
|
if err != nil {
|
||||||
|
return logicalRotor{}, false
|
||||||
|
}
|
||||||
|
for _, r := range flattenRotors(devs) {
|
||||||
|
if r.Key == key {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logicalRotor{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// GetRotators returns the configured rotor list, migrating the legacy single-
|
// GetRotators returns the configured rotor list, migrating the legacy single-
|
||||||
// rotor flat settings into the list on first read (persisted on next save).
|
// rotor flat settings into the list on first read (persisted on next save).
|
||||||
func (a *App) GetRotators() ([]RotatorDevice, error) {
|
func (a *App) GetRotators() ([]RotatorDevice, error) {
|
||||||
@@ -17139,10 +17255,19 @@ func (a *App) SaveRotators(list []RotatorDevice) error {
|
|||||||
if d.Transport != "serial" {
|
if d.Transport != "serial" {
|
||||||
d.Transport = "tcp"
|
d.Transport = "tcp"
|
||||||
}
|
}
|
||||||
|
if d.Baud <= 0 {
|
||||||
|
// Per backend, not a blanket 9600: a SPID at 9600 is silent (it runs
|
||||||
|
// at 600 or 1200), and an ERC-M ships at 19200. A wrong baud rate
|
||||||
|
// reads exactly like a dead controller.
|
||||||
|
d.Baud = rotorTypeInfo(d.Type).DefaultBaud
|
||||||
if d.Baud <= 0 {
|
if d.Baud <= 0 {
|
||||||
d.Baud = 9600
|
d.Baud = 9600
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if d.MaxAz != 450 {
|
||||||
|
d.MaxAz = 360
|
||||||
|
}
|
||||||
|
}
|
||||||
b, err := json.Marshal(list)
|
b, err := json.Marshal(list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -17162,6 +17287,7 @@ type rotorLink struct {
|
|||||||
Baud int
|
Baud int
|
||||||
HasElevation bool
|
HasElevation bool
|
||||||
SpidModel string // SPID: "rot2prog" (default) | "rot1prog"
|
SpidModel string // SPID: "rot2prog" (default) | "rot1prog"
|
||||||
|
MaxAz int // 360 or 450, for the backends OpsLog drives directly
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeRotorIndex returns the compass-selected rotor index, clamped to the
|
// activeRotorIndex returns the compass-selected rotor index, clamped to the
|
||||||
@@ -17199,6 +17325,34 @@ func arcoClient(l rotorLink) *gs232.Client {
|
|||||||
return gs232.New(l.Host, l.Port)
|
return gs232.New(l.Host, l.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ercClient builds the GS-232 client for an ERC-M (Easy Rotor Control, DF9GR).
|
||||||
|
//
|
||||||
|
// Same wire protocol as the ARCO above, and a separate rotor type all the same:
|
||||||
|
// the ERC-M drives BOTH axes of a Yaesu G-5500, and "does this rotor have an
|
||||||
|
// elevation motor" is the question the satellite tracker asks. Folding it into
|
||||||
|
// "arco" would have made every ARCO owner appear in the satellite rotor list
|
||||||
|
// with an elevation axis they do not have.
|
||||||
|
func ercClient(l rotorLink) *gs232.Client {
|
||||||
|
if l.Transport == "serial" {
|
||||||
|
return gs232.NewSerial(l.ComPort, l.Baud)
|
||||||
|
}
|
||||||
|
return gs232.New(l.Host, l.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// easycommClient builds the EasyComm II client for a rotor.
|
||||||
|
//
|
||||||
|
// EasyComm is what SatPC32, Gpredict and K3NG's firmware speak, so it is the
|
||||||
|
// common tongue of home-built az/el controllers. It lives in the rotator list
|
||||||
|
// like every other backend now: it used to be configured inside the satellite
|
||||||
|
// settings, which meant an operator with one mast described it twice and could
|
||||||
|
// describe it differently the second time.
|
||||||
|
func easycommClient(l rotorLink) *easycomm.Client {
|
||||||
|
if l.Transport == "serial" {
|
||||||
|
return easycomm.NewSerial(l.ComPort, l.Baud, l.MaxAz)
|
||||||
|
}
|
||||||
|
return easycomm.New(l.Host, l.Port, l.MaxAz)
|
||||||
|
}
|
||||||
|
|
||||||
// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
|
// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
|
||||||
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
|
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
|
||||||
// or a serial-over-IP bridge on TCP.
|
// or a serial-over-IP bridge on TCP.
|
||||||
@@ -17231,6 +17385,11 @@ type RotatorHeading struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Azimuth int `json:"azimuth"`
|
Azimuth int `json:"azimuth"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
// Elevation is only meaningful when HasElevation is set. The two travel
|
||||||
|
// together so an az-only rotor cannot be drawn pointing at the horizon,
|
||||||
|
// which is a real elevation and not the absence of one.
|
||||||
|
Elevation int `json:"elevation"`
|
||||||
|
HasElevation bool `json:"has_elevation"`
|
||||||
// The compass renders a rotor selector from these — one entry per logical
|
// The compass renders a rotor selector from these — one entry per logical
|
||||||
// rotor — without an extra roundtrip.
|
// rotor — without an extra roundtrip.
|
||||||
Rotors []string `json:"rotors"` // names of every logical rotor (may be empty strings)
|
Rotors []string `json:"rotors"` // names of every logical rotor (may be empty strings)
|
||||||
@@ -17291,8 +17450,35 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
|||||||
base.Azimuth = az
|
base.Azimuth = az
|
||||||
base.Raw = raw
|
base.Raw = raw
|
||||||
return base
|
return base
|
||||||
|
case "erc":
|
||||||
|
az, el, raw, herr := ercClient(link).Position()
|
||||||
|
if herr != nil {
|
||||||
|
base.Raw = herr.Error()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base.OK = true
|
||||||
|
base.Azimuth, base.Elevation, base.HasElevation = az, el, true
|
||||||
|
base.Raw = raw
|
||||||
|
return base
|
||||||
|
case "easycomm":
|
||||||
|
az, el, live, herr := easycommClient(link).Heading()
|
||||||
|
if herr != nil {
|
||||||
|
base.Raw = herr.Error()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base.OK = true
|
||||||
|
base.Azimuth, base.Elevation, base.HasElevation = int(math.Round(az)), int(math.Round(el)), true
|
||||||
|
if live {
|
||||||
|
base.Raw = fmt.Sprintf("AZ %.0f° EL %.0f°", az, el)
|
||||||
|
} else {
|
||||||
|
// The controller answered nothing and this is the last COMMANDED
|
||||||
|
// position. Say so: a stuck rotator must not be able to hide behind
|
||||||
|
// an order it never carried out.
|
||||||
|
base.Raw = fmt.Sprintf("AZ %.0f° EL %.0f° (commanded)", az, el)
|
||||||
|
}
|
||||||
|
return base
|
||||||
case "spid":
|
case "spid":
|
||||||
az, _, herr := spidClient(link).Heading()
|
az, el, herr := spidClient(link).Heading()
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
base.Raw = herr.Error()
|
base.Raw = herr.Error()
|
||||||
return base
|
return base
|
||||||
@@ -17300,6 +17486,10 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
|||||||
base.OK = true
|
base.OK = true
|
||||||
base.Azimuth = az
|
base.Azimuth = az
|
||||||
base.Raw = fmt.Sprintf("%d°", az)
|
base.Raw = fmt.Sprintf("%d°", az)
|
||||||
|
if link.HasElevation {
|
||||||
|
base.Elevation, base.HasElevation = el, true
|
||||||
|
base.Raw = fmt.Sprintf("AZ %d° EL %d°", az, el)
|
||||||
|
}
|
||||||
return base
|
return base
|
||||||
case "dcu1":
|
case "dcu1":
|
||||||
az, raw, herr := dcu1Client(link).Heading()
|
az, raw, herr := dcu1Client(link).Heading()
|
||||||
@@ -17351,6 +17541,23 @@ func (a *App) RotatorGoToPath(az int, el int, path string) error {
|
|||||||
return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
|
return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
|
||||||
case "arco":
|
case "arco":
|
||||||
return arcoClient(link).GoTo(az)
|
return arcoClient(link).GoTo(az)
|
||||||
|
case "erc":
|
||||||
|
// An elevation of -1 is the callers' "no opinion" (a spot click, a
|
||||||
|
// compass drag). Leaving the elevation where it is beats swinging the
|
||||||
|
// dish to the horizon because somebody clicked a DX spot.
|
||||||
|
if el < 0 {
|
||||||
|
return ercClient(link).GoTo(az)
|
||||||
|
}
|
||||||
|
return ercClient(link).GoToAzEl(az, el)
|
||||||
|
case "easycomm":
|
||||||
|
if el < 0 {
|
||||||
|
if _, cur, _, err := easycommClient(link).Heading(); err == nil {
|
||||||
|
el = int(math.Round(cur))
|
||||||
|
} else {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return easycommClient(link).Point(float64(az), float64(el))
|
||||||
case "spid":
|
case "spid":
|
||||||
return spidClient(link).GoTo(az, el)
|
return spidClient(link).GoTo(az, el)
|
||||||
case "dcu1":
|
case "dcu1":
|
||||||
@@ -17372,6 +17579,10 @@ func (a *App) RotatorStop() error {
|
|||||||
return rotgenius.New(link.Host, link.Port).Stop()
|
return rotgenius.New(link.Host, link.Port).Stop()
|
||||||
case "arco":
|
case "arco":
|
||||||
return arcoClient(link).Stop()
|
return arcoClient(link).Stop()
|
||||||
|
case "erc":
|
||||||
|
return ercClient(link).Stop()
|
||||||
|
case "easycomm":
|
||||||
|
return easycommClient(link).Stop()
|
||||||
case "spid":
|
case "spid":
|
||||||
return spidClient(link).Stop()
|
return spidClient(link).Stop()
|
||||||
case "dcu1":
|
case "dcu1":
|
||||||
@@ -17501,8 +17712,12 @@ func (a *App) RotatorPark() error {
|
|||||||
switch link.Type {
|
switch link.Type {
|
||||||
case "rotgenius":
|
case "rotgenius":
|
||||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||||
case "arco":
|
case "arco", "erc":
|
||||||
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
|
return fmt.Errorf("park is a PstRotator feature; not available over a GS-232 link")
|
||||||
|
case "easycomm":
|
||||||
|
// EasyComm has no park command either, but it does take an absolute
|
||||||
|
// position — and the satellite tracker's own park does exactly this.
|
||||||
|
return easycommClient(link).Point(0, 0)
|
||||||
case "spid":
|
case "spid":
|
||||||
return fmt.Errorf("park is a PstRotator feature; a SPID controller has no park command")
|
return fmt.Errorf("park is a PstRotator feature; a SPID controller has no park command")
|
||||||
case "dcu1":
|
case "dcu1":
|
||||||
@@ -17545,6 +17760,21 @@ func testRotorLink(l rotorLink) error {
|
|||||||
// GS-232 — without moving the antenna.
|
// GS-232 — without moving the antenna.
|
||||||
_, _, err := arcoClient(l).Heading()
|
_, _, err := arcoClient(l).Heading()
|
||||||
return err
|
return err
|
||||||
|
case "erc":
|
||||||
|
if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
|
||||||
|
return fmt.Errorf("select the ERC-M's COM port first")
|
||||||
|
}
|
||||||
|
// Both axes, because reading only the azimuth would pass on a controller
|
||||||
|
// wired for azimuth alone — and the whole reason for choosing ERC-M over
|
||||||
|
// the plain GS-232 entry is that it has an elevation motor.
|
||||||
|
_, _, _, err := ercClient(l).Position()
|
||||||
|
return err
|
||||||
|
case "easycomm":
|
||||||
|
if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
|
||||||
|
return fmt.Errorf("select the controller's COM port first")
|
||||||
|
}
|
||||||
|
_, _, _, err := easycommClient(l).Heading()
|
||||||
|
return err
|
||||||
case "spid":
|
case "spid":
|
||||||
if strings.TrimSpace(l.ComPort) == "" {
|
if strings.TrimSpace(l.ComPort) == "" {
|
||||||
return fmt.Errorf("select the SPID controller's COM port first")
|
return fmt.Errorf("select the SPID controller's COM port first")
|
||||||
|
|||||||
+112
-74
@@ -34,15 +34,20 @@ const (
|
|||||||
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
|
// The az/el rotator.
|
||||||
// satellite station's elevation rotator is a different machine on a
|
|
||||||
// different port, and an operator who has both must not have to choose.
|
|
||||||
keySatRotOn = "sat.rot_enabled"
|
keySatRotOn = "sat.rot_enabled"
|
||||||
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
|
// WHICH rotor, out of the ones configured in Settings ▸ Rotator — the key
|
||||||
// which many stations already run in front of their controller. Its own port
|
// flattenRotors gives it. How to reach it is that list's business, not
|
||||||
// key because it is a different program on a different port from an EasyComm
|
// this page's: describing one mast in two places is how a station ends up
|
||||||
// controller, and an operator who tries both must not lose the first setting
|
// working on HF and not on a pass.
|
||||||
// to the second.
|
keySatRotID = "sat.rot_id"
|
||||||
|
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
|
||||||
|
keySatRotStep = "sat.rot_step" // degrees of change worth a command
|
||||||
|
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
|
||||||
|
|
||||||
|
// The satellite page used to configure its own EasyComm or PstRotator link.
|
||||||
|
// These keys are read once by migrateSatRotator, which turns what they hold
|
||||||
|
// into a real entry in the rotator list, and are never written again.
|
||||||
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
||||||
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
||||||
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
|
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
|
||||||
@@ -51,9 +56,6 @@ const (
|
|||||||
keySatRotCOM = "sat.rot_com"
|
keySatRotCOM = "sat.rot_com"
|
||||||
keySatRotBaud = "sat.rot_baud"
|
keySatRotBaud = "sat.rot_baud"
|
||||||
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
|
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.
|
||||||
@@ -74,15 +76,18 @@ type SatSettings struct {
|
|||||||
AltM int `json:"alt_m"`
|
AltM int `json:"alt_m"`
|
||||||
|
|
||||||
// The az/el rotator.
|
// The az/el rotator.
|
||||||
|
//
|
||||||
|
// RotID names one of the rotors configured in Settings ▸ Rotator — the
|
||||||
|
// key flattenRotors gives it. Everything about HOW to reach that rotator
|
||||||
|
// (backend, host, COM port, baud, 360/450) belongs to the rotator list and
|
||||||
|
// is deliberately not repeated here.
|
||||||
|
//
|
||||||
|
// What IS here is the tracking policy, which is the satellite page's own
|
||||||
|
// business and means nothing to a rotor turned by hand: below which
|
||||||
|
// elevation not to bother, how far the antenna must be off before a command
|
||||||
|
// is worth sending, and whether to park at the end.
|
||||||
RotOn bool `json:"rot_on"`
|
RotOn bool `json:"rot_on"`
|
||||||
RotType string `json:"rot_type"`
|
RotID string `json:"rot_id"`
|
||||||
RotPstPort int `json:"rot_pst_port"`
|
|
||||||
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"`
|
RotMinEl int `json:"rot_min_el"`
|
||||||
RotStep int `json:"rot_step"`
|
RotStep int `json:"rot_step"`
|
||||||
RotPark bool `json:"rot_park"`
|
RotPark bool `json:"rot_park"`
|
||||||
@@ -189,6 +194,11 @@ type SatPassInfo struct {
|
|||||||
// PC with no internet as much as on one with. The fetch is the slow, optional
|
// PC with no internet as much as on one with. The fetch is the slow, optional
|
||||||
// half and never blocks a launch.
|
// half and never blocks a launch.
|
||||||
func (a *App) startSatellites() {
|
func (a *App) startSatellites() {
|
||||||
|
// Before anything else reads the rotator choice: an operator upgrading from
|
||||||
|
// the version where the satellite page held its own rotator link must find
|
||||||
|
// that mast already in the list and already selected.
|
||||||
|
a.migrateSatRotator()
|
||||||
|
|
||||||
dir := a.dataDir
|
dir := a.dataDir
|
||||||
birds, err := sat.LoadBirds(dir)
|
birds, err := sat.LoadBirds(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,47 +249,23 @@ func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
|
|||||||
// ── Settings ────────────────────────────────────────────────────────────────
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (a *App) satSettings() SatSettings {
|
func (a *App) satSettings() SatSettings {
|
||||||
// The rotator defaults are the common case, not a blank form: EasyComm over
|
// A five-degree step, which on a beam with any gain at all is well inside
|
||||||
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
|
// the beamwidth and keeps a pass from being a command a second.
|
||||||
// beam with any gain at all is well inside the beamwidth and keeps a pass
|
|
||||||
// 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,
|
RotMinEl: 0, RotStep: 5,
|
||||||
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,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
||||||
keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
|
keySatRotOn, keySatRotID, 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.RotID = strings.TrimSpace(m[keySatRotID])
|
||||||
out.RotType = ty
|
|
||||||
}
|
|
||||||
if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
|
|
||||||
out.RotPstPort = v
|
|
||||||
}
|
|
||||||
if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
|
|
||||||
out.RotTransport = tr
|
|
||||||
}
|
|
||||||
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 {
|
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
|
||||||
out.RotMinEl = v
|
out.RotMinEl = v
|
||||||
}
|
}
|
||||||
@@ -337,27 +323,9 @@ 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" {
|
|
||||||
s.RotTransport = "serial"
|
|
||||||
}
|
|
||||||
if s.RotMaxAz != 450 {
|
|
||||||
s.RotMaxAz = 360
|
|
||||||
}
|
|
||||||
if s.RotStep < 1 || s.RotStep > 30 {
|
if s.RotStep < 1 || s.RotStep > 30 {
|
||||||
s.RotStep = 5
|
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),
|
||||||
@@ -366,14 +334,7 @@ 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,
|
keySatRotID: strings.TrimSpace(s.RotID),
|
||||||
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
|
|
||||||
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),
|
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
|
||||||
keySatRotStep: strconv.Itoa(s.RotStep),
|
keySatRotStep: strconv.Itoa(s.RotStep),
|
||||||
keySatRotPark: boolStr(s.RotPark),
|
keySatRotPark: boolStr(s.RotPark),
|
||||||
@@ -978,3 +939,80 @@ func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (Sa
|
|||||||
out.Visible = p.Visible()
|
out.Visible = p.Visible()
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateSatRotator moves a pre-list satellite rotator into Settings ▸ Rotator.
|
||||||
|
//
|
||||||
|
// Until now the satellite page configured its own EasyComm or PstRotator link,
|
||||||
|
// separately from the rotator list every other backend lived in. An operator who
|
||||||
|
// had set one up must not open OpsLog to an empty dropdown and a mast that no
|
||||||
|
// longer turns — so the old keys are read once, turned into a real rotor in the
|
||||||
|
// list, and the satellite page is pointed at it.
|
||||||
|
//
|
||||||
|
// Runs once. The legacy keys are cleared afterwards so a second run cannot add
|
||||||
|
// the same mast a second time, and so the next reader of this file is not left
|
||||||
|
// wondering which of the two copies is live.
|
||||||
|
func (a *App) migrateSatRotator() {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keySatRotID, keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
|
||||||
|
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m[keySatRotID]) != "" {
|
||||||
|
return // already migrated, or configured since
|
||||||
|
}
|
||||||
|
legacy := strings.TrimSpace(m[keySatRotType])
|
||||||
|
if legacy == "" {
|
||||||
|
return // the satellite rotator was never configured
|
||||||
|
}
|
||||||
|
|
||||||
|
atoi := func(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||||
|
dev := RotatorDevice{
|
||||||
|
ID: fmt.Sprintf("rotor-sat-%d", time.Now().Unix()),
|
||||||
|
Name: "Satellite",
|
||||||
|
MaxAz: atoi(m[keySatRotMaxAz]),
|
||||||
|
// A satellite rotor carries a fixed antenna, not a motorized Ultrabeam
|
||||||
|
// or SteppIR: showing it pattern paths would be showing it something it
|
||||||
|
// cannot do.
|
||||||
|
Motorized: false,
|
||||||
|
}
|
||||||
|
switch legacy {
|
||||||
|
case satRotPst:
|
||||||
|
dev.Type = "pst"
|
||||||
|
dev.Host = strings.TrimSpace(m[keySatRotHost])
|
||||||
|
dev.Port = atoi(m[keySatRotPstPort])
|
||||||
|
// It was in the satellite settings, so it has elevation by construction.
|
||||||
|
dev.HasElevation = true
|
||||||
|
default:
|
||||||
|
dev.Type = "easycomm"
|
||||||
|
dev.Transport = strings.TrimSpace(m[keySatRotTransport])
|
||||||
|
dev.Host = strings.TrimSpace(m[keySatRotHost])
|
||||||
|
dev.Port = atoi(m[keySatRotPort])
|
||||||
|
dev.ComPort = strings.TrimSpace(m[keySatRotCOM])
|
||||||
|
dev.Baud = atoi(m[keySatRotBaud])
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := a.GetRotators()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("satellite: cannot read the rotator list to migrate the satellite rotator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list = append(list, dev)
|
||||||
|
if err := a.SaveRotators(list); err != nil {
|
||||||
|
applog.Printf("satellite: cannot save the migrated satellite rotator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keySatRotID, dev.ID); err != nil {
|
||||||
|
applog.Printf("satellite: migrated the rotator but could not select it: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Clear the old keys so this cannot run twice.
|
||||||
|
for _, k := range []string{keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
|
||||||
|
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz} {
|
||||||
|
_ = a.settings.Set(a.ctx, k, "")
|
||||||
|
}
|
||||||
|
applog.Printf("satellite: the %s rotator configured on the satellite page is now %q in Settings ▸ Rotator", legacy, dev.Name)
|
||||||
|
}
|
||||||
|
|||||||
+156
-34
@@ -1,16 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The two ways a satellite station points its antenna.
|
// How a satellite station points its antenna.
|
||||||
//
|
//
|
||||||
// Some operators drive their az/el rotator directly — EasyComm II, what
|
// It does NOT configure a rotator. Every rotator interface OpsLog knows lives in
|
||||||
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
|
// Settings ▸ Rotator, once, and the satellite page only CHOOSES one of them.
|
||||||
// them and a dozen different controllers and handles az AND el; for those,
|
// The two used to be separate: EasyComm and PstRotator were described inside the
|
||||||
// OpsLog talking to the controller itself would be a second program fighting
|
// satellite settings while five other backends were described in the rotator
|
||||||
// PstRotator over the same cable.
|
// list, so an operator with one mast described it twice — and could describe it
|
||||||
|
// differently the second time, which is a station that works on HF and not on a
|
||||||
|
// pass, for no reason anyone can see.
|
||||||
//
|
//
|
||||||
// So both, behind one small interface, chosen in Settings. Neither is more
|
// What remains here is the adapter: turning whichever backend the operator
|
||||||
// "correct" than the other: the right one is whichever the station already has
|
// picked into the three things a pass needs — point it, ask where it is, let go
|
||||||
// working.
|
// of it at the end.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -18,8 +20,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"hamlog/internal/rotator/easycomm"
|
"hamlog/internal/rotator/gs232"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
|
"hamlog/internal/rotator/spid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
||||||
@@ -33,41 +36,165 @@ type satRotator interface {
|
|||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rotator kinds, as stored.
|
// The legacy satellite-only rotator kinds. They are no longer stored; they
|
||||||
|
// survive only so migrateSatRotator can read what an operator configured before
|
||||||
|
// the rotator list existed.
|
||||||
const (
|
const (
|
||||||
satRotEasycomm = "easycomm"
|
satRotEasycomm = "easycomm"
|
||||||
satRotPst = "pstrotator"
|
satRotPst = "pstrotator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newSatRotator builds the configured controller.
|
// newSatRotator builds a controller for the rotor the satellite page selected.
|
||||||
func newSatRotator(s SatSettings) (satRotator, error) {
|
func (a *App) newSatRotator(s SatSettings) (satRotator, error) {
|
||||||
switch s.RotType {
|
if strings.TrimSpace(s.RotID) == "" {
|
||||||
case satRotPst:
|
return nil, fmt.Errorf("no rotator chosen for satellite tracking — pick one in Settings ▸ Satellite")
|
||||||
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
|
lr, ok := a.rotorByKey(s.RotID)
|
||||||
|
if !ok {
|
||||||
|
// The rotor was deleted from the list after being chosen here. Say that,
|
||||||
|
// rather than failing to connect to an address nobody can see any more.
|
||||||
|
return nil, fmt.Errorf("the rotator chosen for satellite tracking no longer exists in Settings ▸ Rotator")
|
||||||
|
}
|
||||||
|
if !lr.HasEl {
|
||||||
|
name := strings.TrimSpace(lr.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "this rotator"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s has no elevation axis — a satellite pass needs one", name)
|
||||||
|
}
|
||||||
|
l := lr.Link
|
||||||
|
switch l.Type {
|
||||||
|
case "pst":
|
||||||
|
return &pstSatRotator{c: pst.New(l.Host, l.Port), maxAz: l.MaxAz}, nil
|
||||||
|
case "easycomm":
|
||||||
|
return easycommClient(l), nil
|
||||||
|
case "erc":
|
||||||
|
return &gs232SatRotator{c: ercClient(l), maxAz: l.MaxAz}, nil
|
||||||
|
case "spid":
|
||||||
|
return &spidSatRotator{c: spidClient(l)}, nil
|
||||||
default:
|
default:
|
||||||
if s.RotTransport == "tcp" {
|
return nil, fmt.Errorf("the %s backend cannot be pointed in elevation", l.Type)
|
||||||
if strings.TrimSpace(s.RotHost) == "" {
|
|
||||||
return nil, fmt.Errorf("no address for the rotator")
|
|
||||||
}
|
}
|
||||||
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
|
}
|
||||||
|
|
||||||
|
// SatelliteRotorChoice is one entry in the satellite page's rotator dropdown.
|
||||||
|
type SatelliteRotorChoice struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
// Name is the operator's label; Type is the backend's, for the rotors left
|
||||||
|
// unnamed (a list of three blank rows is a list of one rotor as far as
|
||||||
|
// anybody can tell).
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
HasEl bool `json:"has_el"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSatelliteRotors returns every configured rotor, elevation-capable or not.
|
||||||
|
//
|
||||||
|
// Not filtered to the az/el ones, deliberately. An operator who owns exactly one
|
||||||
|
// rotator and does not see it in this list concludes OpsLog cannot find it; shown
|
||||||
|
// with "azimuth only" beside it, they learn the actual thing — that the tracker
|
||||||
|
// needs an elevation axis and this mast has none.
|
||||||
|
func (a *App) ListSatelliteRotors() ([]SatelliteRotorChoice, error) {
|
||||||
|
devs, err := a.GetRotators()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(s.RotCOM) == "" {
|
out := []SatelliteRotorChoice{}
|
||||||
return nil, fmt.Errorf("no COM port for the rotator")
|
for _, r := range flattenRotors(devs) {
|
||||||
|
out = append(out, SatelliteRotorChoice{
|
||||||
|
Key: r.Key, Name: r.Name, Type: rotorTypeInfo(r.Link.Type).Label, HasEl: r.HasEl,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gs232SatRotator points an ERC-M (or any GS-232 az/el controller) through the
|
||||||
|
// W command.
|
||||||
|
//
|
||||||
|
// The 450° overlap is handled HERE and not in the package, the same way the
|
||||||
|
// EasyComm client does it: a controller reports 0-450 and takes 0-450, but the
|
||||||
|
// tracker works in true bearings, and which of the two ways round to reach 010°
|
||||||
|
// depends on where the mast currently is.
|
||||||
|
type gs232SatRotator struct {
|
||||||
|
c *gs232.Client
|
||||||
|
maxAz int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gs232SatRotator) Point(az, el float64) error {
|
||||||
|
return g.c.GoToAzEl(int(math.Round(satWrapAz(az, g.maxAz))), int(math.Round(clampEl(el))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gs232SatRotator) Heading() (float64, float64, bool, error) {
|
||||||
|
az, el, _, err := g.c.Position()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
}
|
}
|
||||||
|
return float64(az), float64(el), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close: nothing to release. The serial port is held by the gs232 package, which
|
||||||
|
// keeps it open across the whole session on purpose — an Arduino-based
|
||||||
|
// controller reboots every time its port is opened.
|
||||||
|
func (g *gs232SatRotator) Close() {}
|
||||||
|
|
||||||
|
// spidSatRotator points a SPID Rot2Prog. Its protocol is absolute and binary,
|
||||||
|
// with no overlap notion to manage: the controller is told a bearing and a
|
||||||
|
// resolution and works out its own path.
|
||||||
|
type spidSatRotator struct{ c *spid.Client }
|
||||||
|
|
||||||
|
func (s *spidSatRotator) Point(az, el float64) error {
|
||||||
|
a := math.Mod(az, 360)
|
||||||
|
if a < 0 {
|
||||||
|
a += 360
|
||||||
|
}
|
||||||
|
return s.c.GoTo(int(math.Round(a)), int(math.Round(clampEl(el))))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spidSatRotator) Heading() (float64, float64, bool, error) {
|
||||||
|
az, el, err := s.c.Heading()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
return float64(az), float64(el), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spidSatRotator) Close() {}
|
||||||
|
|
||||||
|
// satWrapAz maps a true bearing onto what the controller accepts. On a 450°
|
||||||
|
// mast the far end of the overlap is reachable two ways and the higher number is
|
||||||
|
// chosen for the last 90°, which is what keeps a pass crossing north from
|
||||||
|
// unwinding the cable in the middle of it.
|
||||||
|
func satWrapAz(az float64, maxAz int) float64 {
|
||||||
|
a := math.Mod(az, 360)
|
||||||
|
if a < 0 {
|
||||||
|
a += 360
|
||||||
|
}
|
||||||
|
if maxAz == 450 && a < 90 {
|
||||||
|
return a + 360
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampEl keeps the elevation inside what a mast will accept. 180 and not 90: a
|
||||||
|
// G-5500 goes past the zenith and keeps counting, which is how an overhead pass
|
||||||
|
// is followed without swinging the azimuth 180° through the middle of it.
|
||||||
|
func clampEl(el float64) float64 {
|
||||||
|
if el < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if el > 180 {
|
||||||
|
return 180
|
||||||
|
}
|
||||||
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
// pstSatRotator points the antenna through PstRotator.
|
// pstSatRotator points the antenna through PstRotator.
|
||||||
//
|
//
|
||||||
// PstRotator takes whole degrees and does its own overlap handling for a 450°
|
// 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.
|
// 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
|
// So the azimuth is sent plainly, and the 450° logic that the direct backends
|
||||||
// deliberately NOT applied here: two programs each deciding to go the long way
|
// need is deliberately NOT applied here: two programs each deciding to go the
|
||||||
// round is how an antenna ends up unwinding in the middle of a pass.
|
// long way round is how an antenna ends up unwinding in the middle of a pass.
|
||||||
type pstSatRotator struct {
|
type pstSatRotator struct {
|
||||||
c *pst.Client
|
c *pst.Client
|
||||||
maxAz int
|
maxAz int
|
||||||
@@ -87,12 +214,7 @@ func (p *pstSatRotator) Point(az, el float64) error {
|
|||||||
if a < 0 {
|
if a < 0 {
|
||||||
a += 360
|
a += 360
|
||||||
}
|
}
|
||||||
if el < 0 {
|
el = clampEl(el)
|
||||||
el = 0
|
|
||||||
}
|
|
||||||
if el > 180 {
|
|
||||||
el = 180
|
|
||||||
}
|
|
||||||
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -131,7 +131,7 @@ 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 {
|
||||||
r, rerr := newSatRotator(set)
|
r, rerr := a.newSatRotator(set)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
applog.Printf("sat: no rotator: %v", rerr)
|
applog.Printf("sat: no rotator: %v", rerr)
|
||||||
t.status.Error = rerr.Error()
|
t.status.Error = rerr.Error()
|
||||||
@@ -193,7 +193,7 @@ 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")
|
||||||
}
|
}
|
||||||
c, err := newSatRotator(set)
|
c, err := a.newSatRotator(set)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.20",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Every rotator interface now lives in Settings ▸ Rotator, and the satellite page only picks one of them. EasyComm and PstRotator used to be described inside the satellite settings while the other backends were described in the rotator list, so one mast was configured twice. What you already set up is moved into the list for you and selected.",
|
||||||
|
"Each rotator interface says whether it drives azimuth alone or azimuth and elevation, beside the interface itself. The satellite rotator list shows the azimuth-only ones greyed out rather than hiding them, so a rotor that cannot follow a pass says why.",
|
||||||
|
"New rotator interface: ERC-M by DF9GR, the azimuth/elevation controller for a Yaesu G-5500. Over its USB COM port or the network, with its emulation set to GS-232. Untested on hardware — reports welcome.",
|
||||||
|
"EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait qu’en choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.",
|
||||||
|
"Chaque interface de rotor indique si elle pilote l’azimut seul ou l’azimut et l’élévation, juste à côté de l’interface. La liste des rotors de la page satellite affiche les azimut-seul en grisé plutôt que de les cacher : un rotor qui ne peut pas suivre un passage dit pourquoi.",
|
||||||
|
"Nouvelle interface de rotor : ERC-M de DF9GR, le contrôleur azimut/élévation pour un Yaesu G-5500. Via son port COM USB ou le réseau, avec son émulation réglée sur GS-232. Non testé sur matériel — vos retours sont les bienvenus.",
|
||||||
|
"EasyComm II devient une interface de rotor comme les autres : elle peut tourner l’antenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.19",
|
"version": "0.27.19",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import {
|
|||||||
GetChaseSettings, SaveChaseSettings,
|
GetChaseSettings, SaveChaseSettings,
|
||||||
GetAudioMonitorPref,
|
GetAudioMonitorPref,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotatorTypes,
|
||||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetPSUSettings, SavePSUSettings,
|
GetPSUSettings, SavePSUSettings,
|
||||||
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
|
GetSatSettings, SaveSatSettings, TestSatelliteRotator, ListSatelliteRotors,
|
||||||
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
|
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
@@ -1896,6 +1896,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// While true, the next key press is captured as the PTT hotkey.
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||||
|
// What each rotator backend is and what it can do, straight from Go. The
|
||||||
|
// panel used to hold its own copy of the list — labels, default ports, which
|
||||||
|
// ones offer a COM port — and a second copy of that knowledge is a copy that
|
||||||
|
// drifts. It also carries the answer the satellite page needs: which
|
||||||
|
// interfaces drive an elevation axis.
|
||||||
|
const [rotTypes, setRotTypes] = useState<any[]>([]);
|
||||||
|
// The rotors the satellite page may choose between. Read from the same list,
|
||||||
|
// so a mast is described once.
|
||||||
|
const [satRotors, setSatRotors] = useState<any[]>([]);
|
||||||
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||||
// Whether the presets have actually been READ back yet.
|
// Whether the presets have actually been READ back yet.
|
||||||
//
|
//
|
||||||
@@ -1922,7 +1931,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_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 [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_id: '', 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),
|
||||||
@@ -2517,6 +2526,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotors((r ?? []) as any);
|
setRotors((r ?? []) as any);
|
||||||
|
try { setRotTypes(((await GetRotatorTypes()) ?? []) as any[]); } catch {}
|
||||||
|
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
|
||||||
// Loaded HERE, in the loader that runs on mount — not only in the
|
// Loaded HERE, in the loader that runs on mount — not only in the
|
||||||
// event-driven one below. Missing from this one, the state stayed empty
|
// event-driven one below. Missing from this one, the state stayed empty
|
||||||
// on a normal open and Save then wrote an empty list over the operator's
|
// on a normal open and Save then wrote an empty list over the operator's
|
||||||
@@ -2579,6 +2590,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||||||
|
try { setRotTypes(((await GetRotatorTypes()) ?? []) as any[]); } catch {}
|
||||||
|
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
|
||||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
@@ -2780,6 +2793,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotators(rotors as any);
|
await SaveRotators(rotors as any);
|
||||||
|
// The satellite page picks from this list. Re-read it after a save so a
|
||||||
|
// rotor added on the Rotator panel can be chosen straight away, without
|
||||||
|
// closing the window first.
|
||||||
|
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
|
||||||
// Only once they have been read back — see rotorPresetsLoaded.
|
// Only once they have been read back — see rotorPresetsLoaded.
|
||||||
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
@@ -4673,113 +4690,40 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
|
|
||||||
{!!satCfg.rot_on && (
|
{!!satCfg.rot_on && (
|
||||||
<>
|
<>
|
||||||
{/* Who drives the mast. Not a detail: a station already running
|
{/* WHICH rotor, not how to reach it. Every interface is
|
||||||
PstRotator must NOT have OpsLog on the same cable as well. */}
|
described once, in Settings ▸ Rotator; this page only picks
|
||||||
<div className="grid grid-cols-4 gap-3">
|
one of them. Describing one mast in two places is how a
|
||||||
{/* Two columns wide: "OpsLog (EasyComm II)" does not fit in a
|
station ends up working on HF and not on a pass. */}
|
||||||
third of the row, and a truncated choice is a choice an
|
<div className="space-y-1 max-w-md">
|
||||||
operator cannot read. */}
|
<Label>{t('satset.rotPick')}</Label>
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<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">
|
|
||||||
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
|
<Select value={satCfg.rot_id || '_'} onValueChange={(v) => set('rot_id', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('satset.rotPickNone')} /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
{satRotors.length === 0 && <SelectItem value="_" disabled>{t('satset.rotNoneConfigured')}</SelectItem>}
|
||||||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
{/* The azimuth-only rotors are LISTED, and disabled. An
|
||||||
</SelectContent>
|
operator who owns one rotator and does not see it
|
||||||
</Select>
|
concludes OpsLog cannot find it; shown greyed with
|
||||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
"azimuth only" beside it, they learn the real thing. */}
|
||||||
↻
|
{satRotors.map((r: any) => (
|
||||||
</Button>
|
<SelectItem key={r.key} value={r.key} disabled={!r.has_el}>
|
||||||
</div>
|
{(r.name || r.type) + (r.has_el ? '' : ` — ${t('satset.rotAzOnly')}`)}
|
||||||
</div>
|
</SelectItem>
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotBaud')}</Label>
|
|
||||||
<Select value={String(satCfg.rot_baud || 9600)} onValueChange={(v) => set('rot_baud', parseInt(v, 10) || 9600)}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
|
|
||||||
<SelectItem key={b} value={String(b)}>{b}</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9"
|
||||||
|
onClick={() => ListSatelliteRotors().then((r) => setSatRotors((r ?? []) as any[])).catch(() => {})}>
|
||||||
|
↻
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<p className="text-xs text-muted-foreground">{t('satset.rotPickHint')}</p>
|
||||||
|
{satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
|
||||||
|
<p className="text-xs text-[var(--warning)]">{t('satset.rotNoElAtAll')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{/* The rotator's range is ours to know only when we drive the
|
|
||||||
controller. PstRotator knows which machine is on the other
|
|
||||||
end and does its own overlap; two programs each deciding to
|
|
||||||
go the long way round is how an antenna unwinds mid-pass. */}
|
|
||||||
{satCfg.rot_type !== 'pstrotator' && (
|
|
||||||
<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">
|
<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)}
|
||||||
@@ -5138,7 +5082,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const addRotor = () => setRotors((l) => [...l, {
|
const addRotor = () => setRotors((l) => [...l, {
|
||||||
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||||
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
||||||
transport: 'tcp', com_port: '', baud: 9600,
|
transport: 'tcp', com_port: '', baud: 9600, max_az: 360,
|
||||||
} as any]);
|
} as any]);
|
||||||
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
||||||
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
||||||
@@ -5153,18 +5097,45 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const dev = d as any;
|
const dev = d as any;
|
||||||
const isRG = dev.type === 'rotgenius';
|
const isRG = dev.type === 'rotgenius';
|
||||||
const isARCO = dev.type === 'arco';
|
const isARCO = dev.type === 'arco';
|
||||||
|
const isERC = dev.type === 'erc';
|
||||||
const isDCU1 = dev.type === 'dcu1';
|
const isDCU1 = dev.type === 'dcu1';
|
||||||
// A SPID has a COM port and nothing else — no network transport to
|
// A SPID has a COM port and nothing else — no network transport to
|
||||||
// offer, which is the whole point of driving it without PstRotator.
|
// offer, which is the whole point of driving it without PstRotator.
|
||||||
const isSPID = dev.type === 'spid';
|
const isSPID = dev.type === 'spid';
|
||||||
const isSerialCap = isARCO || isDCU1 || isSPID; // COM-port or serial-over-IP controllers
|
const isEasycomm = dev.type === 'easycomm';
|
||||||
|
const isSerialCap = isARCO || isERC || isDCU1 || isSPID || isEasycomm; // COM-port or serial-over-IP controllers
|
||||||
const transport = dev.transport ?? 'tcp';
|
const transport = dev.transport ?? 'tcp';
|
||||||
|
// What this backend can do, from Go. The panel asks rather than
|
||||||
|
// deciding: the same table answers the satellite page's question
|
||||||
|
// about which rotors have an elevation axis, and two tables would
|
||||||
|
// eventually disagree about one rotor.
|
||||||
|
const info = rotTypes.find((k) => k.id === (dev.type ?? 'pst'));
|
||||||
|
// PstRotator is the only backend where the elevation belongs to the
|
||||||
|
// station rather than to the protocol — it forwards EL happily to a
|
||||||
|
// mast that has no elevation motor, so only the operator knows.
|
||||||
|
const elOptional = !!info?.elevation_optional;
|
||||||
|
const hasEl = elOptional ? !!dev.has_elevation
|
||||||
|
: (isSPID ? (dev.spid_model || 'rot2prog') !== 'rot1prog' : !!info?.elevation);
|
||||||
|
// The mast's range is ours to know only when OpsLog drives the
|
||||||
|
// controller. PstRotator knows which machine is on the other end and
|
||||||
|
// does its own overlap; two programs each deciding to go the long
|
||||||
|
// way round is how an antenna unwinds mid-pass.
|
||||||
|
const ownsOverlap = isERC || isEasycomm;
|
||||||
return (
|
return (
|
||||||
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Compass className="size-4 text-primary shrink-0" />
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
||||||
onChange={(e) => patch(i, { name: e.target.value })} />
|
onChange={(e) => patch(i, { name: e.target.value })} />
|
||||||
|
{/* Which axes this interface drives, beside the interface
|
||||||
|
itself. Without it the only way to find out is to pick a
|
||||||
|
rotor on the satellite page and be told no. */}
|
||||||
|
<span className={cn('shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium border',
|
||||||
|
hasEl ? 'border-[var(--success)]/40 text-[var(--success)] bg-[var(--success)]/10'
|
||||||
|
: 'border-border text-muted-foreground bg-muted/40')}
|
||||||
|
title={hasEl ? t('rot.capAzElHint') : t('rot.capAzHint')}>
|
||||||
|
{hasEl ? t('rot.capAzEl') : t('rot.capAz')}
|
||||||
|
</span>
|
||||||
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
@@ -5173,17 +5144,28 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
{/* The list, the labels, each backend's default port and its
|
||||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
default baud all come from Go — see rotatorTypes. A
|
||||||
|
SPID's 600 baud and an ERC-M's 19200 are not typos, and a
|
||||||
|
wrong rate reads exactly like a dead controller. */}
|
||||||
<Select value={dev.type ?? 'pst'}
|
<Select value={dev.type ?? 'pst'}
|
||||||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' || v === 'spid' ? { transport: 'serial' } : {}), ...(v === 'spid' ? { baud: 600, spid_model: 'rot2prog' } : {}) })}>
|
onValueChange={(v) => {
|
||||||
|
const k = rotTypes.find((x) => x.id === v);
|
||||||
|
patch(i, {
|
||||||
|
type: v as any,
|
||||||
|
port: k?.default_port || 12000,
|
||||||
|
baud: k?.default_baud || 9600,
|
||||||
|
// A backend with no network transport must not be left
|
||||||
|
// pointing at a TCP host it cannot use.
|
||||||
|
...(k && !k.network ? { transport: 'serial' } : {}),
|
||||||
|
...(v === 'spid' ? { spid_model: 'rot2prog' } : {}),
|
||||||
|
} as any);
|
||||||
|
}}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
{rotTypes.map((k) => (
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<SelectItem key={k.id} value={k.id}>{k.label}</SelectItem>
|
||||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
))}
|
||||||
<SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
|
|
||||||
<SelectItem value="spid">SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -5216,8 +5198,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
|
{/* Offered only when the backend really has both. A SPID has a
|
||||||
{isSerialCap && !isSPID && (
|
COM port and nothing else, which is the whole point of
|
||||||
|
driving it without PstRotator in front. */}
|
||||||
|
{!!info?.serial && !!info?.network && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Connection</Label>
|
<Label>Connection</Label>
|
||||||
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||||
@@ -5254,10 +5238,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{/* A SPID runs at 600 or 1200 baud — not a typo, a pulse
|
{/* A SPID runs at 600 or 1200 baud — not a typo, a pulse
|
||||||
controller has nothing to say quickly. Offering only the
|
controller has nothing to say quickly. Offering only the
|
||||||
usual rates would have left it permanently mute. */}
|
usual rates would have left it permanently mute. */}
|
||||||
<Select value={String(dev.baud || (isSPID ? 600 : 9600))} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
<Select value={String(dev.baud || info?.default_baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600]).map((b) => (
|
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600, 115200]).map((b) => (
|
||||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -5274,20 +5258,35 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
|
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||||
fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
|
fallback={info?.default_port || 12000} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRG && !isSerialCap && (
|
{elOptional && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
{t('rot.hasElevation')}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{/* 360 or 450, and only for the backends OpsLog drives itself. */}
|
||||||
|
{ownsOverlap && (
|
||||||
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
|
<Label>{t('rot.range')}</Label>
|
||||||
|
<Select value={String(dev.max_az === 450 ? 450 : 360)} onValueChange={(v) => patch(i, { max_az: parseInt(v, 10) } as any)}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="360">360°</SelectItem>
|
||||||
|
<SelectItem value="450">450°</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
|
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
|
||||||
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
|
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
|
||||||
|
{isERC && <p className="text-xs text-muted-foreground">{t('rot.ercHint')}</p>}
|
||||||
|
{isEasycomm && <p className="text-xs text-muted-foreground">{t('rot.easycommHint')}</p>}
|
||||||
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||||
{multi && (
|
{multi && (
|
||||||
<div className="space-y-1 max-w-xs">
|
<div className="space-y-1 max-w-xs">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -583,6 +583,8 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
|||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
|
export function GetRotatorTypes():Promise<Array<main.RotatorTypeInfo>>;
|
||||||
|
|
||||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||||
@@ -831,6 +833,8 @@ export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
||||||
|
|
||||||
|
export function ListSatelliteRotors():Promise<Array<main.SatelliteRotorChoice>>;
|
||||||
|
|
||||||
export function ListSerialPorts():Promise<Array<string>>;
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
|
|||||||
@@ -1098,6 +1098,10 @@ export function GetRotatorHeading() {
|
|||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRotatorTypes() {
|
||||||
|
return window['go']['main']['App']['GetRotatorTypes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotators() {
|
export function GetRotators() {
|
||||||
return window['go']['main']['App']['GetRotators']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
@@ -1594,6 +1598,10 @@ export function ListRadios() {
|
|||||||
return window['go']['main']['App']['ListRadios']();
|
return window['go']['main']['App']['ListRadios']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListSatelliteRotors() {
|
||||||
|
return window['go']['main']['App']['ListSatelliteRotors']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSerialPorts() {
|
export function ListSerialPorts() {
|
||||||
return window['go']['main']['App']['ListSerialPorts']();
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3881,6 +3881,7 @@ export namespace main {
|
|||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
spid_model?: string;
|
spid_model?: string;
|
||||||
|
max_az?: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorDevice(source);
|
return new RotatorDevice(source);
|
||||||
@@ -3903,6 +3904,7 @@ export namespace main {
|
|||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.spid_model = source["spid_model"];
|
this.spid_model = source["spid_model"];
|
||||||
|
this.max_az = source["max_az"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
@@ -3910,6 +3912,8 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
elevation: number;
|
||||||
|
has_elevation: boolean;
|
||||||
rotors: string[];
|
rotors: string[];
|
||||||
active: number;
|
active: number;
|
||||||
motorized: boolean;
|
motorized: boolean;
|
||||||
@@ -3924,11 +3928,39 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.azimuth = source["azimuth"];
|
this.azimuth = source["azimuth"];
|
||||||
this.raw = source["raw"];
|
this.raw = source["raw"];
|
||||||
|
this.elevation = source["elevation"];
|
||||||
|
this.has_elevation = source["has_elevation"];
|
||||||
this.rotors = source["rotors"];
|
this.rotors = source["rotors"];
|
||||||
this.active = source["active"];
|
this.active = source["active"];
|
||||||
this.motorized = source["motorized"];
|
this.motorized = source["motorized"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class RotatorTypeInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
elevation: boolean;
|
||||||
|
elevation_optional: boolean;
|
||||||
|
serial: boolean;
|
||||||
|
network: boolean;
|
||||||
|
default_port: number;
|
||||||
|
default_baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotatorTypeInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.elevation = source["elevation"];
|
||||||
|
this.elevation_optional = source["elevation_optional"];
|
||||||
|
this.serial = source["serial"];
|
||||||
|
this.network = source["network"];
|
||||||
|
this.default_port = source["default_port"];
|
||||||
|
this.default_baud = source["default_baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RotorPreset {
|
export class RotorPreset {
|
||||||
label: string;
|
label: string;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
@@ -4138,14 +4170,7 @@ export namespace main {
|
|||||||
grid: string;
|
grid: string;
|
||||||
alt_m: number;
|
alt_m: number;
|
||||||
rot_on: boolean;
|
rot_on: boolean;
|
||||||
rot_type: string;
|
rot_id: string;
|
||||||
rot_pst_port: number;
|
|
||||||
rot_transport: string;
|
|
||||||
rot_host: string;
|
|
||||||
rot_port: number;
|
|
||||||
rot_com: string;
|
|
||||||
rot_baud: number;
|
|
||||||
rot_max_az: number;
|
|
||||||
rot_min_el: number;
|
rot_min_el: number;
|
||||||
rot_step: number;
|
rot_step: number;
|
||||||
rot_park: boolean;
|
rot_park: boolean;
|
||||||
@@ -4163,14 +4188,7 @@ 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_id = source["rot_id"];
|
||||||
this.rot_pst_port = source["rot_pst_port"];
|
|
||||||
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_min_el = source["rot_min_el"];
|
||||||
this.rot_step = source["rot_step"];
|
this.rot_step = source["rot_step"];
|
||||||
this.rot_park = source["rot_park"];
|
this.rot_park = source["rot_park"];
|
||||||
@@ -4362,6 +4380,24 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class SatelliteRotorChoice {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
has_el: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatelliteRotorChoice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.key = source["key"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.has_el = source["has_el"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -16,9 +16,12 @@
|
|||||||
// GS-232A subset used:
|
// GS-232A subset used:
|
||||||
//
|
//
|
||||||
// Maaa<CR> move to azimuth aaa (000-450)
|
// Maaa<CR> move to azimuth aaa (000-450)
|
||||||
|
// Waaa eee<CR> move to azimuth aaa AND elevation eee (az/el controllers)
|
||||||
// S<CR> stop rotation
|
// S<CR> stop rotation
|
||||||
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
||||||
// flavour); both are parsed.
|
// flavour); both are parsed.
|
||||||
|
// C2<CR> query both axes — "+0aaa+0eee" / "AZ=aaa EL=eee"
|
||||||
|
// B<CR> query elevation alone, for the controllers that do not answer C2
|
||||||
package gs232
|
package gs232
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -242,3 +245,84 @@ func (c *Client) Heading() (az int, raw string, err error) {
|
|||||||
az, _ = strconv.Atoi(m[1])
|
az, _ = strconv.Atoi(m[1])
|
||||||
return az % 360, raw, nil
|
return az % 360, raw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Elevation: the az/el controllers ---
|
||||||
|
//
|
||||||
|
// The ERC-M (Easy Rotor Control, DF9GR) is the reason this half exists. It
|
||||||
|
// drives a Yaesu G-5500 — the az/el pair most satellite stations own — and
|
||||||
|
// emulates GS-232 over its USB port, so the same three commands that already
|
||||||
|
// pointed an azimuth rotator point a satellite antenna once elevation is added.
|
||||||
|
//
|
||||||
|
// A plain ERC or a microHAM ARCO answers the azimuth commands and ignores
|
||||||
|
// these; that is why the elevation capability is a property of the configured
|
||||||
|
// TYPE and not something probed at runtime. Asking a controller with no
|
||||||
|
// elevation motor where its elevation is gets an answer, and the answer is
|
||||||
|
// zero, for ever.
|
||||||
|
|
||||||
|
// GoToAzEl points an az/el controller at both axes in one command. GS-232's W
|
||||||
|
// takes the two angles separated by a space, azimuth first.
|
||||||
|
//
|
||||||
|
// Elevation is clamped to 0-180 rather than 0-90: a G-5500 goes past the zenith
|
||||||
|
// and keeps counting, which is how an overhead pass is followed without swinging
|
||||||
|
// the azimuth 180° through the middle of it.
|
||||||
|
func (c *Client) GoToAzEl(az, el int) error {
|
||||||
|
az = ((az % 360) + 360) % 360
|
||||||
|
if el < 0 {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
if el > 180 {
|
||||||
|
el = 180
|
||||||
|
}
|
||||||
|
_, err := c.roundTrip(fmt.Sprintf("W%03d %03d", az, el), false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// elRe matches the elevation half of a reply, in either flavour. The GS-232A
|
||||||
|
// form of C2 is "+0aaa+0eee" — two identically-shaped groups — so the azimuth
|
||||||
|
// is taken from the first match and the elevation from the second, which is
|
||||||
|
// what bothRe below does; this one is for the reply to a bare B.
|
||||||
|
var elRe = regexp.MustCompile(`(?:\+0|EL=)(\d{3})`)
|
||||||
|
|
||||||
|
// bothRe pulls both angles out of a C2 reply.
|
||||||
|
var bothRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})[^0-9+]*(?:\+0|EL=)(\d{3})`)
|
||||||
|
|
||||||
|
// Position queries both axes.
|
||||||
|
//
|
||||||
|
// C2 first, because one exchange is one chance for a serial line to go quiet.
|
||||||
|
// Controllers that answer C2 with the azimuth alone — some ERC firmware does —
|
||||||
|
// fall through to the two separate queries rather than reporting an elevation
|
||||||
|
// of zero, which would read as "the antenna is on the horizon" and is the one
|
||||||
|
// wrong answer that looks plausible.
|
||||||
|
func (c *Client) Position() (az, el int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("C2", true)
|
||||||
|
if err == nil {
|
||||||
|
if m := bothRe.FindStringSubmatch(raw); m != nil {
|
||||||
|
a, _ := strconv.Atoi(m[1])
|
||||||
|
e, _ := strconv.Atoi(m[2])
|
||||||
|
return a % 360, e, raw, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a, azRaw, aerr := c.Heading()
|
||||||
|
if aerr != nil {
|
||||||
|
return 0, 0, azRaw, aerr
|
||||||
|
}
|
||||||
|
e, elRaw, eerr := c.Elevation()
|
||||||
|
if eerr != nil {
|
||||||
|
return a, 0, azRaw + " " + elRaw, eerr
|
||||||
|
}
|
||||||
|
return a, e, azRaw + " " + elRaw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation queries the elevation axis alone.
|
||||||
|
func (c *Client) Elevation() (el int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("B", true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, raw, err
|
||||||
|
}
|
||||||
|
m := elRe.FindStringSubmatch(raw)
|
||||||
|
if m == nil {
|
||||||
|
return 0, raw, fmt.Errorf("unrecognised elevation reply %q", raw)
|
||||||
|
}
|
||||||
|
el, _ = strconv.Atoi(m[1])
|
||||||
|
return el, raw, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,3 +32,68 @@ func TestAzimuthReplies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The az/el replies an ERC-M sends back to C2, in both flavours. The GS-232A
|
||||||
|
// form is two identical "+0nnn" groups running together with nothing between
|
||||||
|
// them, which is exactly the shape that makes a naive azimuth regex match the
|
||||||
|
// ELEVATION when the azimuth is read a second time.
|
||||||
|
func TestPositionReplies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
wantAz, wantEl int
|
||||||
|
}{
|
||||||
|
{"+0140+0032\r\n", 140, 32}, // GS-232A, the ERC-M's own form
|
||||||
|
{"+0000+0000\r", 0, 0}, // parked
|
||||||
|
{"AZ=140 EL=032\r\n", 140, 32}, // GS-232B flavour
|
||||||
|
{"AZ=005 EL=090\r\n", 5, 90}, // straight up
|
||||||
|
{"+0270+0180\r\n", 270, 180}, // past the zenith, still counting
|
||||||
|
{"\r\n+0075+0005\r\n", 75, 5}, // a leftover terminator ahead of it
|
||||||
|
{"+0450+0045\r\n", 90, 45}, // 450° mast in its overlap
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m := bothRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("no position found in %q", c.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
az, _ := strconv.Atoi(m[1])
|
||||||
|
el, _ := strconv.Atoi(m[2])
|
||||||
|
if az%360 != c.wantAz || el != c.wantEl {
|
||||||
|
t.Errorf("%q → az %d el %d, want az %d el %d", c.raw, az%360, el, c.wantAz, c.wantEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller that answers C2 with the azimuth alone must NOT be read as
|
||||||
|
// "elevation zero" — that is a plausible-looking wrong answer, the antenna
|
||||||
|
// sitting on the horizon, and it would send the tracker chasing it.
|
||||||
|
func TestPositionRejectsAzimuthOnlyReply(t *testing.T) {
|
||||||
|
for _, raw := range []string{"+0140\r\n", "AZ=140\r\n", "?>\r\n"} {
|
||||||
|
if m := bothRe.FindStringSubmatch(strings.TrimSpace(raw)); m != nil {
|
||||||
|
t.Errorf("%q parsed as a two-axis reply (%v) — it is not one", raw, m[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reply to a bare B, for the controllers that do not answer C2.
|
||||||
|
func TestElevationReplies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"+0032\r\n", 32},
|
||||||
|
{"EL=032\r\n", 32},
|
||||||
|
{"+0000\r", 0},
|
||||||
|
{"+0090\r\n", 90},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m := elRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("no elevation found in %q", c.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got, _ := strconv.Atoi(m[1]); got != c.want {
|
||||||
|
t.Errorf("%q → %d, want %d", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Which rotors the satellite page may offer. Getting this wrong is not a
|
||||||
|
// cosmetic fault: a rotor listed as az/el that has no elevation motor is a
|
||||||
|
// tracker sending W commands into a controller that ignores them, and a pass
|
||||||
|
// spent wondering why the antenna never lifts.
|
||||||
|
func TestRotorHasElevation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dev RotatorDevice
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ERC-M drives both axes of a G-5500", RotatorDevice{Type: "erc"}, true},
|
||||||
|
{"EasyComm is an az/el protocol", RotatorDevice{Type: "easycomm"}, true},
|
||||||
|
{"an ARCO is azimuth only", RotatorDevice{Type: "arco"}, false},
|
||||||
|
{"a Rotator Genius is azimuth only", RotatorDevice{Type: "rotgenius"}, false},
|
||||||
|
{"a DCU-1 is azimuth only", RotatorDevice{Type: "dcu1"}, false},
|
||||||
|
|
||||||
|
// SPID: the dialect decides. Rot1Prog has no elevation in its reply
|
||||||
|
// format at all, so offering it would be offering a rotor that cannot
|
||||||
|
// answer the question.
|
||||||
|
{"SPID Rot2Prog has elevation", RotatorDevice{Type: "spid", SpidModel: "rot2prog"}, true},
|
||||||
|
{"SPID defaults to Rot2Prog", RotatorDevice{Type: "spid"}, true},
|
||||||
|
{"SPID Rot1Prog does not", RotatorDevice{Type: "spid", SpidModel: "rot1prog"}, false},
|
||||||
|
|
||||||
|
// PstRotator: the elevation belongs to the station, not the protocol.
|
||||||
|
// PstRotator will happily forward EL to a controller that has no
|
||||||
|
// elevation motor, so only the operator can answer this one.
|
||||||
|
{"PstRotator with elevation declared", RotatorDevice{Type: "pst", HasElevation: true}, true},
|
||||||
|
{"PstRotator without", RotatorDevice{Type: "pst"}, false},
|
||||||
|
|
||||||
|
// An unknown type falls back to PstRotator, and must not fall back to
|
||||||
|
// "has elevation" with it.
|
||||||
|
{"an unknown backend", RotatorDevice{Type: "nonsense"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := rotorHasElevation(c.dev); got != c.want {
|
||||||
|
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The satellite page stores a rotor KEY, not a list index: deleting the first
|
||||||
|
// rotor must not silently point the tracker at a different mast.
|
||||||
|
func TestFlattenRotorsKeys(t *testing.T) {
|
||||||
|
devs := []RotatorDevice{
|
||||||
|
{ID: "a", Name: "HF", Type: "pst"},
|
||||||
|
{ID: "b", Name: "Sat", Type: "erc"},
|
||||||
|
{ID: "c", Name: "RG", Type: "rotgenius", Dual: true, Name2: "RG 2"},
|
||||||
|
}
|
||||||
|
got := flattenRotors(devs)
|
||||||
|
want := []struct {
|
||||||
|
key string
|
||||||
|
hasEl bool
|
||||||
|
}{
|
||||||
|
{"a", false},
|
||||||
|
{"b", true},
|
||||||
|
{"c", false},
|
||||||
|
{"c#2", false}, // the second port of a Dual Rotator Genius
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d logical rotors, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Key != w.key || got[i].HasEl != w.hasEl {
|
||||||
|
t.Errorf("rotor %d: key %q el %v, want key %q el %v", i, got[i].Key, got[i].HasEl, w.key, w.hasEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each backend's default baud, because a blanket 9600 is silence on two of
|
||||||
|
// them: a SPID runs at 600 and an ERC-M ships at 19200, and a wrong rate reads
|
||||||
|
// exactly like a dead controller.
|
||||||
|
func TestRotorDefaultBaud(t *testing.T) {
|
||||||
|
cases := map[string]int{"spid": 600, "erc": 19200, "dcu1": 4800, "arco": 9600, "easycomm": 9600}
|
||||||
|
for typ, want := range cases {
|
||||||
|
if got := rotorTypeInfo(typ).DefaultBaud; got != want {
|
||||||
|
t.Errorf("%s: default baud %d, want %d", typ, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,13 +32,28 @@ one you pick.
|
|||||||
|
|
||||||
### Types (Settings → Rotator)
|
### Types (Settings → Rotator)
|
||||||
|
|
||||||
| Type | Connection | Notes |
|
Every rotator interface is configured here, once — including the az/el ones a
|
||||||
|---|---|---|
|
satellite pass needs. The satellite page does not configure a rotator; it picks
|
||||||
| **PstRotator** | UDP | Enable PstRotator's UDP listener (Setup → Communication → UDP). |
|
one of these.
|
||||||
| **Rotator Genius** (4O3A) | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
|
|
||||||
| **microHAM ARCO / GS-232A** | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
|
| Type | Axes | Connection | Notes |
|
||||||
| **Hy-Gain DCU-1** | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. Azimuth only. A DCU-1 is 4800 baud; others may differ — match the controller. |
|
|---|---|---|---|
|
||||||
| **SPID / AlfaSpid** | COM port | Native, so PstRotator is not needed in between. See below. |
|
| **PstRotator** | Az, or Az + El | UDP | Enable PstRotator's UDP listener (Setup → Communication → UDP). Tick *This rotator has an elevation axis* if the mast behind PstRotator has one — PstRotator itself will forward elevation to a rotor that cannot use it. |
|
||||||
|
| **Rotator Genius** (4O3A) | Az | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
|
||||||
|
| **GS-232 azimuth** (microHAM ARCO, ERC) | Az | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
|
||||||
|
| **ERC-M by DF9GR** | Az + El | USB COM or LAN | The az/el interface for a **Yaesu G-5500** and its relatives. GS-232 emulation, 19200 baud out of the box. Pick this rather than the GS-232 azimuth entry: it is what tells OpsLog the mast has an elevation motor. |
|
||||||
|
| **Hy-Gain DCU-1** | Az | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. A DCU-1 is 4800 baud; others may differ — match the controller. |
|
||||||
|
| **SPID / AlfaSpid** | Az + El (Rot2Prog) | COM port | Native, so PstRotator is not needed in between. See below. |
|
||||||
|
| **EasyComm II** | Az + El | COM port or TCP | What SatPC32, Gpredict, Hamlib and K3NG firmware speak — the usual choice for a home-built az/el controller. |
|
||||||
|
|
||||||
|
Each interface carries an **Az** or **Az + El** badge beside its name, so you can
|
||||||
|
see at a glance which of your rotors can follow a satellite.
|
||||||
|
|
||||||
|
**Rotator range (360° / 450°)** appears for the interfaces OpsLog drives itself.
|
||||||
|
A 450° mast follows a pass straight through north instead of unwinding. It is
|
||||||
|
deliberately absent for PstRotator: PstRotator knows which controller is on the
|
||||||
|
other end and does its own overlap, and two programs each deciding to go the
|
||||||
|
long way round is how an antenna unwinds mid-pass.
|
||||||
|
|
||||||
### SPID / AlfaSpid
|
### SPID / AlfaSpid
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user