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
+88 -4
View File
@@ -15,10 +15,13 @@
//
// GS-232A subset used:
//
// Maaa<CR> move to azimuth aaa (000-450)
// S<CR> stop rotation
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// flavour); both are parsed.
// 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
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// 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
import (
@@ -242,3 +245,84 @@ func (c *Client) Heading() (az int, raw string, err error) {
az, _ = strconv.Atoi(m[1])
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
}
+65
View File
@@ -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)
}
}
}