feat(station): drive the motorized antenna from its widget

It showed pattern, elements and Retract — everything except where the antenna
is pointed, which is the thing an operator changes most. Tuning it meant opening
Settings or moving the rig.

A button per band, taken from the bands configured in Settings rather than a
list invented here: a band dropped there cannot be clicked here. They point at
where people actually work, not the arithmetic centre — 20 m centres on 14175
and nobody lives there, 10 m spans 1.7 MHz of which the top half is empty.

Up and down move 25 kHz, which is also the finest tracking threshold: a smaller
nudge would be undone by the next poll while tracking is on. They work from the
ANTENNA's frequency, not the rig's, or walking the antenna across a band while
the rig stays put would be undone on the first press.

Tracking moved within reach because it is an operating decision — off to park
the antenna, on to resume — not something set up once. Its step only appears
when it is on: a threshold for something switched off is a question the operator
cannot act on.

MotorTuneKHz carries the CURRENT direction rather than resetting it. Both
controllers set frequency and pattern in one command, so a bare frequency would
silently drop a 180° or bidirectional pattern — a beam turning round is not an
acceptable side effect of clicking a band.
This commit is contained in:
2026-08-11 15:45:02 +02:00
parent 1ac00c101e
commit c4d90e6880
7 changed files with 203 additions and 6 deletions
+76
View File
@@ -14986,6 +14986,14 @@ type UltrabeamStatusInfo struct {
Band int `json:"band"`
Moving bool `json:"moving"`
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
// Follow and StepKHz are mirrored here so the Station Control widget can show
// and change tracking without loading the whole settings block for a poll.
Follow bool `json:"follow"`
StepKHz int `json:"step_khz"`
// Bands the antenna is configured to cover — the widget offers exactly these
// as buttons rather than inventing its own list, so a band dropped in Settings
// cannot be clicked here.
Bands []string `json:"bands"`
}
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
@@ -14994,6 +15002,9 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled
out.Type = s.Type
out.Follow = s.Follow
out.StepKHz = s.StepKHz
out.Bands = append(out.Bands, s.Bands...)
if a.motorAnt == nil {
return out
}
@@ -15051,6 +15062,71 @@ func (a *App) SetUltrabeamDirection(direction int) error {
return a.motorAnt.SetDirection(direction)
}
// MotorTuneKHz points the antenna at a frequency, in kHz.
//
// The direction is carried over from the antenna's current state rather than
// reset: both controllers set frequency and pattern in the same command, so
// sending a bare frequency would silently drop a 180° or bidirectional pattern
// the operator had chosen — a beam quietly turning round is not an acceptable
// side effect of clicking a band.
func (a *App) MotorTuneKHz(khz int) error {
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
}
// A generous envelope rather than a band table: the antennas differ, some
// carry extensions, and refusing a frequency the hardware would have accepted
// is worse than letting the controller say no itself.
if khz < 1000 || khz > 60000 {
return fmt.Errorf("frequency %d kHz is outside anything these antennas tune", khz)
}
a.noteMotorMoveCommanded()
return a.motorAnt.SetFrequency(khz, a.motorAnt.Status().Direction)
}
// MotorNudgeKHz moves the antenna's commanded frequency by a step, up or down.
//
// Works from the ANTENNA's frequency, not the rig's: this exists so an operator
// can walk the antenna across a band while the rig stays where it is, and
// starting from the rig would undo that on the first press. Falls back to the
// rig only when the antenna has not reported a frequency yet.
func (a *App) MotorNudgeKHz(deltaKHz int) error {
if a.motorAnt == nil {
return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
}
cur := a.motorAnt.Status().Frequency
if cur <= 0 && a.cat != nil {
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
cur = int(rs.FreqHz / 1000)
}
}
if cur <= 0 {
return fmt.Errorf("the antenna has not reported a frequency yet")
}
return a.MotorTuneKHz(cur + deltaKHz)
}
// SetMotorFollow turns frequency tracking on or off and sets its step, without
// opening Settings. Both are ordinary operating decisions — an operator turns
// tracking off to park the antenna and back on to resume — and a preferences
// dialog is the wrong place for something changed that often.
func (a *App) SetMotorFollow(on bool, stepKHz int) error {
s, err := a.GetUltrabeamSettings()
if err != nil {
return err
}
switch stepKHz {
case 25, 50, 100:
s.StepKHz = stepKHz
case 0: // leave the step alone
default:
return fmt.Errorf("step must be 25, 50 or 100 kHz")
}
s.Follow = on
// SaveUltrabeamSettings restarts the client, which is what actually starts or
// stops the follow loop — the setting alone would not.
return a.SaveUltrabeamSettings(s)
}
// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
if a.motorAnt == nil {