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:
2026-09-09 11:04:04 +02:00
co-authored by Claude Opus 5
parent 8b1dff581b
commit ca81d4fc68
14 changed files with 1027 additions and 305 deletions
+83
View File
@@ -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)
}
}
}