feat: New Station Control, allow to control Webswitch 1216H or KMTronic
This commit is contained in:
@@ -49,6 +49,8 @@ import (
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/relaydev"
|
||||
"hamlog/internal/rotgenius"
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/steppir"
|
||||
@@ -154,6 +156,8 @@ const (
|
||||
keyRotatorHost = "rotator.host"
|
||||
keyRotatorPort = "rotator.port"
|
||||
keyRotatorHasElevation = "rotator.has_elevation"
|
||||
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
||||
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
||||
|
||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||
@@ -169,6 +173,7 @@ const (
|
||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
|
||||
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
|
||||
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
|
||||
|
||||
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
||||
// port is fixed at 9007, so only the IP is configurable.
|
||||
@@ -10393,30 +10398,40 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
||||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||||
type RotatorSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
||||
Host string `json:"host"` // default 127.0.0.1
|
||||
Port int `json:"port"` // default 12000
|
||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets
|
||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
|
||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||
}
|
||||
|
||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||
out := RotatorSettings{Host: "127.0.0.1", Port: 12000}
|
||||
out := RotatorSettings{Type: "pst", Host: "127.0.0.1", Port: 12000, RotatorNum: 1}
|
||||
if a.settings == nil {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation)
|
||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Enabled = m[keyRotatorEnabled] == "1"
|
||||
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
|
||||
out.Type = t
|
||||
}
|
||||
if h := m[keyRotatorHost]; h != "" {
|
||||
out.Host = h
|
||||
}
|
||||
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
||||
out.Port = p
|
||||
} else if out.Type == "rotgenius" {
|
||||
out.Port = 9006 // native default when no port stored yet
|
||||
}
|
||||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||||
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
||||
out.RotatorNum = 2
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -10426,17 +10441,25 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Type != "rotgenius" {
|
||||
s.Type = "pst"
|
||||
}
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
}
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 12000
|
||||
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
|
||||
}
|
||||
if s.RotatorNum != 2 {
|
||||
s.RotatorNum = 1
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyRotatorEnabled: boolStr(s.Enabled),
|
||||
keyRotatorType: s.Type,
|
||||
keyRotatorHost: s.Host,
|
||||
keyRotatorPort: strconv.Itoa(s.Port),
|
||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -10445,18 +10468,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotatorClient returns a fresh PST UDP client built from current settings,
|
||||
// or an error if the rotator is disabled / misconfigured.
|
||||
func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return nil, s, err
|
||||
}
|
||||
if !s.Enabled {
|
||||
return nil, s, fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
return pst.New(s.Host, s.Port), s, nil
|
||||
}
|
||||
|
||||
// RotatorHeading is the live antenna heading for the status bar.
|
||||
type RotatorHeading struct {
|
||||
@@ -10473,6 +10484,16 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
if err != nil || !s.Enabled {
|
||||
return RotatorHeading{Enabled: false}
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
st, raw, herr := rotgenius.New(s.Host, s.Port).Heading(s.RotatorNum)
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
||||
}
|
||||
if !st.Connected {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: "sensor not connected (999)"}
|
||||
}
|
||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
||||
}
|
||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||
@@ -10483,30 +10504,49 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||||
// elevation if the rotator is configured for it).
|
||||
func (a *App) RotatorGoTo(az int, el int) error {
|
||||
c, s, err := a.rotatorClient()
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.GoTo(az, s.HasElevation, el)
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
||||
}
|
||||
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
||||
}
|
||||
|
||||
// RotatorStop interrupts any in-progress rotation.
|
||||
// RotatorStop interrupts any in-progress rotation, on either backend.
|
||||
func (a *App) RotatorStop() error {
|
||||
c, _, err := a.rotatorClient()
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Stop()
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
return rotgenius.New(s.Host, s.Port).Stop()
|
||||
}
|
||||
return pst.New(s.Host, s.Port).Stop()
|
||||
}
|
||||
|
||||
// RotatorPark moves the antenna to its parked position (configured in
|
||||
// PstRotator itself).
|
||||
// PstRotator itself). Park is a PstRotator concept — the 4O3A native protocol
|
||||
// has no park command.
|
||||
func (a *App) RotatorPark() error {
|
||||
c, _, err := a.rotatorClient()
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Park()
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||
}
|
||||
return pst.New(s.Host, s.Port).Park()
|
||||
}
|
||||
|
||||
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
||||
@@ -10517,6 +10557,19 @@ func (a *App) TestRotator(s RotatorSettings) error {
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 9006
|
||||
}
|
||||
rn := s.RotatorNum
|
||||
if rn != 2 {
|
||||
rn = 1
|
||||
}
|
||||
// Match the button ("Test — point to 0°"): actually command the move, like
|
||||
// the PstRotator path does. GoTo confirms the link AND the accept ('K')
|
||||
// reply, so a rejected command surfaces as an error instead of a false OK.
|
||||
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
||||
}
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 12000
|
||||
}
|
||||
@@ -10530,6 +10583,146 @@ func boolStr(b bool) string {
|
||||
return "0"
|
||||
}
|
||||
|
||||
// ── Station Control (relay boards) ─────────────────────────────────────
|
||||
|
||||
// StationDevice is one configured relay board for the Station Control tab.
|
||||
type StationDevice struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic"
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||
Pass string `json:"pass,omitempty"`
|
||||
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||
}
|
||||
|
||||
// relayCountFor returns a device type's fixed relay count.
|
||||
func relayCountFor(typ string) int {
|
||||
if typ == "kmtronic" {
|
||||
return 8
|
||||
}
|
||||
return 5 // webswitch 1216H
|
||||
}
|
||||
|
||||
// deviceDriver builds the wire driver for a configured device.
|
||||
func deviceDriver(d StationDevice) relaydev.Device {
|
||||
if d.Type == "kmtronic" {
|
||||
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||
}
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
|
||||
// GetStationDevices returns the configured relay boards (without live state).
|
||||
func (a *App) GetStationDevices() []StationDevice {
|
||||
out := []StationDevice{}
|
||||
if a.settings == nil {
|
||||
return out
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyStationDevices)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
|
||||
return []StationDevice{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SaveStationDevices persists the relay-board list. IDs and label lengths are
|
||||
// normalized so the UI can rely on them.
|
||||
func (a *App) SaveStationDevices(devs []StationDevice) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
for i := range devs {
|
||||
if devs[i].Type != "kmtronic" {
|
||||
devs[i].Type = "webswitch"
|
||||
}
|
||||
if strings.TrimSpace(devs[i].ID) == "" {
|
||||
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
|
||||
}
|
||||
n := relayCountFor(devs[i].Type)
|
||||
for len(devs[i].Labels) < n {
|
||||
devs[i].Labels = append(devs[i].Labels, "")
|
||||
}
|
||||
devs[i].Labels = devs[i].Labels[:n]
|
||||
}
|
||||
b, err := json.Marshal(devs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
|
||||
}
|
||||
|
||||
// StationRelay is one relay's live state for the UI.
|
||||
type StationRelay struct {
|
||||
Number int `json:"number"` // 1-based
|
||||
Label string `json:"label"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
|
||||
// StationDeviceStatus is a device plus its live relay states.
|
||||
type StationDeviceStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Connected bool `json:"connected"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Relays []StationRelay `json:"relays"`
|
||||
}
|
||||
|
||||
// GetStationStatus polls every configured board for its live relay states.
|
||||
// Boards are polled concurrently so one slow/offline device doesn't stall the rest.
|
||||
func (a *App) GetStationStatus() []StationDeviceStatus {
|
||||
devs := a.GetStationDevices()
|
||||
out := make([]StationDeviceStatus, len(devs))
|
||||
var wg sync.WaitGroup
|
||||
for i := range devs {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
d := devs[i]
|
||||
n := relayCountFor(d.Type)
|
||||
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
states, err := deviceDriver(d).Status(ctx)
|
||||
if err != nil {
|
||||
ds.Error = err.Error()
|
||||
} else {
|
||||
ds.Connected = true
|
||||
}
|
||||
ds.Relays = make([]StationRelay, n)
|
||||
for r := 0; r < n; r++ {
|
||||
label := ""
|
||||
if r < len(d.Labels) {
|
||||
label = d.Labels[r]
|
||||
}
|
||||
on := false
|
||||
if r < len(states) {
|
||||
on = states[r]
|
||||
}
|
||||
ds.Relays[r] = StationRelay{Number: r + 1, Label: label, On: on}
|
||||
}
|
||||
out[i] = ds
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
// StationSetRelay switches one relay on a configured board.
|
||||
func (a *App) StationSetRelay(id string, relay int, on bool) error {
|
||||
for _, d := range a.GetStationDevices() {
|
||||
if d.ID == id {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
return deviceDriver(d).Set(ctx, relay, on)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("station device %q not found", id)
|
||||
}
|
||||
|
||||
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
|
||||
|
||||
// motorAntenna is the shared control surface of a motorized antenna. The
|
||||
|
||||
Reference in New Issue
Block a user