feat: New Station Control, allow to control Webswitch 1216H or KMTronic

This commit is contained in:
2026-07-16 22:01:07 +02:00
parent c9fd1379e1
commit 829c236d6c
13 changed files with 1344 additions and 48 deletions
+218 -25
View File
@@ -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