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/rigctld"
|
||||
"hamlog/internal/rotator/dcu1"
|
||||
"hamlog/internal/rotator/easycomm"
|
||||
"hamlog/internal/rotator/gs232"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/rotator/spid"
|
||||
@@ -16955,11 +16956,13 @@ const keyRotatorsList = "rotators.json"
|
||||
// contributes TWO logical rotors — Name/Motorized for the first, Name2/Motorized2
|
||||
// for the second.
|
||||
type RotatorDevice struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (GS-232A)
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// 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
|
||||
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)
|
||||
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
|
||||
@@ -16973,36 +16976,125 @@ type RotatorDevice struct {
|
||||
// azimuth + elevation) or "rot1prog" (the older azimuth-only controller).
|
||||
// They differ in reply length and baud rate, so guessing is not an option.
|
||||
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
|
||||
// Dual Rotator Genius into two.
|
||||
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
|
||||
Motorized bool
|
||||
HasEl bool
|
||||
Link rotorLink
|
||||
}
|
||||
|
||||
// normRotorType clamps a rotor type to a known backend.
|
||||
func normRotorType(t string) string {
|
||||
if t == "rotgenius" || t == "arco" || t == "dcu1" || t == "spid" {
|
||||
return t
|
||||
for _, k := range rotatorTypes {
|
||||
if k.ID == t {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return "pst"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
switch typ {
|
||||
case "rotgenius":
|
||||
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
|
||||
if p := rotorTypeInfo(typ).DefaultPort; p > 0 {
|
||||
return p
|
||||
}
|
||||
return 12000 // PstRotator UDP
|
||||
}
|
||||
|
||||
// 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 {
|
||||
l := rotorLink{
|
||||
Type: normRotorType(d.Type), Host: d.Host, Port: d.Port,
|
||||
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: d.HasElevation,
|
||||
SpidModel: d.SpidModel,
|
||||
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: rotorHasElevation(d),
|
||||
SpidModel: d.SpidModel, MaxAz: d.MaxAz,
|
||||
}
|
||||
if l.MaxAz != 450 {
|
||||
l.MaxAz = 360
|
||||
}
|
||||
if l.Host == "" {
|
||||
l.Host = "127.0.0.1"
|
||||
@@ -17050,18 +17145,39 @@ func deviceLink(d RotatorDevice, sub int) rotorLink {
|
||||
func flattenRotors(devs []RotatorDevice) []logicalRotor {
|
||||
var out []logicalRotor
|
||||
for _, d := range devs {
|
||||
el := rotorHasElevation(d)
|
||||
if normRotorType(d.Type) == "rotgenius" && d.Dual {
|
||||
out = append(out,
|
||||
logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)},
|
||||
logicalRotor{Name: d.Name2, Motorized: d.Motorized2, Link: deviceLink(d, 2)},
|
||||
logicalRotor{Key: d.ID, Name: d.Name, Motorized: d.Motorized, HasEl: el, Link: deviceLink(d, 1)},
|
||||
logicalRotor{Key: d.ID + "#2", Name: d.Name2, Motorized: d.Motorized2, HasEl: el, Link: deviceLink(d, 2)},
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
// 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-
|
||||
// rotor flat settings into the list on first read (persisted on next save).
|
||||
func (a *App) GetRotators() ([]RotatorDevice, error) {
|
||||
@@ -17140,7 +17256,16 @@ func (a *App) SaveRotators(list []RotatorDevice) error {
|
||||
d.Transport = "tcp"
|
||||
}
|
||||
if d.Baud <= 0 {
|
||||
d.Baud = 9600
|
||||
// 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 {
|
||||
d.Baud = 9600
|
||||
}
|
||||
}
|
||||
if d.MaxAz != 450 {
|
||||
d.MaxAz = 360
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
@@ -17162,6 +17287,7 @@ type rotorLink struct {
|
||||
Baud int
|
||||
HasElevation bool
|
||||
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
|
||||
@@ -17199,6 +17325,34 @@ func arcoClient(l rotorLink) *gs232.Client {
|
||||
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
|
||||
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
|
||||
// or a serial-over-IP bridge on TCP.
|
||||
@@ -17231,6 +17385,11 @@ type RotatorHeading struct {
|
||||
OK bool `json:"ok"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
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
|
||||
// rotor — without an extra roundtrip.
|
||||
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.Raw = raw
|
||||
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":
|
||||
az, _, herr := spidClient(link).Heading()
|
||||
az, el, herr := spidClient(link).Heading()
|
||||
if herr != nil {
|
||||
base.Raw = herr.Error()
|
||||
return base
|
||||
@@ -17300,6 +17486,10 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
base.OK = true
|
||||
base.Azimuth = 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
|
||||
case "dcu1":
|
||||
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)
|
||||
case "arco":
|
||||
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":
|
||||
return spidClient(link).GoTo(az, el)
|
||||
case "dcu1":
|
||||
@@ -17372,6 +17579,10 @@ func (a *App) RotatorStop() error {
|
||||
return rotgenius.New(link.Host, link.Port).Stop()
|
||||
case "arco":
|
||||
return arcoClient(link).Stop()
|
||||
case "erc":
|
||||
return ercClient(link).Stop()
|
||||
case "easycomm":
|
||||
return easycommClient(link).Stop()
|
||||
case "spid":
|
||||
return spidClient(link).Stop()
|
||||
case "dcu1":
|
||||
@@ -17501,8 +17712,12 @@ func (a *App) RotatorPark() error {
|
||||
switch link.Type {
|
||||
case "rotgenius":
|
||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||
case "arco":
|
||||
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
|
||||
case "arco", "erc":
|
||||
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":
|
||||
return fmt.Errorf("park is a PstRotator feature; a SPID controller has no park command")
|
||||
case "dcu1":
|
||||
@@ -17545,6 +17760,21 @@ func testRotorLink(l rotorLink) error {
|
||||
// GS-232 — without moving the antenna.
|
||||
_, _, err := arcoClient(l).Heading()
|
||||
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":
|
||||
if strings.TrimSpace(l.ComPort) == "" {
|
||||
return fmt.Errorf("select the SPID controller's COM port first")
|
||||
|
||||
Reference in New Issue
Block a user