Files
OpsLog/internal/rotator/gs232/reply_test.go
T
rouggyandClaude Opus 5 ca81d4fc68 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]>
2026-09-09 11:04:04 +02:00

100 lines
3.1 KiB
Go

package gs232
import (
"strconv"
"strings"
"testing"
)
// Real replies, as the controllers actually send them — a K3NG answering "C"
// with "+0140" and CR+LF among them (reported from a live controller).
func TestAzimuthReplies(t *testing.T) {
cases := []struct {
raw string
want int
}{
{"+0140\r\n", 140}, // GS-232A, K3NG firmware
{"+0000\r", 0}, // due north
{"+0359\r\n", 359}, // just short of it
{"AZ=140\r\n", 140}, // GS-232B flavour
{"AZ=140 EL=000\r\n", 140}, // GS-232B with elevation on the same line
{"\r\n+0075\r\n", 75}, // a leftover terminator ahead of the answer
}
for _, c := range cases {
m := azRe.FindStringSubmatch(strings.TrimSpace(c.raw))
if m == nil {
t.Errorf("no azimuth found in %q", c.raw)
continue
}
got, _ := strconv.Atoi(m[1])
if got%360 != c.want {
t.Errorf("%q parsed as %d, want %d", c.raw, got%360, c.want)
}
}
}
// 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)
}
}
}